From 74768b021facbda85d3a0697769ca2144d56068b Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 22:20:22 -0300 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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('