@@ -340,9 +340,22 @@ def detect_fw():
340340 v = f'{ r .major_version } .{ r .minor_version } .{ r .patch_version } ' ; c .close (); return v
341341 except : return None
342342
343+ # Census of everything the merged JUnit actually contained, so the report can
344+ # state how much of the run it covers. Without this the PDF silently implies
345+ # that its catalog IS the test suite -- an RC audit read "no dice in the report"
346+ # as "dice is untested" when test_reset_device_dice had in fact run green.
347+ JUNIT_CENSUS = {'ran' : 0 , 'skipped' : 0 , 'native' : 0 }
348+
349+
343350def parse_junit (path ):
344351 """Parse junit XML for pass/fail. Returns dict keyed by 'module::method' (precise)
345- and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo."""
352+ and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo.
353+
354+ Native gtest suites carry a bare classname ("Dice", "Storage") with no dotted
355+ python module, so they get keyed as 'Suite::Test'. They used to produce no
356+ 'mod::meth' key at all, which made every native unit test structurally
357+ impossible to put in SECTIONS -- the firmware-unit XMLs were merged in and
358+ then silently unusable."""
346359 if not path or not os .path .exists (path ): return {}
347360 import xml .etree .ElementTree as ET
348361 results = {}
@@ -353,6 +366,12 @@ def parse_junit(path):
353366 elif tc .find ('error' ) is not None : status = 'error'
354367 elif tc .find ('skipped' ) is not None : status = 'skip'
355368 else : status = 'pass'
369+ JUNIT_CENSUS ['ran' ] += 1
370+ # 'ran' counts every collected testcase, skips included. A version-gated
371+ # feature test that SKIPs on an older emulator is NOT evidence the feature
372+ # works, so the two must never be reported as one number.
373+ if status == 'skip' :
374+ JUNIT_CENSUS ['skipped' ] += 1
356375 # Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo
357376 mod = ''
358377 if cls :
@@ -361,6 +380,9 @@ def parse_junit(path):
361380 if p .startswith ('test_msg_' ) or p .startswith ('test_sign_' ) or p .startswith ('test_verify_' ):
362381 mod = p
363382 break
383+ if not mod and '.' not in cls :
384+ mod = cls # native gtest suite
385+ JUNIT_CENSUS ['native' ] += 1
364386 results [f'{ cls } .{ name } ' ] = status
365387 # Key by module::method (disambiguates collisions like test_sign_btc_eth_swap)
366388 if mod :
@@ -672,6 +694,95 @@ def _arg_shown(a):
672694 ['Wordlist rejection warning' ]),
673695 ]),
674696
697+ ('K' , 'Seed Generation Hardening (7.15)' , '7.15.0' ,
698+ 'The 7.15 changes to how a seed comes into existence: user-supplied dice entropy folded in '
699+ 'on-device, and the PIN key-derivation rewrap. These ran green from the first 7.15 RC but '
700+ 'appeared nowhere in this report, because the catalog could not reference native firmware '
701+ 'unit tests at all and nobody had catalogued the two new pyk cases. Absent evidence read as '
702+ 'absent coverage during an RC audit, which is exactly the failure this section exists to '
703+ 'prevent.' ,
704+ [
705+ 'DICE: user rolls a d6 on-device; short press advances 1-6, long press commits, undo backs out.' ,
706+ 'The roll string is hashed and the digest confirmed on the OLED before it is mixed in.' ,
707+ 'MIX: int_entropy = SHA256(int_entropy || rolls), folded in BEFORE the host EntropyRequest,' ,
708+ 'so the device commits to its own contribution first and the host cannot choose the seed.' ,
709+ 'ABORT: any aborted reset must disarm EntropyAck, or a later host EntropyAck would derive' ,
710+ 'a seed from sha256(0*32 || host_bytes) -- entirely host-chosen. That is K2.' ,
711+ 'PIN KDF: a v16 storage blob must still unlock and then rewrap to v19, or the upgrade bricks.' ,
712+ ],
713+ [
714+ ('K1' , 'test_msg_resetdevice' , 'test_reset_device_dice' ,
715+ 'Dice entropy end-to-end' ,
716+ 'Drives the full on-device dice flow over DebugLink: 99 rolls injected in chunks with undo '
717+ 'exercised, extras past the cap dropped. Asserts the device-computed digest equals '
718+ 'SHA256 of exactly the expected roll string, then derives the mnemonic from the post-mix '
719+ 'internal entropy and compares -- which is what proves the rolls actually reached the seed '
720+ 'rather than being collected and discarded.' ,
721+ ['Dice entry screen' , 'Digest confirmation' ]),
722+ ('K2' , 'test_msg_resetdevice' , 'test_reset_reentry_disarms_entropy_ack' ,
723+ 'Aborted reset disarms EntropyAck' ,
724+ 'Regression for a host-chosen-seed hole: reset_init aborts left awaiting_entropy set from '
725+ 'an earlier run while zeroing int_entropy, so a following EntropyAck derived the seed '
726+ 'from host bytes alone. Arms a reset, re-enters with dice, cancels, and asserts the '
727+ 'next EntropyAck is refused with "Not in Reset mode" and the device stays uninitialized.' ,
728+ []),
729+ ('K3' , 'Dice' , 'RollsForStrength' ,
730+ 'Roll count per seed strength' ,
731+ 'd6 carries log2(6)=2.585 bits, so 128/192/256-bit seeds need 50/75/99 rolls '
732+ '(the Coldcard convention). A short count would silently weaken the seed.' ,
733+ []),
734+ ('K4' , 'Dice' , 'MixZeroEntropyVector' ,
735+ 'Mix known-answer vector (zero entropy)' ,
736+ 'SHA256(0x00*32 || "123456") against a hardcoded digest. Pins the mix construction so a '
737+ 'refactor cannot quietly change how dice enter the seed.' ,
738+ []),
739+ ('K5' , 'Dice' , 'MixNonZeroEntropyVector' ,
740+ 'Mix known-answer vector (non-zero entropy)' ,
741+ 'Same construction with a non-zero starting entropy buffer, pinned to a hardcoded digest.' ,
742+ []),
743+ ('K6' , 'Dice' , 'MixDependsOnRolls' ,
744+ 'Different rolls produce different entropy' ,
745+ 'Two mixes differing only in the final roll must diverge. Catches a mix that ignores its '
746+ 'roll argument -- the failure mode where dice appear to work and contribute nothing.' ,
747+ []),
748+ ('K7' , 'Dice' , 'MixUsesExactCount' ,
749+ 'Only the counted rolls contribute' ,
750+ 'Bytes past the declared roll count must not affect the result, so uninitialized tail '
751+ 'bytes of the roll buffer can never leak into seed material.' ,
752+ []),
753+ ('K8' , 'Storage' , 'PinKdfRewrapsToActiveVersionAfterCorrectPin' ,
754+ 'Correct PIN unlocks and rewraps to the ACTIVE KDF' ,
755+ 'The migration path for the hardened PIN KDF: an existing device must still unlock with '
756+ 'its current PIN, and any rewrap must target whatever KDF the build actually has '
757+ 'enabled. Renamed from PinKdfV16RewrapsToV19AfterCorrectPin because it is no longer '
758+ 'v19-specific -- the test now asserts BOTH sides of the STORAGE_PIN_KDF_V19 gate, so it '
759+ 'is meaningful in the shipping build where v19 is off. If this regressed, every '
760+ 'upgrading device would be locked out of its own seed.' ,
761+ []),
762+ ('K8b' , 'Storage' , 'PinUnlocksAfterRebootUnderV17' ,
763+ 'The PIN still opens the wallet after a reboot' ,
764+ 'The whole round trip in device order: create, set a PIN, serialize the V17 record as '
765+ 'storage_commit() does, reload into fresh state as a boot would, unlock, decrypt. Every '
766+ 'other storage test stays in RAM, and the wallet lockout this guards against lived '
767+ 'exactly on the serialize/reboot boundary -- a wrap the persisted record could not '
768+ 'describe, so the next boot derived the wrong KDF and every PIN failed.' ,
769+ []),
770+ ('K9' , 'Storage' , 'PinKdfV2FlagIsVersionedInV19' ,
771+ 'KDF version flag is recorded in v19' ,
772+ 'The new KDF is marked in the storage version band, so firmware can tell which derivation '
773+ 'a blob was written with instead of guessing.' ,
774+ []),
775+ ('K10' , 'Storage' , 'StorageUpgrade_Normal' ,
776+ 'Normal storage upgrade path' ,
777+ 'Baseline upgrade across storage versions with policies and cache preserved.' ,
778+ []),
779+ ('K11' , 'Storage' , 'NoopSecMigrate' ,
780+ 'Idempotent security migration' ,
781+ 'Re-running the migration on already-migrated storage must be a no-op rather than a '
782+ 'second rewrap.' ,
783+ []),
784+ ]),
785+
675786 ('B' , 'Bitcoin' , '7.0.0' ,
676787 'Bitcoin is the primary chain and most extensively tested. Covers legacy P2PKH, P2SH-wrapped '
677788 'SegWit, native SegWit (bech32), and Taproot (P2TR). Transaction signing validates that the '
@@ -1943,7 +2054,14 @@ def _arg_shown(a):
19432054 'test_private_send_preserves_compact_real_spend_order' ,
19442055 'Private send preserves real-spend signature order' ,
19452056 'Compact device signatures remain ordered by the real-spend actions when dummy actions '
1946- 'are interleaved.' ,
2057+ 'are interleaved. OFFLINE CONTRACT TEST -- like every test in test_msg_zcash_sign_pczt, '
2058+ 'it drives a ScriptedTransport with canned responses and never reaches a device. It '
2059+ 'proves the client builds and orders the messages correctly; it proves nothing about '
2060+ 'firmware behaviour, and it can never produce an OLED frame. ZcashSignPCZT is not sent '
2061+ 'to a device anywhere in THIS module. On-device shielded signing is covered '
2062+ 'separately by test_msg_zcash_sign_pczt_device (see Z22), which drives a real '
2063+ 'device and asserts the per-output confirm screens; this module proves only that '
2064+ 'the client builds and orders the messages correctly.' ,
19472065 []),
19482066 ('Z18' , 'test_msg_zcash_sign_pczt' ,
19492067 'test_missing_is_spend_is_rejected_before_device_call' ,
@@ -1966,6 +2084,42 @@ def _arg_shown(a):
19662084 'Duplicate action requests rejected' ,
19672085 'A repeated device request for the same action index aborts the streaming session.' ,
19682086 []),
2087+ ('Z22' , 'test_msg_zcash_sign_pczt_device' ,
2088+ 'test_shielded_output_review_is_two_screens' ,
2089+ 'Shielded output review: amount and full address (ON DEVICE)' ,
2090+ 'The first test in this suite that sends ZcashSignPCZT to an actual device -- Z15-Z21 '
2091+ 'above are offline contract tests against a scripted transport. Signs a shielded-only '
2092+ 'transaction built from the firmware\' s own known-answer note vector, so the device '
2093+ 'accepts its recomputed commitment, and asserts the output review is two screens. It '
2094+ 'has to be: a unified address is 106 characters, three full body rows, and the body is '
2095+ 'three rows total, so a single confirm holding the question, the address and the amount '
2096+ 'renders 76 characters of address and silently drops the rest along with the amount. '
2097+ 'That screen is the verification gate for Orchard output values -- total_amount on the '
2098+ 'summary is a host-supplied prompt -- so the amount vanishing there is the whole trust '
2099+ 'story. Verified as a regression test: against the shipped 7.15.0 RC emulator it fails '
2100+ 'with "expected 2 ConfirmOutput screens, got 1".' ,
2101+ ['Shielded amount review' , 'Shielded recipient address' ]),
2102+ ('Z23' , 'test_msg_zcash_sign_pczt_device' ,
2103+ 'test_note_commitment_binds_the_recipient' ,
2104+ 'Tampered recipient breaks the note commitment (ON DEVICE)' ,
2105+ 'Flipping one bit of the recipient makes the device-recomputed cmx disagree with the '
2106+ 'supplied commitment, and signing is refused. This is what stops a host displaying one '
2107+ 'recipient while committing to another.' ,
2108+ []),
2109+ ('Z24' , 'test_msg_zcash_sign_pczt_device' ,
2110+ 'test_pool_selection_is_honoured' ,
2111+ 'Orchard commitment rejected under the Ironwood pool (ON DEVICE)' ,
2112+ 'The same note commits to a different value in each pool, so offering the Orchard '
2113+ 'commitment while declaring Ironwood must be rejected. Passes trivially if the device '
2114+ 'ignores shielded_pool, which is why it is paired with Z25.' ,
2115+ []),
2116+ ('Z25' , 'test_msg_zcash_sign_pczt_device' ,
2117+ 'test_ironwood_note_is_accepted' ,
2118+ 'Ironwood commitment for the same note is accepted (ON DEVICE)' ,
2119+ 'The positive half of Z24: identical inputs, Ironwood commitment, accepted. Together '
2120+ 'they prove the pool branch is selected by shielded_pool rather than one path serving '
2121+ 'both.' ,
2122+ []),
19692123 ]),
19702124
19712125 ('D' , 'BIP-85 Child Derivation' , '7.14.0' ,
@@ -2044,6 +2198,21 @@ def _section_state(s):
20442198 if build_label :
20452199 for line in _w (f'Candidate: { build_label } ' , 95 ):
20462200 pb .text (8 , line , bold = True )
2201+ # Scope of this document. The catalog is a curated subset, and saying so is
2202+ # the difference between evidence and a misleading completeness claim: an RC
2203+ # audit grepped this PDF for feature keywords, found none, and reported four
2204+ # features as untested when their tests had run green in the same CI run.
2205+ ran = JUNIT_CENSUS ['ran' ]
2206+ if ran :
2207+ pb .gap (3 )
2208+ skipped = JUNIT_CENSUS ['skipped' ]
2209+ for line in _w ('Scope: this report is a curated catalog of %d tests. The CI run collected %d '
2210+ '(%d of them native firmware unit tests); %d SKIPPED and did not execute, '
2211+ 'usually because the emulator predates the firmware the test targets -- a skip '
2212+ 'is not evidence the feature works. Absence from this report is NOT '
2213+ 'evidence that a feature is untested -- check the JUnit artifacts.'
2214+ % (total , ran , JUNIT_CENSUS ['native' ], skipped ), 100 ):
2215+ pb .text (8 , line , color = GRAY )
20472216 pb .gap (6 )
20482217 pb .text (12 , 'Sections' , bold = True )
20492218 _hdr_withheld = _hdr_pending = False
0 commit comments