@@ -275,6 +275,89 @@ def parse_junit(path):
275275# context = why this test exists, what it proves, what user sees
276276
277277SECTIONS = [
278+ ('S' , 'Display Binding - What the Device Signs Is What It Shows' , '7.14.2' ,
279+ 'The 7.14.2 security release changed what reaches the OLED on the signing paths. Every '
280+ 'defect it fixed was a case of the device hashing bytes it never rendered, or rendering '
281+ 'text it could not vouch for. These tests exist to capture those screens: a passing wire '
282+ 'assertion proves the device refused or signed, but only the screen proves the user was '
283+ 'told the truth about what they approved.' ,
284+ [
285+ 'DISCLOSURE RULE: every byte covered by the signature must be reachable on screen.' ,
286+ '' ,
287+ 'The defects this section guards against, all shipped at some point:' ,
288+ '- bytes past an embedded NUL were signed and never drawn ("%s" stops at 0x00)' ,
289+ '- whitespace padding pushed a tail past the cut with no warning' ,
290+ '- 456 bytes past the initial chunk were hashed with a clear-sign screen showing' ,
291+ ' confident token amounts for calldata the device had not seen' ,
292+ '- an unresolved token rendered as the literal "Unknown token value" and signed' ,
293+ '- a truncated memo dropped its last character (Confirm limit 42 vs 420)' ,
294+ '' ,
295+ 'A test here with an EMPTY screenshot list is deliberate: refusal paths draw nothing,' ,
296+ 'and their evidence is the Failure on the wire plus the absence of a ButtonRequest.' ,
297+ ],
298+ [
299+ ('S1' , 'test_msg_ethereum_erc20_0x_signtx' , 'test__sign_transformERC20' ,
300+ '0x transformERC20 raw disclosure' ,
301+ 'A 1480-byte transformERC20 payload exceeds one 1024-byte chunk. The device must NOT '
302+ 'clear-sign it as a token swap, because the bytes past the initial chunk are hashed '
303+ 'without being decoded. With AdvancedMode on it falls to the raw path, where the byte '
304+ 'count shown must be the FULL length (1480), not the chunk length (1024) - a short '
305+ 'count would under-report what is being signed.' ,
306+ ['Raw contract data screen showing the full byte count' ]),
307+ ('S2' , 'test_msg_ethereum_erc20_0x_signtx' , 'test_sign_0x_swap_ERC20_to_ETH' ,
308+ '0x sellToUniswap names both assets' ,
309+ 'Clear-signing is only honest when BOTH token words resolve to known assets. This '
310+ 'payload resolves (USDC -> ETH) and must name both sides with real amounts. The '
311+ 'failure this guards is a screen naming a DEX while showing no amount.' ,
312+ ['Swap screen naming both assets and amounts' ]),
313+ ('S3' , 'test_msg_ethereum_erc20_0x_signtx' , 'test_sign_longdata_swap' ,
314+ 'Long 0x calldata stays disclosed' ,
315+ 'Calldata spanning multiple chunks must not silently lose its tail from the display '
316+ 'while remaining inside the signature.' ,
317+ ['Contract data screen' ]),
318+ ('S8' , 'test_msg_ethereum_signing_guards' ,
319+ 'test_contract_handler_streamed_calldata_signs_full_data' ,
320+ 'Streamed calldata is fully covered' ,
321+ 'Calldata delivered across several chunks must be hashed in full and disclosed in full. '
322+ 'This is the positive control for the chunk-completeness gate. NOTE: every test in '
323+ 'test_msg_ethereum_signing_guards currently SKIPS in CI under requires_firmware, so no '
324+ 'screen can be captured for it yet - the screenshot list stays empty until the gate '
325+ 'opens, rather than declaring an expectation nothing can satisfy.' ,
326+ []),
327+ ('S9' , 'test_msg_ethereum_signing_guards' , 'test_eip1559_requires_chain_id' ,
328+ 'Omitted chain_id is refused before any screen' ,
329+ 'Without a chain_id the device cannot name the network, and a signature would be '
330+ 'pre-EIP-155 - replayable on every EVM chain. The refusal happens before the first '
331+ 'confirm(), so NO screen is drawn and no ButtonRequest is emitted. The empty '
332+ 'screenshot list below is the assertion.' ,
333+ []),
334+ ('S10' , 'test_verify_typed_data' , 'test_structured_eip712_is_refused' ,
335+ 'Structured EIP-712 is closed by default' ,
336+ 'The legacy JSON parser could not guarantee that every displayed value was the '
337+ 'canonical value being hashed, and one screen took its title from the attacker-supplied '
338+ 'domain name. The feature is withdrawn rather than shipped with a screen it could not '
339+ 'vouch for: zero screens, refusal on the wire.' ,
340+ []),
341+ ('S11' , 'test_msg_binance_sign_tx' , 'test_transfer' ,
342+ 'Binance denom renders in full' ,
343+ 'A long denom must render completely and must not overflow the formatting buffer.' ,
344+ ['Transfer screen showing the full denom' ]),
345+ ('S12' , 'test_msg_ping' , 'test_ping_long_body_is_paged' ,
346+ 'A long body is paged, not clipped' ,
347+ 'A body that will not fit one screen is shown across several, with the page number '
348+ 'in the title. Before 7.14.2 the device drew what fitted and stopped - no ellipsis, '
349+ 'no warning - and a later warning screen claimed "Hold to view it anyway" while '
350+ 're-drawing the same clipped text. These captures are the evidence that the '
351+ 'remainder is now actually reachable. The press DURATIONS (click to page, hold to '
352+ 'approve) are not assertable in an emulator with no physical button.' ,
353+ ['Numbered page screens covering the whole body' ]),
354+ ('S13' , 'test_msg_ping' , 'test_ping_short_body_is_not_paged' ,
355+ 'A body that fits is not paged' ,
356+ 'The control for S12. A fitting body must still take exactly one screen with an '
357+ 'unnumbered title - otherwise a pager that numbered every confirmation, making '
358+ 'ordinary approvals cost extra presses, would pass unnoticed.' ,
359+ ['Single unnumbered confirmation screen' ]),
360+ ]),
278361 ('X' , 'Device Specifications' , '0.0.0' ,
279362 'The KeepKey is an open-source hardware wallet built on an ARM Cortex-M3 (STM32F205, 120MHz) '
280363 'with a 256x64 monochrome OLED, single confirmation button, and micro-USB interface. The '
@@ -973,6 +1056,59 @@ def parse_junit(path):
9731056 ('D6' , 'test_msg_bip85' , 'test_bip85_invalid_word_count' ,
9741057 'Invalid count rejected' , 'Word counts other than 12/18/24 are refused.' , []),
9751058 ]),
1059+ ('D' , 'Display Disclosure - What Is Shown Is What Is Signed' , '7.14.2' ,
1060+ 'The single property behind every display/sign divergence found in the 7.14.2 audit: two '
1061+ 'requests whose SIGNED BYTES differ must not produce IDENTICAL screens. If two payloads render '
1062+ 'the same pixels, whatever separates them was invisible when the user approved, and the '
1063+ 'signature covers the difference. A failure here means a host can show one thing and have '
1064+ 'another signed - the exact class the OLED exists to prevent.' ,
1065+ [
1066+ 'ASSERTED DIFFERENTIALLY: DebugLinkState.layout is the framebuffer, not text, so these' ,
1067+ 'compare screen sequences. That assumes nothing about wording, fonts or truncation' ,
1068+ 'strategy, so it survives copy changes and cannot be satisfied by a plausible-looking screen.' ,
1069+ '' ,
1070+ 'EACH CASE PUTS THE DIFFERENCE WHERE AN IMPLEMENTATION STOPS LOOKING:' ,
1071+ '- past an embedded NUL: a protobuf bytes field is not a C string; "%s" stops, the signature does not' ,
1072+ '- past whitespace padding: a leading space costs no pixels once wrapped, so a padded body measures as fitting' ,
1073+ '- past one screenful: a truncating renderer drops the tail instead of paging it' ,
1074+ '- behind newlines: exercises the row counter rather than the character count' ,
1075+ '' ,
1076+ 'REFUSAL COUNTS AS A PASS. Declining to sign what it cannot display honestly satisfies' ,
1077+ 'the property; the failure under test is signing it while looking identical to the benign case.' ,
1078+ ],
1079+ [
1080+ ('D1' , 'test_msg_display_disclosure' , 'test_bytes_past_an_embedded_nul_are_disclosed' ,
1081+ 'Bytes after a NUL are shown' ,
1082+ 'A protobuf bytes field is not a NUL-terminated string. Rendering it with "%s" stops at the '
1083+ 'first NUL while the signature covers message.size bytes, so a payload like '
1084+ '"benign login\\ 0 AND APPROVE TRANSFER" displays only the benign prefix. This asserts the '
1085+ 'two payloads do not present identically.' ,
1086+ ['Message screen, plain' , 'Message screen, NUL-suffixed' ]),
1087+ ('D2' , 'test_msg_display_disclosure' , 'test_bytes_past_whitespace_padding_are_disclosed' ,
1088+ 'Whitespace cannot hide signed text' ,
1089+ 'Whitespace is the cheapest way to push content out of view: a leading space costs zero '
1090+ 'pixels once a line has wrapped, so padding can make an over-long body measure as fitting '
1091+ 'while the tail is neither shown nor dropped from the signature.' ,
1092+ ['Message screen, short' , 'Message screen, padded' ]),
1093+ ('D3' , 'test_msg_display_disclosure' , 'test_bytes_past_the_first_screen_are_disclosed' ,
1094+ 'Content beyond one screen is not silently dropped' ,
1095+ 'Whether the device pages the remainder, states how much is hidden, or refuses is not '
1096+ 'asserted - only that a long payload with a distinct tail does not look identical to a '
1097+ 'short one.' ,
1098+ ['Message screen, fits' , 'Message screen, overlong' ]),
1099+ ('D4' , 'test_msg_display_disclosure' , 'test_newline_padding_does_not_collapse_the_screen' ,
1100+ 'Line counting cannot be overflowed' ,
1101+ 'Line counting is a security boundary once it gates a truncation warning. A body carrying '
1102+ 'many newlines exercises the row counter rather than the character count; if that counter '
1103+ 'wraps, an arbitrarily long body reports as fitting.' ,
1104+ ['Message screen, one line' , 'Message screen, newline-padded' ]),
1105+ ('D5' , 'test_msg_display_disclosure' , 'test_signing_shows_at_least_one_screen' ,
1106+ 'Guard: the comparisons are not vacuous' ,
1107+ 'Every other test in this section compares screen sequences. A flow that produced no '
1108+ 'ButtonRequest would make two payloads compare equal as empty tuples and pass while showing '
1109+ 'the user nothing. This asserts at least one non-blank screen is actually displayed.' ,
1110+ ['Control message screen' ]),
1111+ ]),
9761112]
9771113
9781114# ---------------------------------------------------------------
@@ -1111,6 +1247,46 @@ def screenshot_filter(fw_version):
11111247 return ' or ' .join (terms )
11121248
11131249
1250+ def screenshot_audit (fw_version , screenshot_root , junit_path = None ):
1251+ """Which SECTIONS tests DECLARED screens but captured none?
1252+
1253+ The CI gate was `total PNG count > 0`, which a single captured suite
1254+ satisfies. That cannot distinguish "captured everything" from "captured
1255+ something": in the 7.14.2 round, 345 PNGs were produced while every suite
1256+ the release actually changed captured zero, and the phase reported healthy.
1257+
1258+ Returns (ok, missing) where missing is a list of (module, method) that
1259+ declared a non-empty screenshot list, were not skipped, and produced no
1260+ PNG directory. Skipped tests are not missing -- a version-gated test
1261+ cannot draw.
1262+ """
1263+ import os as _os
1264+ skipped = set ()
1265+ if junit_path and _os .path .exists (junit_path ):
1266+ import xml .etree .ElementTree as _ET
1267+ root = _ET .parse (junit_path ).getroot ()
1268+ suites = [root ] if root .tag == 'testsuite' else root .findall ('testsuite' )
1269+ for su in suites :
1270+ for tc in su .findall ('testcase' ):
1271+ if tc .find ('skipped' ) is not None :
1272+ cn = tc .get ('classname' , '' )
1273+ mod = next ((p for p in cn .split ('.' ) if p .startswith ('test_' )), '' )
1274+ skipped .add ((mod , tc .get ('name' )))
1275+
1276+ active = [x for x in SECTIONS if ver_ge (fw_version , x [2 ])]
1277+ missing = []
1278+ for letter , title , mf , bg , fl , tests in active :
1279+ for tid , mod , meth , ttl , ctx , scr in tests :
1280+ if not scr :
1281+ continue
1282+ if (mod , meth ) in skipped :
1283+ continue
1284+ d = _os .path .join (screenshot_root , mod .replace ('test_' , '' , 1 ), meth )
1285+ if not _os .path .isdir (d ) or not [f for f in _os .listdir (d ) if f .endswith ('.png' )]:
1286+ missing .append ((mod , meth ))
1287+ return (len (missing ) == 0 , missing )
1288+
1289+
11141290def validate_junit (fw_version , results ):
11151291 """Check SECTIONS tests against JUnit results. Returns (passed, failed_list).
11161292
@@ -1137,6 +1313,10 @@ def main():
11371313 p .add_argument ('--fw-version' , default = None )
11381314 p .add_argument ('--junit' , default = None , help = 'JUnit XML for pass/fail results' )
11391315 p .add_argument ('--screenshots' , default = None , help = 'Directory with per-test OLED screenshots' )
1316+ p .add_argument ('--screenshot-audit' , metavar = 'SCREENSHOT_DIR' ,
1317+ help = 'exit 1 if any SECTIONS test that declared screens captured none' )
1318+ p .add_argument ('--audit-junit' , metavar = 'XML' , default = None ,
1319+ help = 'JUnit XML for --screenshot-audit, so skipped tests are not counted missing' )
11401320 p .add_argument ('--screenshot-filter' , action = 'store_true' ,
11411321 help = 'Print pytest -k expression for tests needing screenshots, then exit' )
11421322 p .add_argument ('--validate-junit' , action = 'store_true' ,
@@ -1150,6 +1330,15 @@ def main():
11501330 if fw : print (f'Detected: { fw } ' , file = sys .stderr )
11511331 else : print ('No emulator, defaulting to 7.10.0' , file = sys .stderr ); fw = '7.10.0'
11521332
1333+ if args .screenshot_audit :
1334+ ok , missing = screenshot_audit (fw , args .screenshot_audit , args .audit_junit )
1335+ if ok :
1336+ print ('screenshot audit: every declared screen was captured' )
1337+ sys .exit (0 )
1338+ print ('screenshot audit FAILED -- declared screens with no capture:' )
1339+ for mod , meth in missing :
1340+ print (' %s::%s' % (mod , meth ))
1341+ sys .exit (1 )
11531342 if args .screenshot_filter :
11541343 print (screenshot_filter (fw ))
11551344 sys .exit (0 )
0 commit comments