@@ -104,7 +104,10 @@ def write(self, path):
104104 img_obj_ids = {} # img_name -> obj_id
105105 for img_path , (name , pixels , iw , ih ) in self .images .items ():
106106 compressed = zlib .compress (pixels )
107- obj = f'{ nxt } 0 obj\n << /Type /XObject /Subtype /Image /Width { iw } /Height { ih } /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length { len (compressed )} >>\n stream\n ' .encode () + compressed + b'\n endstream\n endobj\n '
107+ # The report verifier rasterizes at an integer OLED-pixel scale.
108+ # State the PDF default explicitly so a reader cannot smooth a
109+ # security frame and make the final visible pixels ambiguous.
110+ obj = f'{ nxt } 0 obj\n << /Type /XObject /Subtype /Image /Width { iw } /Height { ih } /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate false /Filter /FlateDecode /Length { len (compressed )} >>\n stream\n ' .encode () + compressed + b'\n endstream\n endobj\n '
108111 objs .append (obj )
109112 img_obj_ids [name ] = nxt
110113 nxt += 1
@@ -363,10 +366,141 @@ def junit_reconciliation(path):
363366 ('test_msg_resetdevice' , 'test_reset_device_24_words' ): 7 ,
364367 ('test_msg_resetdevice' , 'test_reset_device_pin' ): 6 ,
365368 ('test_msg_resetdevice' , 'test_failed_pin' ): 2 ,
369+ ('test_msg_recoverydevice_cipher' , 'test_nopin_nopassphrase' ): 43 ,
370+ ('test_msg_recoverydevice_cipher' , 'test_pin_passphrase' ): 45 ,
371+ ('test_msg_changepin' , 'test_set_pin' ): 3 ,
372+ ('test_msg_changepin' , 'test_change_pin' ): 4 ,
373+ ('test_msg_changepin' , 'test_remove_pin' ): 2 ,
374+ ('test_msg_ethereum_signtx' , 'test_ethereum_blind_sign_blocked' ): 1 ,
366375 ('test_msg_ping' , 'test_authenticator_passphrase_cancel_is_terminal' ): 1 ,
367376 ('test_msg_solana_signtx' , 'test_solana_sign_system_transfer' ): 3 ,
368377}
369378
379+ # Counts and unique whole-frame hashes reject missing pages and stale capture,
380+ # but a same-sized set of unrelated screens could still be relabelled as the
381+ # ceremony. Pin stable semantic regions: static prompts in full, seed-page
382+ # titles without random words, and PIN/recovery labels without randomized
383+ # grids. Values are raw 8-bit framebuffer-region SHA-256 digests, not PNG file
384+ # hashes, so metadata/encoding changes cannot satisfy them.
385+ _RECOVERY_PROGRESS_DIGESTS = (
386+ '356da6d4365dc08dc554d7d14d82c6458cd4812682ebcf16521154582c201c9d' ,
387+ 'bd5c4a4e92abb097d0c5f69176d915568b483a5bc1c98e17ba8c26e35bc6e308' ,
388+ '3cf4d797c321cf56e63c177f64e6d889358fed8f6dd1cecd658b90621c10df8d' ,
389+ 'ac4b89a3020e9c9bc12dc630f95d96e5ac86d381cfad71843aa88835d82f42de' ,
390+ 'dd30561c70c73b39733b6d71d6cbea89d43a81cf032cbed7e013a4a93694d709' ,
391+ 'b6e134f114a1572da2464780eca206a422f31de5f2699ae8979e89c0e3c70825' ,
392+ '98712d58cb5f17fda59d73cd37c33c6e81ffe011eeb2fbc9bf530a2b8effc052' ,
393+ '2e9ac61c8da62ed071b8ba9ede76ca1df45d52684ddc0b8192e57ae485aa2d14' ,
394+ 'ffc90a573947af63fb0700a9b7e4d105ef02cfbda1077f9e0da61fd7d5dbecc0' ,
395+ '6ea6b5da97c73890c2c304d92a08807b4c1f86cd22f35d8011ac9b16545f1e26' ,
396+ 'fe736b5dc3d03ac1beab86df27aec6a8493e8f53776889fcf64afd4ea9b99f8c' ,
397+ '508ccfc1207a64f4c13cb08d5068cbd20aff4070213ff39b14beaa3d7e5b291c' ,
398+ '46cdc4cde98844b548431ce166ff7ce9d044f3d2b3f6cf073bc6f5e6991c64c5' ,
399+ '15ab192e8fb5ad155072a686cf8141b9c031bac561368b090ef03f48fe20be41' ,
400+ 'd96df49ded78365e8d5670d37b2013eb1b15be148f0de3fa24e01749bcf40799' ,
401+ '8a0857857a5fc98d395229b6f0551a784e60ec7503450d1ff6703c43e93bedf0' ,
402+ '1d2f9d8b4d89fc531e22ab8861ca8c4855e1e782b45f9e8a94ef18fccc8abc24' ,
403+ 'fb2f1a2e4bf6a55ba80b3ea93ac7f4a4d920d92947fdda5e19b4f36d8ca97c39' ,
404+ '8baf086979d10099db857889f5e98870b69b533dbb6ad8223759012bfb1883fd' ,
405+ '4871fb187d2c5d18fab2ff733e44877c4be255b889e5d92809e5c96a764977c7' ,
406+ 'ab63aefd9107d312e155446f1d3b6885408c72f81100bcbe15018ffa10bd2323' ,
407+ 'eecaca7c1159ab9cd9050efd48c6fa2d041fa968b90a36020639723368ae619b' ,
408+ '0add6eea0290f825a0a96d55859192fa17f91ba641d0acba3aae0da1d82013da' ,
409+ '613fafd7d89e66bdc6b3df8929ae8189d0628e8fedad164a68dab0d524973f42' ,
410+ '1bb1ef1cadc2c771ef6ae2cc439cde1c96d54b2dfbfc5c797ccdaab96cea9217' ,
411+ '0fb5c836d9399a7842a9bfefcdc4e98e9f0beba73fa6a9ddf9fb6d20a45c557a' ,
412+ 'b1872159d11592ba047fffad4c6d4afeb9f08d63efb1da08db4bf7a7333652fa' ,
413+ '766f7c35fb85b92a63d53f38d85209cacc2c77896a971ce6404069334c985832' ,
414+ '0037fe837403c31e8afc3dcdcabe59749d827b2056e976e60911c820633a1138' ,
415+ '1838b5b983fa4ce45b18ee42d6f6eb265a04242bb4d49a731e44c06ee05c0e6f' ,
416+ '06f177d53c810c8ebaf39183960dba6d516bc4d614e31e3d54d2806d87a53373' ,
417+ 'ac8c1ae7c3106d13467784442149a923b538d2496de6112859a6a9aa35aba9e0' ,
418+ 'ef8f79330bfc7c1433cef9f4bbaa72553187405b6dc953a0289bc23588a89ba5' ,
419+ 'd9beba0b50498295a17d0574af5319340a49c9b0eb584fc6a692bdb8de61120c' ,
420+ 'cdf9f82d26cc510be4245de86eea8d15d475fc7188cad2313985817393c5e98f' ,
421+ '698e4369c80ededc84089de96e3ce3f75d6e5a0fe0f948c170a72b52596cff18' ,
422+ 'c24ab48b79202080b794e6bbe08fd1addeb3d464d350f8ab870d16d65cbfd892' ,
423+ '0f132132d36b77080285577210c9584041e96fdadb43ff1623c0a76321bc1ac8' ,
424+ 'cac9864396459cd742266d587c3a4668c83e30cb5c4586c80c8d4467f12b7c29' ,
425+ '73e185aed618ea31559e32571350d5994ed1a8548ed242c0aea04b3e928a20b6' ,
426+ 'ce7b85a3ab8faa6e04cbaecdc8c26a7afef950c371bf39b5480de3e7b21ab715' ,
427+ 'c511560f884aa174d6980fbc2494daa5e21cf3a2b3c9fefd58a3d52fa2b91990' ,
428+ )
429+ SECURITY_STAGE_POLICIES = {
430+ ('test_msg_resetdevice' , 'test_reset_device' ): (
431+ ((0 , 0 , 256 , 64 ), 'be76f41dcbb2699274234cc687ae2a136cc23ee57e2b16ebc94a5c95c07d62d5' ),
432+ ((0 , 0 , 256 , 16 ), '9ae3b8815317cf35882cbbdd8129bc6fa79c029759edce8cf64103f291809d05' ),
433+ ((0 , 0 , 256 , 16 ), '9c70c1af5f9a4da9b6aa69c3beefc5e3c70d82af51212350e757bfd9cabf36de' ),
434+ ((0 , 0 , 256 , 16 ), '88e1755feef35658a506016ea50ed1ba5a651d53e1a98dd97f07282ca22c77ef' ),
435+ ),
436+ ('test_msg_resetdevice' , 'test_reset_device_18_words' ): (
437+ ((0 , 0 , 256 , 64 ), 'be76f41dcbb2699274234cc687ae2a136cc23ee57e2b16ebc94a5c95c07d62d5' ),
438+ ((0 , 0 , 256 , 16 ), '5e967b430c4f07a0f8f8dc7594eaa66be7237f3baf41a90f7a5edd9e88912474' ),
439+ ((0 , 0 , 256 , 16 ), 'e58ece0276ba86987987ab6fdfd0a12e4ed7dd8478dfe9ed7249a516c8063d69' ),
440+ ((0 , 0 , 256 , 16 ), '1669d638b7256699b9ed57615619e365fb4ab67a7c8a6f2ed8b23d758da43027' ),
441+ ((0 , 0 , 256 , 16 ), 'b259422a2db01225278f22bf43e7a60969aa202a260b11d2c91b44f5764cbac8' ),
442+ ((0 , 0 , 256 , 16 ), 'd4e6ed9a13a1bf88a662d7568094d5a66d2bdbd9ac39f3a576f59108b0578515' ),
443+ ),
444+ ('test_msg_resetdevice' , 'test_reset_device_24_words' ): (
445+ ((0 , 0 , 256 , 64 ), 'be76f41dcbb2699274234cc687ae2a136cc23ee57e2b16ebc94a5c95c07d62d5' ),
446+ ((0 , 0 , 256 , 16 ), '2fb7530c04acb560dbe1a29179bb084bd7003846a9204b26152c363f2fa076ac' ),
447+ ((0 , 0 , 256 , 16 ), '46c79190446218f6b33ac2e1cf0407c37fc680db911dff5ad61c27459157dd9b' ),
448+ ((0 , 0 , 256 , 16 ), '5e2f25cd0c1e4b6d41fe42316b6f696f747636b7446f33f0b853f322f397e628' ),
449+ ((0 , 0 , 256 , 16 ), '934e043c7691ea2bd1e3126da5add2e907f1b9e4d9ae183bba34b0227c69e724' ),
450+ ((0 , 0 , 256 , 16 ), '68b48e9ca14c309b3953afe4f0f0c0f289a4a0fa7920d4c5188562754c294d2e' ),
451+ ((0 , 0 , 256 , 16 ), 'd0f315000890dce0317553c695a2bd72e8bbb67a28d3640812c7a93945bd2882' ),
452+ ),
453+ ('test_msg_resetdevice' , 'test_reset_device_pin' ): (
454+ ((0 , 0 , 72 , 64 ), '41cf35c908d63d6f350edbcf0d70f504dd35142906c85402f108c373c37264bc' ),
455+ ((0 , 0 , 72 , 64 ), '42775a67b27b34e34bea67aa17a8942eb2bc28215cd5dd8d31c3562f556393db' ),
456+ ((0 , 0 , 256 , 64 ), 'be76f41dcbb2699274234cc687ae2a136cc23ee57e2b16ebc94a5c95c07d62d5' ),
457+ ((0 , 0 , 256 , 16 ), '9ae3b8815317cf35882cbbdd8129bc6fa79c029759edce8cf64103f291809d05' ),
458+ ((0 , 0 , 256 , 16 ), '9c70c1af5f9a4da9b6aa69c3beefc5e3c70d82af51212350e757bfd9cabf36de' ),
459+ ((0 , 0 , 256 , 16 ), '88e1755feef35658a506016ea50ed1ba5a651d53e1a98dd97f07282ca22c77ef' ),
460+ ),
461+ ('test_msg_resetdevice' , 'test_failed_pin' ): (
462+ ((0 , 0 , 72 , 64 ), '41cf35c908d63d6f350edbcf0d70f504dd35142906c85402f108c373c37264bc' ),
463+ ((0 , 0 , 72 , 64 ), '42775a67b27b34e34bea67aa17a8942eb2bc28215cd5dd8d31c3562f556393db' ),
464+ ),
465+ ('test_msg_recoverydevice_cipher' , 'test_nopin_nopassphrase' ):
466+ (((0 , 0 , 256 , 64 ), '2130e48943d0894d27d00ecc015e9cb15ea8be96044d823ef9b8b6e64897a0d8' ),) +
467+ tuple (((0 , 32 , 72 , 64 ), digest )
468+ for digest in _RECOVERY_PROGRESS_DIGESTS ),
469+ ('test_msg_recoverydevice_cipher' , 'test_pin_passphrase' ):
470+ (((0 , 0 , 72 , 64 ), '41cf35c908d63d6f350edbcf0d70f504dd35142906c85402f108c373c37264bc' ),
471+ ((0 , 0 , 72 , 64 ), '42775a67b27b34e34bea67aa17a8942eb2bc28215cd5dd8d31c3562f556393db' ),
472+ ((0 , 0 , 256 , 64 ), '2130e48943d0894d27d00ecc015e9cb15ea8be96044d823ef9b8b6e64897a0d8' )) +
473+ tuple (((0 , 32 , 72 , 64 ), digest )
474+ for digest in _RECOVERY_PROGRESS_DIGESTS ),
475+ ('test_msg_changepin' , 'test_set_pin' ): (
476+ ((0 , 0 , 256 , 64 ), 'a1181d01ad8cdf156f4d082ad1a976b2d83b863da99ce75fd007053775fb89b9' ),
477+ ((0 , 0 , 72 , 64 ), '41cf35c908d63d6f350edbcf0d70f504dd35142906c85402f108c373c37264bc' ),
478+ ((0 , 0 , 72 , 64 ), '42775a67b27b34e34bea67aa17a8942eb2bc28215cd5dd8d31c3562f556393db' ),
479+ ),
480+ ('test_msg_changepin' , 'test_change_pin' ): (
481+ ((0 , 0 , 256 , 64 ), '8358abe3b90dff94b248cf5577758538d9ed6abd8f299c53cb6b452f27beb8f2' ),
482+ ((0 , 0 , 72 , 64 ), 'e5addc208c68cf1655e2aa00789159a3039b6a27e18df9891c88fcfe1af71219' ),
483+ ((0 , 0 , 72 , 64 ), '41cf35c908d63d6f350edbcf0d70f504dd35142906c85402f108c373c37264bc' ),
484+ ((0 , 0 , 72 , 64 ), '42775a67b27b34e34bea67aa17a8942eb2bc28215cd5dd8d31c3562f556393db' ),
485+ ),
486+ ('test_msg_changepin' , 'test_remove_pin' ): (
487+ ((0 , 0 , 256 , 64 ), '4e0e3515ea141409fe932778a59fe2c4007d8074aa4469e5fba5f4a8527d0c98' ),
488+ ((0 , 0 , 72 , 64 ), 'e5addc208c68cf1655e2aa00789159a3039b6a27e18df9891c88fcfe1af71219' ),
489+ ),
490+ ('test_msg_ethereum_signtx' , 'test_ethereum_blind_sign_blocked' ): (
491+ ((0 , 0 , 80 , 16 ),
492+ 'f0d262606b7358aee6b8c9d9dfac9035f89bd8a8b26b2551c11561731b47880d' ),
493+ ),
494+ ('test_msg_ping' , 'test_authenticator_passphrase_cancel_is_terminal' ): (
495+ ((0 , 0 , 72 , 64 ), 'e5addc208c68cf1655e2aa00789159a3039b6a27e18df9891c88fcfe1af71219' ),
496+ ),
497+ ('test_msg_solana_signtx' , 'test_solana_sign_system_transfer' ): (
498+ ((0 , 0 , 128 , 32 ), '7c497cdbcbab7e37c4bc0ad6a0276b18cfe4b8744689cad0d6afa37b09b7b37c' ),
499+ ((0 , 0 , 80 , 32 ), '6229b9bc36eed4e5dece1deafdcb44e3b6449f7c1329fef3797bea609dad4b8f' ),
500+ ((0 , 0 , 256 , 64 ), '0f1acb8bc327fed89037093f9b489a8ce598c8f58d26601ae0d618c03403e823' ),
501+ ),
502+ }
503+
370504SECURITY_DISTINCT_FRAME_TESTS = set (SECURITY_FRAME_POLICIES ) | {
371505 ('test_msg_solana_instruction_disclosure' ,
372506 'test_all_verified_instruction_fields_change_oled_review' ),
@@ -468,8 +602,8 @@ def junit_reconciliation(path):
468602 '- an unresolved token rendered as the literal "Unknown token value" and signed' ,
469603 '- a truncated memo dropped its last character (Confirm limit 42 vs 420)' ,
470604 '' ,
471- 'A test here with an EMPTY screenshot list is deliberate: refusal paths draw nothing, ' ,
472- 'and their evidence is the Failure on the wire plus the absence of a ButtonRequest .' ,
605+ 'An EMPTY screenshot list is valid only for a refusal that asserts no ButtonRequest. ' ,
606+ 'A refusal that emits a policy notice must retain that frame and its exact wire order .' ,
473607 ],
474608 [
475609 ('DB1' , 'test_msg_ethereum_erc20_0x_signtx' , 'test__sign_transformERC20' ,
@@ -582,16 +716,17 @@ def junit_reconciliation(path):
582716 ('X' , 'Device Specifications' , '0.0.0' ,
583717 'The KeepKey is an open-source hardware wallet built on an ARM Cortex-M3 (STM32F205, 120MHz) '
584718 'with a 256x64 monochrome OLED, single confirmation button, and micro-USB interface. The '
585- 'bootloader (v2.x) is flashed at manufacture and never updated - it is the immutable root of '
586- 'trust. On every boot, the bootloader verifies the firmware signature using redundant F3 checks '
587- 'before transferring control .' ,
719+ 'bootloader (v2.x) verifies the firmware signature using redundant F3 checks before transferring '
720+ 'control. Bootloader updates are supported through a separately built updater and therefore '
721+ 'remain a security-sensitive release path; the bootloader must not be described as immutable .' ,
588722 [
589723 'BOOT SEQUENCE:' ,
590724 '1. USB connect -> bootloader executes (always first)' ,
591725 '2. F3 signature check (redundant dual-path verify)' ,
592726 '3. Valid -> KeepKey logo -> firmware runs' ,
593727 '4. Invalid/missing -> "UPDATE FIRMWARE" screen' ,
594728 '5. Firmware upload -> verify -> flash -> reboot -> re-verify' ,
729+ '6. Bootloader updates use the separately audited blupdater artifact' ,
595730 '' ,
596731 'HARDWARE:' ,
597732 '- MCU: STM32F205RET6, 120MHz, 128KB bootloader + 896KB firmware' ,
@@ -934,9 +1069,12 @@ def junit_reconciliation(path):
9341069 ['ETH send confirmation' ]),
9351070 ('E3' , 'test_msg_ethereum_signtx' , 'test_ethereum_signtx_data' ,
9361071 'Sign ETH tx with contract data' ,
937- 'Transaction with data field (contract call). Device shows data as hex since it cannot '
938- 'decode arbitrary ABI without metadata.' ,
939- ['Contract data hex' ]),
1072+ 'Transaction with a data field (contract call). Without trusted metadata the device does '
1073+ 'not show raw calldata as decoded semantics: AdvancedMode is required and the OLED shows '
1074+ 'the complete-calldata Keccak-256 commitment with the transaction fields. This test signs '
1075+ 'three contract-call variants in sequence; repeated amount/hash/fee frames are intentional '
1076+ 'per-variant approvals, not one duplicated approval flow.' ,
1077+ ['Contract data hash and transaction fields for three variants' ]),
9401078 ('E4' , 'test_msg_ethereum_signtx' , 'test_ethereum_signtx_nodata_eip155' ,
9411079 'Sign ETH with EIP-155 replay protection' ,
9421080 'Chain ID embedded in signature v value to prevent cross-chain replay attacks.' , []),
@@ -1150,9 +1288,10 @@ def junit_reconciliation(path):
11501288 ['Opaque policy warning' , 'Complete calldata hash approval' ]),
11511289 ('V9' , 'test_msg_ethereum_signtx' , 'test_ethereum_blind_sign_blocked' ,
11521290 'Opaque contract data blocked (AdvancedMode OFF)' ,
1153- 'On 7.14.2 the same arbitrary calldata is refused by policy before any signing approval. '
1154- 'The wire Failure and absence of a signing ButtonRequest are the evidence.' ,
1155- []),
1291+ 'On 7.14.2 the same arbitrary calldata produces one non-approving Blocked notice, then '
1292+ 'Failure_ActionCancelled. The exact two-message sequence proves there is no signing '
1293+ 'approval after the policy notice.' ,
1294+ ['Blocked policy notice' ]),
11561295 ]),
11571296
11581297 ('SOL' , 'Solana' , '7.14.0' ,
@@ -1780,6 +1919,23 @@ def _sequence_record(screenshot_root, directory, label, frames):
17801919 }
17811920
17821921
1922+ def _validate_security_stage_digests (mod , meth , stage_policy ,
1923+ actual_digests ):
1924+ """Bind each ceremony position to its expected visible semantic stage."""
1925+ if len (stage_policy ) != len (actual_digests ):
1926+ raise ValueError (
1927+ 'security ceremony semantic-stage count mismatch for %s::%s' %
1928+ (mod , meth ))
1929+ for index , (actual , stage ) in enumerate (zip (
1930+ actual_digests , stage_policy )):
1931+ _region , expected = stage
1932+ allowed = (expected ,) if isinstance (expected , str ) else expected
1933+ if actual not in allowed :
1934+ raise ValueError (
1935+ 'security ceremony frame %d has the wrong semantic stage '
1936+ 'for %s::%s' % (index , mod , meth ))
1937+
1938+
17831939def _enforce_security_frame_policy (directory , mod , meth , frames ):
17841940 expected_count = SECURITY_FRAME_POLICIES .get ((mod , meth ))
17851941 if expected_count is not None and len (frames ) != expected_count :
@@ -1797,6 +1953,28 @@ def _enforce_security_frame_policy(directory, mod, meth, frames):
17971953 raise ValueError (
17981954 'security ceremony contains a duplicate framebuffer for %s::%s' %
17991955 (mod , meth ))
1956+ stage_policy = SECURITY_STAGE_POLICIES .get ((mod , meth ))
1957+ if stage_policy is None :
1958+ return
1959+ actual_digests = []
1960+ for index , (frame , stage ) in enumerate (zip (frames , stage_policy )):
1961+ region , expected = stage
1962+ x0 , y0 , x1 , y1 = region
1963+ try :
1964+ pixels , width , height = _read_png_pixels (
1965+ os .path .join (directory , frame ))
1966+ except Exception as exc :
1967+ raise ValueError (
1968+ 'security ceremony frame %d is not a valid framebuffer: %s' %
1969+ (index , exc ))
1970+ if not (0 <= x0 < x1 <= width and 0 <= y0 < y1 <= height ):
1971+ raise ValueError ('invalid security semantic region' )
1972+ cropped = b'' .join (
1973+ pixels [y * width + x0 :y * width + x1 ]
1974+ for y in range (y0 , y1 ))
1975+ actual_digests .append (hashlib .sha256 (cropped ).hexdigest ())
1976+ _validate_security_stage_digests (
1977+ mod , meth , stage_policy , actual_digests )
18001978
18011979
18021980def selected_screenshot_sequences (screenshot_root , mod , meth , wants_screens ):
0 commit comments