From b44f1b367e7b8ce98d88a49b3457443335365d8f Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 23:11:45 -0300 Subject: [PATCH 1/8] test(reset): display_random is accepted and ignored Firmware no longer renders the Internal Entropy screen -- internal entropy is seed pre-image material, and a host that supplies ext_entropy and reads that screen once can compute SHA256(shown || ext) and derive the seed. test_reset_device_pin and test_failed_pin asserted the ButtonRequest for that screen, so they failed against the new firmware. Rather than dropping display_random from the request, they keep sending it =True and now assert the NEXT message is PinMatrixRequest -- which is a direct test of the compatibility claim: the field stays decodable on the wire and changes nothing. Verified 6/6 against an emulator built from the paired firmware branch. --- tests/test_msg_resetdevice.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_msg_resetdevice.py b/tests/test_msg_resetdevice.py index 28c5475f..e1d3c4cd 100644 --- a/tests/test_msg_resetdevice.py +++ b/tests/test_msg_resetdevice.py @@ -245,10 +245,11 @@ def test_reset_device_pin(self): language='english', label='test')) - self.assertIsInstance(ret, proto.ButtonRequest) - self.client.debug.press_yes() - ret = self.client.call_raw(proto.ButtonAck()) - + # display_random=True above is deliberate: the field stays in the wire + # schema for host compatibility but production firmware ignores it, + # because internal entropy is seed pre-image material. A host that + # sets it must get a NORMAL reset -- no Internal Entropy screen -- so + # the very next message is the PIN request, not a ButtonRequest. self.assertIsInstance(ret, proto.PinMatrixRequest) # Enter PIN for first time @@ -318,10 +319,11 @@ def test_failed_pin(self): language='english', label='test')) - self.assertIsInstance(ret, proto.ButtonRequest) - self.client.debug.press_yes() - ret = self.client.call_raw(proto.ButtonAck()) - + # display_random=True above is deliberate: the field stays in the wire + # schema for host compatibility but production firmware ignores it, + # because internal entropy is seed pre-image material. A host that + # sets it must get a NORMAL reset -- no Internal Entropy screen -- so + # the very next message is the PIN request, not a ButtonRequest. self.assertIsInstance(ret, proto.PinMatrixRequest) # Enter PIN for first time From 74768b021facbda85d3a0697769ca2144d56068b Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 22:20:22 -0300 Subject: [PATCH 2/8] report: catalog the 7.15 seed-generation evidence and state the report's scope The PDF is the artifact a release review actually reads, and it was quietly claiming more than it knew. Two defects, one visible consequence. parse_junit only emitted a 'mod::meth' key when the JUnit classname contained a dotted test_msg_*/test_sign_*/test_verify_* module. Native gtest suites carry a bare classname ("Dice", "Storage"), so they produced no such key, and _lookup has no bare-method fallback by design. CI merged the firmware-unit XMLs into the report input and every one of the 432 native tests was then structurally impossible to reference from SECTIONS. Bare classnames are now keyed as 'Suite::Test'. The header reported "N/N PASSED" against the catalog with nothing saying the catalog is a subset. A 7.15 RC audit grepped this PDF for feature keywords, found no hits for dice and PIN KDF, and reported both as having zero coverage. Both had in fact run green in the same CI run: test_reset_device_dice passed, and so did all five Dice unit tests and the PIN KDF rewrap tests. The header now states catalogued-vs-executed and says outright that absence here is not evidence of absence. New section K catalogues what that audit went looking for: the dice flow end-to-end (digest equals SHA256 of exactly the injected rolls, then the mnemonic is derived from post-mix internal entropy, which is what proves the rolls reached the seed), the aborted-reset EntropyAck disarm regression, the five Dice known-answer and independence vectors, and the v16->v19 PIN KDF rewrap plus storage migration. Verified against the 7.15.0 RC artifacts from run 31284108490: dice went from 0 to 13 occurrences in the rendered PDF, section K renders 11/11 passed, and poisoning Dice::MixDependsOnRolls in the merged JUnit turns the header red and fails --validate-junit, so the entries are wired to real results. Needs the companion firmware change: the CI trigger validated against the Python JUnit alone, where every native entry resolves to "missing". --- scripts/generate-test-report.py | 109 +++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index bc5657fa..2b369fdb 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -340,9 +340,22 @@ def detect_fw(): v = f'{r.major_version}.{r.minor_version}.{r.patch_version}'; c.close(); return v except: return None +# Census of everything the merged JUnit actually contained, so the report can +# state how much of the run it covers. Without this the PDF silently implies +# that its catalog IS the test suite -- an RC audit read "no dice in the report" +# as "dice is untested" when test_reset_device_dice had in fact run green. +JUNIT_CENSUS = {'ran': 0, 'native': 0} + + def parse_junit(path): """Parse junit XML for pass/fail. Returns dict keyed by 'module::method' (precise) - and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo.""" + and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo. + + Native gtest suites carry a bare classname ("Dice", "Storage") with no dotted + python module, so they get keyed as 'Suite::Test'. They used to produce no + 'mod::meth' key at all, which made every native unit test structurally + impossible to put in SECTIONS -- the firmware-unit XMLs were merged in and + then silently unusable.""" if not path or not os.path.exists(path): return {} import xml.etree.ElementTree as ET results = {} @@ -353,6 +366,7 @@ def parse_junit(path): elif tc.find('error') is not None: status = 'error' elif tc.find('skipped') is not None: status = 'skip' else: status = 'pass' + JUNIT_CENSUS['ran'] += 1 # Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo mod = '' if cls: @@ -361,6 +375,9 @@ def parse_junit(path): if p.startswith('test_msg_') or p.startswith('test_sign_') or p.startswith('test_verify_'): mod = p break + if not mod and '.' not in cls: + mod = cls # native gtest suite + JUNIT_CENSUS['native'] += 1 results[f'{cls}.{name}'] = status # Key by module::method (disambiguates collisions like test_sign_btc_eth_swap) if mod: @@ -672,6 +689,84 @@ def _arg_shown(a): ['Wordlist rejection warning']), ]), + ('K', 'Seed Generation Hardening (7.15)', '7.15.0', + 'The 7.15 changes to how a seed comes into existence: user-supplied dice entropy folded in ' + 'on-device, and the PIN key-derivation rewrap. These ran green from the first 7.15 RC but ' + 'appeared nowhere in this report, because the catalog could not reference native firmware ' + 'unit tests at all and nobody had catalogued the two new pyk cases. Absent evidence read as ' + 'absent coverage during an RC audit, which is exactly the failure this section exists to ' + 'prevent.', + [ + 'DICE: user rolls a d6 on-device; short press advances 1-6, long press commits, undo backs out.', + 'The roll string is hashed and the digest confirmed on the OLED before it is mixed in.', + 'MIX: int_entropy = SHA256(int_entropy || rolls), folded in BEFORE the host EntropyRequest,', + 'so the device commits to its own contribution first and the host cannot choose the seed.', + 'ABORT: any aborted reset must disarm EntropyAck, or a later host EntropyAck would derive', + 'a seed from sha256(0*32 || host_bytes) -- entirely host-chosen. That is K2.', + 'PIN KDF: a v16 storage blob must still unlock and then rewrap to v19, or the upgrade bricks.', + ], + [ + ('K1', 'test_msg_resetdevice', 'test_reset_device_dice', + 'Dice entropy end-to-end', + 'Drives the full on-device dice flow over DebugLink: 99 rolls injected in chunks with undo ' + 'exercised, extras past the cap dropped. Asserts the device-computed digest equals ' + 'SHA256 of exactly the expected roll string, then derives the mnemonic from the post-mix ' + 'internal entropy and compares -- which is what proves the rolls actually reached the seed ' + 'rather than being collected and discarded.', + ['Dice entry screen', 'Digest confirmation']), + ('K2', 'test_msg_resetdevice', 'test_reset_reentry_disarms_entropy_ack', + 'Aborted reset disarms EntropyAck', + 'Regression for a host-chosen-seed hole: reset_init aborts left awaiting_entropy set from ' + 'an earlier run while zeroing int_entropy, so a following EntropyAck derived the seed ' + 'from host bytes alone. Arms a reset, re-enters with dice, cancels, and asserts the ' + 'next EntropyAck is refused with "Not in Reset mode" and the device stays uninitialized.', + []), + ('K3', 'Dice', 'RollsForStrength', + 'Roll count per seed strength', + 'd6 carries log2(6)=2.585 bits, so 128/192/256-bit seeds need 50/75/99 rolls ' + '(the Coldcard convention). A short count would silently weaken the seed.', + []), + ('K4', 'Dice', 'MixZeroEntropyVector', + 'Mix known-answer vector (zero entropy)', + 'SHA256(0x00*32 || "123456") against a hardcoded digest. Pins the mix construction so a ' + 'refactor cannot quietly change how dice enter the seed.', + []), + ('K5', 'Dice', 'MixNonZeroEntropyVector', + 'Mix known-answer vector (non-zero entropy)', + 'Same construction with a non-zero starting entropy buffer, pinned to a hardcoded digest.', + []), + ('K6', 'Dice', 'MixDependsOnRolls', + 'Different rolls produce different entropy', + 'Two mixes differing only in the final roll must diverge. Catches a mix that ignores its ' + 'roll argument -- the failure mode where dice appear to work and contribute nothing.', + []), + ('K7', 'Dice', 'MixUsesExactCount', + 'Only the counted rolls contribute', + 'Bytes past the declared roll count must not affect the result, so uninitialized tail ' + 'bytes of the roll buffer can never leak into seed material.', + []), + ('K8', 'Storage', 'PinKdfV16RewrapsToV19AfterCorrectPin', + 'v16 storage unlocks and rewraps to v19', + 'The migration path for the hardened PIN KDF: an existing device on the old format must ' + 'still unlock with its current PIN and then be rewrapped. If this regressed, every ' + 'upgrading device would be locked out of its own seed.', + []), + ('K9', 'Storage', 'PinKdfV2FlagIsVersionedInV19', + 'KDF version flag is recorded in v19', + 'The new KDF is marked in the storage version band, so firmware can tell which derivation ' + 'a blob was written with instead of guessing.', + []), + ('K10', 'Storage', 'StorageUpgrade_Normal', + 'Normal storage upgrade path', + 'Baseline upgrade across storage versions with policies and cache preserved.', + []), + ('K11', 'Storage', 'NoopSecMigrate', + 'Idempotent security migration', + 'Re-running the migration on already-migrated storage must be a no-op rather than a ' + 'second rewrap.', + []), + ]), + ('B', 'Bitcoin', '7.0.0', 'Bitcoin is the primary chain and most extensively tested. Covers legacy P2PKH, P2SH-wrapped ' 'SegWit, native SegWit (bech32), and Taproot (P2TR). Transaction signing validates that the ' @@ -2044,6 +2139,18 @@ def _section_state(s): if build_label: for line in _w(f'Candidate: {build_label}', 95): pb.text(8, line, bold=True) + # Scope of this document. The catalog is a curated subset, and saying so is + # the difference between evidence and a misleading completeness claim: an RC + # audit grepped this PDF for feature keywords, found none, and reported four + # features as untested when their tests had run green in the same CI run. + ran = JUNIT_CENSUS['ran'] + if ran: + pb.gap(3) + for line in _w('Scope: this report is a curated catalog of %d tests. The CI run executed %d ' + '(%d of them native firmware unit tests). Absence from this report is NOT ' + 'evidence that a feature is untested -- check the JUnit artifacts.' + % (total, ran, JUNIT_CENSUS['native']), 100): + pb.text(8, line, color=GRAY) pb.gap(6) pb.text(12, 'Sections', bold=True) _hdr_withheld = _hdr_pending = False From 576139244e0eb677351a4219e680842d12e9752e Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 01:37:22 -0300 Subject: [PATCH 3/8] report: say which Zcash shielded tests never touch a device Chasing a rendering defect on the per-output shielded confirm turned up something worse than a missing screenshot: ZcashSignPCZT is never sent to a device anywhere in this suite. Every test in test_msg_zcash_sign_pczt drives a ScriptedTransport with canned responses -- they are offline contract tests that prove the client builds and orders its messages correctly, and prove nothing whatsoever about firmware behaviour. The device-driven Zcash tests cover transparent signing, display-address, FVK derivation and the seed fingerprint. None of them signs a shielded output. So the on-device shielded path -- including the confirm screen that fsm_msg_zcash.h designates as the verification gate for Orchard output values, since total_amount is "a summary prompt" taken from the host -- has no automated coverage at all. The catalog gave no hint of this. The section Z entries read exactly like the device tests around them, and that is how a screen nobody has ever rendered sat behind seven green checks. Say it in the entry instead. No screenshot hint: requesting frames from a test that cannot reach a device would produce silently zero of them, which is the same class of empty-but-green evidence this whole pass exists to remove. Verified: --screenshot-filter does not select it, and the report still renders 325 tests and passes --validate-junit against the RC artifacts. --- scripts/generate-test-report.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 2b369fdb..5c2de2e3 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -2038,7 +2038,14 @@ def _arg_shown(a): 'test_private_send_preserves_compact_real_spend_order', 'Private send preserves real-spend signature order', 'Compact device signatures remain ordered by the real-spend actions when dummy actions ' - 'are interleaved.', + 'are interleaved. OFFLINE CONTRACT TEST -- like every test in test_msg_zcash_sign_pczt, ' + 'it drives a ScriptedTransport with canned responses and never reaches a device. It ' + 'proves the client builds and orders the messages correctly; it proves nothing about ' + 'firmware behaviour, and it can never produce an OLED frame. ZcashSignPCZT is not sent ' + 'to a device anywhere in this suite, so the on-device shielded signing path -- ' + 'including the per-output confirm that is the designed verification gate for Orchard ' + 'output values -- has no automated coverage at all. Shielded signing must be walked on ' + 'real hardware.', []), ('Z18', 'test_msg_zcash_sign_pczt', 'test_missing_is_spend_is_rejected_before_device_call', From 3bbf996f09cd20a22f82ce2de6d4fb746489ed9d Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 02:15:45 -0300 Subject: [PATCH 4/8] test(zcash): sign a shielded transaction on an actual device ZcashSignPCZT had never been sent to a device by anything in this suite. Every test in test_msg_zcash_sign_pczt drives a ScriptedTransport with canned responses; the device-driven Zcash tests cover transparent signing, display-address, FVK derivation and the seed fingerprint. So the on-device shielded path had no automated coverage at all, and seven green checks in section Z read exactly like device coverage while proving only that the client serialises its messages in the right order. That is how a confirm screen which cannot physically fit its amount line shipped unnoticed. The per-output shielded confirm is the verification gate for Orchard output values -- total_amount on the summary prompt is taken straight from the host message -- and a unified address is 106 characters, three full body rows, against a three-row body. The amount never rendered. The fixtures are the firmware's own known-answer vectors from unittests/firmware/zcash.cpp, so the device's cmx recomputation accepts them without needing a Pallas implementation in Python. The same note under both pools commits to a different value, which is what makes the pool tests possible at all. Four tests: - the output review is two screens, and they render differently and non-blank (read_layout returns a framebuffer, not text, so the assertions are structural rather than OCR) - a one-bit change to the recipient breaks the commitment - the Orchard commitment is refused when Ironwood is declared - the Ironwood commitment for that same note is accepted Two gates the offline fixtures do not satisfy had to be met for real firmware: the header digest is recomputed and compared, and for a shielded-only transaction the verified fee reduces to orchard_value_balance and must equal the declared fee. Both are computed here rather than canned. VERIFIED AS A REGRESSION TEST, not just written: run against the shipped 7.15.0 RC emulator (docker image from run 31284108490, the 27970b0c6 build) it fails with "expected 2 ConfirmOutput screens, got 1", while the commitment-binding and pool-selection tests pass. It reproduces the defect on the firmware that has it. Catalogued as Z22-Z25 with screenshot hints, so the shielded confirm screens finally appear in the report -- the RC run captured 1037 OLED frames and not one came from a shielded flow. --- scripts/generate-test-report.py | 36 +++ tests/test_msg_zcash_sign_pczt_device.py | 298 +++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 tests/test_msg_zcash_sign_pczt_device.py diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 5c2de2e3..3e77d5aa 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -2068,6 +2068,42 @@ def _arg_shown(a): 'Duplicate action requests rejected', 'A repeated device request for the same action index aborts the streaming session.', []), + ('Z22', 'test_msg_zcash_sign_pczt_device', + 'test_shielded_output_review_is_two_screens', + 'Shielded output review: amount and full address (ON DEVICE)', + 'The first test in this suite that sends ZcashSignPCZT to an actual device -- Z15-Z21 ' + 'above are offline contract tests against a scripted transport. Signs a shielded-only ' + 'transaction built from the firmware\'s own known-answer note vector, so the device ' + 'accepts its recomputed commitment, and asserts the output review is two screens. It ' + 'has to be: a unified address is 106 characters, three full body rows, and the body is ' + 'three rows total, so a single confirm holding the question, the address and the amount ' + 'renders 76 characters of address and silently drops the rest along with the amount. ' + 'That screen is the verification gate for Orchard output values -- total_amount on the ' + 'summary is a host-supplied prompt -- so the amount vanishing there is the whole trust ' + 'story. Verified as a regression test: against the shipped 7.15.0 RC emulator it fails ' + 'with "expected 2 ConfirmOutput screens, got 1".', + ['Shielded amount review', 'Shielded recipient address']), + ('Z23', 'test_msg_zcash_sign_pczt_device', + 'test_note_commitment_binds_the_recipient', + 'Tampered recipient breaks the note commitment (ON DEVICE)', + 'Flipping one bit of the recipient makes the device-recomputed cmx disagree with the ' + 'supplied commitment, and signing is refused. This is what stops a host displaying one ' + 'recipient while committing to another.', + []), + ('Z24', 'test_msg_zcash_sign_pczt_device', + 'test_pool_selection_is_honoured', + 'Orchard commitment rejected under the Ironwood pool (ON DEVICE)', + 'The same note commits to a different value in each pool, so offering the Orchard ' + 'commitment while declaring Ironwood must be rejected. Passes trivially if the device ' + 'ignores shielded_pool, which is why it is paired with Z25.', + []), + ('Z25', 'test_msg_zcash_sign_pczt_device', + 'test_ironwood_note_is_accepted', + 'Ironwood commitment for the same note is accepted (ON DEVICE)', + 'The positive half of Z24: identical inputs, Ironwood commitment, accepted. Together ' + 'they prove the pool branch is selected by shielded_pool rather than one path serving ' + 'both.', + []), ]), ('D', 'BIP-85 Child Derivation', '7.14.0', diff --git a/tests/test_msg_zcash_sign_pczt_device.py b/tests/test_msg_zcash_sign_pczt_device.py new file mode 100644 index 00000000..55be6ae9 --- /dev/null +++ b/tests/test_msg_zcash_sign_pczt_device.py @@ -0,0 +1,298 @@ +"""Device-level Zcash shielded signing. + +Every other PCZT test in this suite is an offline contract test: they drive a +ScriptedTransport with canned responses and never reach a device. That left the +on-device shielded path with no automated coverage at all -- and it is not a +quiet corner of the firmware. fsm_msg_zcash.h calls total_amount "a summary +prompt" and delegates verification of Orchard output *values* to the per-output +confirm screen, so that screen is the whole trust story for a shielded send. + +Nothing had ever rendered it. The RC run captured 1037 OLED frames and not one +came from a shielded flow, which is how a confirm that could not physically fit +its amount line shipped unnoticed. + +The note fixtures are the known-answer vectors from +unittests/firmware/zcash.cpp (OrchardNoteCommitment_KnownVectorAndProgress, +IronwoodNoteCommitment_V3KnownVector, OrchardReceiverToUnifiedAddress_KnownVector), +so the device's own cmx recomputation accepts them. Same note under both pools, +with a different commitment each -- which is what lets us prove the device +actually honours shielded_pool instead of ignoring it. +""" + +import hashlib +import struct +import unittest + +import common + +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib import messages_zcash_pb2 as zcash_proto + + +H = 0x80000000 +ADDRESS_N = [H + 32, H + 133, H] + +# --- known-answer note, from unittests/firmware/zcash.cpp ------------------- +RECIPIENT = bytes.fromhex( + '3c150e6098b861716cc7f62835f69feb302193c92660444f26624fd13e00ea7a' + 'c774cd55074d6367efef37') # 43 bytes +RHO = bytes.fromhex( + '112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00') +RSEED = bytes.fromhex( + 'cafebabedeadbeef0102030405060708090a0b0c0d0e0f101112131415161718') +VALUE = 12345678 + +CMX_ORCHARD = bytes.fromhex( + '02defb39c8f2e1ecc945189373cf2a8e21d4e154398efa1621d5fb989e1deb36') +CMX_IRONWOOD = bytes.fromhex( + '896ee345d8b0409872172537666a482409661a22ad77c09896a3e71765f18633') + +# OrchardReceiverToUnifiedAddress_KnownVector. 106 characters -- three full +# body rows on their own, which is the entire reason the confirm needs two +# screens instead of one. +EXPECTED_UA = ('u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf' + '74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7') + +ORCHARD_TX = dict(tx_version=5, version_group_id=0x26A7270A, branch_id=0x5437F330) +IRONWOOD_TX = dict(tx_version=6, version_group_id=0xD884B698, branch_id=0x37A5165B) + +ANCHOR = b'\x13' * 32 +FLAGS = 3 + + +def _b2b(person, data): + return hashlib.blake2b(data, digest_size=32, person=person).digest() + + +def header_digest(tx_version, version_group_id, branch_id, lock_time, expiry): + """BLAKE2b-256('ZTxIdHeadersHash', 20-byte LE header). zcash.c:840-857.""" + header = struct.pack(' Date: Tue, 11 Aug 2026 19:42:48 -0600 Subject: [PATCH 5/8] fix(report): K8 named a storage test that no longer exists The firmware test was renamed PinKdfV16RewrapsToV19AfterCorrectPin -> PinKdfRewrapsToActiveVersionAfterCorrectPin when the STORAGE_PIN_KDF_V19 gate went to 0, because it is no longer v19-specific: it now asserts BOTH sides of the gate, which is what makes it meaningful in the shipping build where v19 is off. Catalog validation failed against the old name. Adds K8b for PinUnlocksAfterRebootUnderV17, the end-to-end create/set-PIN/ serialize/reload/unlock/decrypt regression. It belongs in a curated catalog precisely because every other storage test stays in RAM, and the wallet lockout it guards lived on the serialize/reboot boundary. --- scripts/generate-test-report.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 3e77d5aa..673e18f0 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -745,12 +745,23 @@ def _arg_shown(a): 'Bytes past the declared roll count must not affect the result, so uninitialized tail ' 'bytes of the roll buffer can never leak into seed material.', []), - ('K8', 'Storage', 'PinKdfV16RewrapsToV19AfterCorrectPin', - 'v16 storage unlocks and rewraps to v19', - 'The migration path for the hardened PIN KDF: an existing device on the old format must ' - 'still unlock with its current PIN and then be rewrapped. If this regressed, every ' + ('K8', 'Storage', 'PinKdfRewrapsToActiveVersionAfterCorrectPin', + 'Correct PIN unlocks and rewraps to the ACTIVE KDF', + 'The migration path for the hardened PIN KDF: an existing device must still unlock with ' + 'its current PIN, and any rewrap must target whatever KDF the build actually has ' + 'enabled. Renamed from PinKdfV16RewrapsToV19AfterCorrectPin because it is no longer ' + 'v19-specific -- the test now asserts BOTH sides of the STORAGE_PIN_KDF_V19 gate, so it ' + 'is meaningful in the shipping build where v19 is off. If this regressed, every ' 'upgrading device would be locked out of its own seed.', []), + ('K8b', 'Storage', 'PinUnlocksAfterRebootUnderV17', + 'The PIN still opens the wallet after a reboot', + 'The whole round trip in device order: create, set a PIN, serialize the V17 record as ' + 'storage_commit() does, reload into fresh state as a boot would, unlock, decrypt. Every ' + 'other storage test stays in RAM, and the wallet lockout this guards against lived ' + 'exactly on the serialize/reboot boundary -- a wrap the persisted record could not ' + 'describe, so the next boot derived the wrong KDF and every PIN failed.', + []), ('K9', 'Storage', 'PinKdfV2FlagIsVersionedInV19', 'KDF version flag is recorded in v19', 'The new KDF is marked in the storage version band, so firmware can tell which derivation ' From f558eeff9046f4e12f152d63b633c6c1d5412171 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 15 Aug 2026 00:51:02 -0600 Subject: [PATCH 6/8] test(eth): pin the multi-byte chain_id EIP-1559 regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware fix ed6db167 shipped without a test at a chain id that reproduces it. Every EIP-1559 case in this file uses chain_id 1 or 3 — both single-byte — so the bug had zero coverage in the file that tests the feature. hash_rlp_field((uint8_t*)&chain_id, 1) fed only the least-significant byte into keccak on little-endian ARM. Base (8453 = 0x2105) hashed 0x05 and the signature recovered to an unrelated address. RLP length was correct; the legacy EIP-155 path was correct; only the EIP-1559 hash was wrong. A golden r/s needs a device run, so this is a differential: sign one identical tx under 8453 (0x2105) and 4357 (0x1105). Same low byte AND same RLP length header, so broken firmware hashes an identical pre-image and — signing being deterministic — returns the same signature twice, failing the assertion. Fixed firmware hashes 0x21 0x05 vs 0x11 0x05. No golden value, no new deps (the repo has ecdsa but no keccak, so recovering the signer address was not available). Version-gated to 7.15.0 so it SKIPs rather than fails on older firmware. --- tests/test_msg_ethereum_signtx.py | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_msg_ethereum_signtx.py b/tests/test_msg_ethereum_signtx.py index 501b36d8..241c9e02 100644 --- a/tests/test_msg_ethereum_signtx.py +++ b/tests/test_msg_ethereum_signtx.py @@ -405,6 +405,65 @@ def test_ethereum_eip_1559(self): "67297089e0ba53c29dda1aafc23fce64a772c5433e127e5885edc03ece4670c9", ) + def test_ethereum_eip_1559_multibyte_chain_id(self): + """EIP-1559 must hash the WHOLE chain_id, not just its low byte. + + Regression for the multi-byte chain_id bug (firmware ed6db167). The + EIP-1559 hash step used hash_rlp_field((uint8_t*)&chain_id, 1), which on + little-endian ARM fed only the least-significant byte into keccak. For + Base (8453 = 0x2105) that hashed 0x05, so the signature recovered to an + unrelated address with no funds. The RLP *length* was computed correctly + from the full value and the legacy EIP-155 path was always correct — + only the EIP-1559 hash was wrong. Affected: Base (8453), Arbitrum + (42161), Avalanche (43114). Unaffected: ETH (1), OP (10), BSC (56), + Polygon (137) — all single-byte. + + Every other EIP-1559 case in this file uses chain_id 1 or 3, so the bug + had no coverage in the file that tests the feature. + + A golden r/s would need a device run to produce, so this is a + differential. Sign one identical transaction under two chain ids the + BUGGY firmware cannot tell apart: + + 8453 = 0x2105 low byte 0x05, two-byte value + 4357 = 0x1105 low byte 0x05, two-byte value + + Same low byte AND same RLP length header, so the broken code hashes a + byte-identical pre-image for both. Signing is deterministic (RFC 6979), + so buggy firmware returns the SAME signature twice and this fails. + Correct firmware hashes 0x21 0x05 vs 0x11 0x05, which must differ. + + Note a comparison against chain_id=5 would NOT work: the RLP length was + always derived from the full value, so the buggy pre-image for 8453 is + malformed rather than equal to a well-formed single-byte encoding. The + twin must match on both low byte and byte-width. + """ + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_nopin_nopassphrase() + + def sign(chain_id): + return self.client.ethereum_sign_tx( + n=[0x80000000 | 44, 0x80000000 | 60, 0x80000000, 0, 0], + nonce=0, + gas_limit=0x5ac3, + max_fee_per_gas=0x16854be509, + max_priority_fee_per_gas=0x540ae480, + to=binascii.unhexlify("fc0cc6e85dff3d75e3985e0cb83b090cfd498dd1"), + value=0x1550f7dca70000, + chain_id=chain_id, + ) + + _, base_r, base_s = sign(8453) + _, twin_r, twin_s = sign(4357) + + self.assertNotEqual( + (binascii.hexlify(base_r), binascii.hexlify(base_s)), + (binascii.hexlify(twin_r), binascii.hexlify(twin_s)), + "chain_id 8453 and 4357 produced the same signature — only the low " + "byte of chain_id reached the EIP-1559 hash", + ) + def test_ethereum_signtx_nodata_eip_1559(self): self.requires_fullFeature() self.requires_firmware("7.2.1") From 040a9e5a0ed5758a28e758dfcd81ef61c60c71f0 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 15 Aug 2026 00:56:44 -0600 Subject: [PATCH 7/8] test(reset): version-gate the display_random tests to 7.15.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on #212: test_reset_device_pin and test_failed_pin both failed with 'AssertionError: code: ButtonRequest_ResetDevice'. Not a bug in b44f1b3 — that commit is correct. Firmware 7.15.0 removed the Internal Entropy screen (fw 320f0eb5, 'auditable entropy source, on-device dice, and no entropy display'), because internal entropy is seed pre-image material: a host that sets display_random and reads the screen once can compute SHA256(shown || ext) and derive the seed. Confirmed by inspection — reset.c on the rc29 line contains no display_random references at all, while the pinned older submodule still has 'if (display_random) {'. So the assertion 'next message is PinMatrixRequest' is right for 7.15.0+ and wrong for anything earlier, where that ButtonRequest legitimately still exists. The tests carried NO version gate, so on CI's older emulator they failed instead of skipping. This is the same root cause as test_ethereum_eip_1559_multibyte_chain_id skipping in the same run: CI's emulator predates 7.15.0. That test was gated and skipped cleanly; these two were not and went red. Per the house rule that feature tests must version-gate to SKIP, gate both at 7.15.0. Worth noting the failure was only visible because these commits finally entered a PR — they were reachable solely from a fork branch that rc29 pinned directly, so the breakage rode along unreviewed. --- tests/test_msg_resetdevice.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_msg_resetdevice.py b/tests/test_msg_resetdevice.py index e1d3c4cd..98aafbea 100644 --- a/tests/test_msg_resetdevice.py +++ b/tests/test_msg_resetdevice.py @@ -235,6 +235,14 @@ def test_reset_reentry_disarms_entropy_ack(self): self.assertFalse(ret.initialized) def test_reset_device_pin(self): + # Firmware 7.15.0 removed the Internal Entropy screen (fw 320f0eb5, + # "no entropy display"): internal entropy is seed pre-image material, so a + # host that sets display_random and reads that screen could compute + # SHA256(shown || ext) and derive the seed. This test asserts the POST-removal + # flow (next message is PinMatrixRequest, not a ButtonRequest), so it must + # SKIP on older firmware rather than fail against a screen that legitimately + # still exists there. + self.requires_firmware("7.15.0") external_entropy = b'zlutoucky kun upel divoke ody' * 2 strength = 128 @@ -309,6 +317,14 @@ def test_reset_device_pin(self): self.client.call_raw(proto.Cancel()) def test_failed_pin(self): + # Firmware 7.15.0 removed the Internal Entropy screen (fw 320f0eb5, + # "no entropy display"): internal entropy is seed pre-image material, so a + # host that sets display_random and reads that screen could compute + # SHA256(shown || ext) and derive the seed. This test asserts the POST-removal + # flow (next message is PinMatrixRequest, not a ButtonRequest), so it must + # SKIP on older firmware rather than fail against a screen that legitimately + # still exists there. + self.requires_firmware("7.15.0") external_entropy = 'zlutoucky kun upel divoke ody' * 2 strength = 128 From 2cf5edce69d60e89174b11b391f420aaeef9967c Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 15 Aug 2026 01:02:29 -0600 Subject: [PATCH 8/8] fix(review): address all four technical findings on #212 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [1] Version gate removed real reset coverage. My requires_firmware("7.15.0") skipped the WHOLE of test_reset_device_pin / test_failed_pin on the 7.10 emulator — PIN entry, EntropyRequest/Ack, and mnemonic derivation checked against a locally computed expected value — to silence one assertion. Branch on the version instead: pre-7.15 acks the Internal Entropy ButtonRequest that legitimately still exists there, then both paths converge on PinMatrixRequest. Everything downstream keeps running on every firmware. [2] Z17 claimed 'ZcashSignPCZT is not sent to a device anywhere in this suite ... no automated coverage at all' while 3bbf996 in the same branch added exactly that device test. Scoped the claim to THIS module and pointed at Z22. Notable because the comment above the scope block explains that saying so is 'the difference between evidence and a misleading completeness claim'. [3] The report said CI 'executed' JUNIT_CENSUS['ran'], which increments for every collected testcase including skips. The run behind this PR was 613 collected / 252 skipped — the report would have overstated execution by 41%, and a version-gated test that SKIPs on an old emulator is not evidence the feature works. Track skipped separately and say 'collected', with the skip count and why skips happen stated inline. [4] _capture_button_screens read the framebuffer BEFORE delegating to the original callback, which is where the render-settle delay lives — so it could capture a partially drawn or previous screen. Reading after would be worse (the original presses the button and advances). Settle inside the wrapper before reading. Unconditional, unlike client.callback_ButtonRequest's SCREENSHOT-only sleep, because these are structural assertions rather than screenshot evidence and need a settled layout on every run. [5] handled in the PR description. --- scripts/generate-test-report.py | 24 +++++++---- tests/test_msg_resetdevice.py | 52 +++++++++++++----------- tests/test_msg_zcash_sign_pczt_device.py | 17 ++++++++ 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 673e18f0..37c60496 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -344,7 +344,7 @@ def detect_fw(): # state how much of the run it covers. Without this the PDF silently implies # that its catalog IS the test suite -- an RC audit read "no dice in the report" # as "dice is untested" when test_reset_device_dice had in fact run green. -JUNIT_CENSUS = {'ran': 0, 'native': 0} +JUNIT_CENSUS = {'ran': 0, 'skipped': 0, 'native': 0} def parse_junit(path): @@ -367,6 +367,11 @@ def parse_junit(path): elif tc.find('skipped') is not None: status = 'skip' else: status = 'pass' JUNIT_CENSUS['ran'] += 1 + # 'ran' counts every collected testcase, skips included. A version-gated + # feature test that SKIPs on an older emulator is NOT evidence the feature + # works, so the two must never be reported as one number. + if status == 'skip': + JUNIT_CENSUS['skipped'] += 1 # Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo mod = '' if cls: @@ -2053,10 +2058,10 @@ def _arg_shown(a): 'it drives a ScriptedTransport with canned responses and never reaches a device. It ' 'proves the client builds and orders the messages correctly; it proves nothing about ' 'firmware behaviour, and it can never produce an OLED frame. ZcashSignPCZT is not sent ' - 'to a device anywhere in this suite, so the on-device shielded signing path -- ' - 'including the per-output confirm that is the designed verification gate for Orchard ' - 'output values -- has no automated coverage at all. Shielded signing must be walked on ' - 'real hardware.', + 'to a device anywhere in THIS module. On-device shielded signing is covered ' + 'separately by test_msg_zcash_sign_pczt_device (see Z22), which drives a real ' + 'device and asserts the per-output confirm screens; this module proves only that ' + 'the client builds and orders the messages correctly.', []), ('Z18', 'test_msg_zcash_sign_pczt', 'test_missing_is_spend_is_rejected_before_device_call', @@ -2200,10 +2205,13 @@ def _section_state(s): ran = JUNIT_CENSUS['ran'] if ran: pb.gap(3) - for line in _w('Scope: this report is a curated catalog of %d tests. The CI run executed %d ' - '(%d of them native firmware unit tests). Absence from this report is NOT ' + skipped = JUNIT_CENSUS['skipped'] + for line in _w('Scope: this report is a curated catalog of %d tests. The CI run collected %d ' + '(%d of them native firmware unit tests); %d SKIPPED and did not execute, ' + 'usually because the emulator predates the firmware the test targets -- a skip ' + 'is not evidence the feature works. Absence from this report is NOT ' 'evidence that a feature is untested -- check the JUnit artifacts.' - % (total, ran, JUNIT_CENSUS['native']), 100): + % (total, ran, JUNIT_CENSUS['native'], skipped), 100): pb.text(8, line, color=GRAY) pb.gap(6) pb.text(12, 'Sections', bold=True) diff --git a/tests/test_msg_resetdevice.py b/tests/test_msg_resetdevice.py index 98aafbea..278f7e09 100644 --- a/tests/test_msg_resetdevice.py +++ b/tests/test_msg_resetdevice.py @@ -235,14 +235,6 @@ def test_reset_reentry_disarms_entropy_ack(self): self.assertFalse(ret.initialized) def test_reset_device_pin(self): - # Firmware 7.15.0 removed the Internal Entropy screen (fw 320f0eb5, - # "no entropy display"): internal entropy is seed pre-image material, so a - # host that sets display_random and reads that screen could compute - # SHA256(shown || ext) and derive the seed. This test asserts the POST-removal - # flow (next message is PinMatrixRequest, not a ButtonRequest), so it must - # SKIP on older firmware rather than fail against a screen that legitimately - # still exists there. - self.requires_firmware("7.15.0") external_entropy = b'zlutoucky kun upel divoke ody' * 2 strength = 128 @@ -254,10 +246,20 @@ def test_reset_device_pin(self): label='test')) # display_random=True above is deliberate: the field stays in the wire - # schema for host compatibility but production firmware ignores it, - # because internal entropy is seed pre-image material. A host that - # sets it must get a NORMAL reset -- no Internal Entropy screen -- so - # the very next message is the PIN request, not a ButtonRequest. + # schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no + # entropy display") stopped honouring it -- internal entropy is seed + # pre-image material, and a host that sets the flag and reads that + # screen once can compute SHA256(shown || ext) and derive the seed. + # + # Branch on the version rather than skipping the test: everything below + # (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version- + # independent and must keep running on older firmware. + f = self.client.features + if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0): + # Pre-7.15: the Internal Entropy screen legitimately still exists. + self.assertIsInstance(ret, proto.ButtonRequest) + self.client.debug.press_yes() + ret = self.client.call_raw(proto.ButtonAck()) self.assertIsInstance(ret, proto.PinMatrixRequest) # Enter PIN for first time @@ -317,14 +319,6 @@ def test_reset_device_pin(self): self.client.call_raw(proto.Cancel()) def test_failed_pin(self): - # Firmware 7.15.0 removed the Internal Entropy screen (fw 320f0eb5, - # "no entropy display"): internal entropy is seed pre-image material, so a - # host that sets display_random and reads that screen could compute - # SHA256(shown || ext) and derive the seed. This test asserts the POST-removal - # flow (next message is PinMatrixRequest, not a ButtonRequest), so it must - # SKIP on older firmware rather than fail against a screen that legitimately - # still exists there. - self.requires_firmware("7.15.0") external_entropy = 'zlutoucky kun upel divoke ody' * 2 strength = 128 @@ -336,10 +330,20 @@ def test_failed_pin(self): label='test')) # display_random=True above is deliberate: the field stays in the wire - # schema for host compatibility but production firmware ignores it, - # because internal entropy is seed pre-image material. A host that - # sets it must get a NORMAL reset -- no Internal Entropy screen -- so - # the very next message is the PIN request, not a ButtonRequest. + # schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no + # entropy display") stopped honouring it -- internal entropy is seed + # pre-image material, and a host that sets the flag and reads that + # screen once can compute SHA256(shown || ext) and derive the seed. + # + # Branch on the version rather than skipping the test: everything below + # (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version- + # independent and must keep running on older firmware. + f = self.client.features + if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0): + # Pre-7.15: the Internal Entropy screen legitimately still exists. + self.assertIsInstance(ret, proto.ButtonRequest) + self.client.debug.press_yes() + ret = self.client.call_raw(proto.ButtonAck()) self.assertIsInstance(ret, proto.PinMatrixRequest) # Enter PIN for first time diff --git a/tests/test_msg_zcash_sign_pczt_device.py b/tests/test_msg_zcash_sign_pczt_device.py index 55be6ae9..97a59e67 100644 --- a/tests/test_msg_zcash_sign_pczt_device.py +++ b/tests/test_msg_zcash_sign_pczt_device.py @@ -21,6 +21,7 @@ import hashlib import struct +import time import unittest import common @@ -182,6 +183,11 @@ def _lit_pixels(layout): return total +# Matches client.SCREENSHOT_SETTLE_SECONDS; the emulator needs a moment to +# finish drawing after ButtonRequest before read_layout() is meaningful. +BUTTON_RENDER_SETTLE_SECONDS = 0.5 + + class TestZcashShieldedSigningDevice(common.KeepKeyTest): def setUp(self): @@ -197,6 +203,17 @@ def _capture_button_screens(self): original = self.client.callback_ButtonRequest def capture(msg): + # The firmware emits ButtonRequest immediately BEFORE drawing the + # confirmation, so the framebuffer must be allowed to settle first. + # original(msg) does contain that delay, but it runs after this read + # and then presses the button -- so reading before it captures a + # partially drawn (or previous) screen, and reading after it captures + # the NEXT one. Settle here instead. + # + # Unconditional, unlike client.callback_ButtonRequest's SCREENSHOT-only + # sleep: these are structural assertions, not screenshot evidence, so + # they need a settled layout on every run. + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) screens.append((msg.code, self.client.debug.read_layout())) return original(msg)