Skip to content

Commit 74768b0

Browse files
committed
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".
1 parent b44f1b3 commit 74768b0

1 file changed

Lines changed: 108 additions & 1 deletion

File tree

scripts/generate-test-report.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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, 'native': 0}
348+
349+
343350
def 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,7 @@ 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
356370
# Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo
357371
mod = ''
358372
if cls:
@@ -361,6 +375,9 @@ def parse_junit(path):
361375
if p.startswith('test_msg_') or p.startswith('test_sign_') or p.startswith('test_verify_'):
362376
mod = p
363377
break
378+
if not mod and '.' not in cls:
379+
mod = cls # native gtest suite
380+
JUNIT_CENSUS['native'] += 1
364381
results[f'{cls}.{name}'] = status
365382
# Key by module::method (disambiguates collisions like test_sign_btc_eth_swap)
366383
if mod:
@@ -672,6 +689,84 @@ def _arg_shown(a):
672689
['Wordlist rejection warning']),
673690
]),
674691

692+
('K', 'Seed Generation Hardening (7.15)', '7.15.0',
693+
'The 7.15 changes to how a seed comes into existence: user-supplied dice entropy folded in '
694+
'on-device, and the PIN key-derivation rewrap. These ran green from the first 7.15 RC but '
695+
'appeared nowhere in this report, because the catalog could not reference native firmware '
696+
'unit tests at all and nobody had catalogued the two new pyk cases. Absent evidence read as '
697+
'absent coverage during an RC audit, which is exactly the failure this section exists to '
698+
'prevent.',
699+
[
700+
'DICE: user rolls a d6 on-device; short press advances 1-6, long press commits, undo backs out.',
701+
'The roll string is hashed and the digest confirmed on the OLED before it is mixed in.',
702+
'MIX: int_entropy = SHA256(int_entropy || rolls), folded in BEFORE the host EntropyRequest,',
703+
'so the device commits to its own contribution first and the host cannot choose the seed.',
704+
'ABORT: any aborted reset must disarm EntropyAck, or a later host EntropyAck would derive',
705+
'a seed from sha256(0*32 || host_bytes) -- entirely host-chosen. That is K2.',
706+
'PIN KDF: a v16 storage blob must still unlock and then rewrap to v19, or the upgrade bricks.',
707+
],
708+
[
709+
('K1', 'test_msg_resetdevice', 'test_reset_device_dice',
710+
'Dice entropy end-to-end',
711+
'Drives the full on-device dice flow over DebugLink: 99 rolls injected in chunks with undo '
712+
'exercised, extras past the cap dropped. Asserts the device-computed digest equals '
713+
'SHA256 of exactly the expected roll string, then derives the mnemonic from the post-mix '
714+
'internal entropy and compares -- which is what proves the rolls actually reached the seed '
715+
'rather than being collected and discarded.',
716+
['Dice entry screen', 'Digest confirmation']),
717+
('K2', 'test_msg_resetdevice', 'test_reset_reentry_disarms_entropy_ack',
718+
'Aborted reset disarms EntropyAck',
719+
'Regression for a host-chosen-seed hole: reset_init aborts left awaiting_entropy set from '
720+
'an earlier run while zeroing int_entropy, so a following EntropyAck derived the seed '
721+
'from host bytes alone. Arms a reset, re-enters with dice, cancels, and asserts the '
722+
'next EntropyAck is refused with "Not in Reset mode" and the device stays uninitialized.',
723+
[]),
724+
('K3', 'Dice', 'RollsForStrength',
725+
'Roll count per seed strength',
726+
'd6 carries log2(6)=2.585 bits, so 128/192/256-bit seeds need 50/75/99 rolls '
727+
'(the Coldcard convention). A short count would silently weaken the seed.',
728+
[]),
729+
('K4', 'Dice', 'MixZeroEntropyVector',
730+
'Mix known-answer vector (zero entropy)',
731+
'SHA256(0x00*32 || "123456") against a hardcoded digest. Pins the mix construction so a '
732+
'refactor cannot quietly change how dice enter the seed.',
733+
[]),
734+
('K5', 'Dice', 'MixNonZeroEntropyVector',
735+
'Mix known-answer vector (non-zero entropy)',
736+
'Same construction with a non-zero starting entropy buffer, pinned to a hardcoded digest.',
737+
[]),
738+
('K6', 'Dice', 'MixDependsOnRolls',
739+
'Different rolls produce different entropy',
740+
'Two mixes differing only in the final roll must diverge. Catches a mix that ignores its '
741+
'roll argument -- the failure mode where dice appear to work and contribute nothing.',
742+
[]),
743+
('K7', 'Dice', 'MixUsesExactCount',
744+
'Only the counted rolls contribute',
745+
'Bytes past the declared roll count must not affect the result, so uninitialized tail '
746+
'bytes of the roll buffer can never leak into seed material.',
747+
[]),
748+
('K8', 'Storage', 'PinKdfV16RewrapsToV19AfterCorrectPin',
749+
'v16 storage unlocks and rewraps to v19',
750+
'The migration path for the hardened PIN KDF: an existing device on the old format must '
751+
'still unlock with its current PIN and then be rewrapped. If this regressed, every '
752+
'upgrading device would be locked out of its own seed.',
753+
[]),
754+
('K9', 'Storage', 'PinKdfV2FlagIsVersionedInV19',
755+
'KDF version flag is recorded in v19',
756+
'The new KDF is marked in the storage version band, so firmware can tell which derivation '
757+
'a blob was written with instead of guessing.',
758+
[]),
759+
('K10', 'Storage', 'StorageUpgrade_Normal',
760+
'Normal storage upgrade path',
761+
'Baseline upgrade across storage versions with policies and cache preserved.',
762+
[]),
763+
('K11', 'Storage', 'NoopSecMigrate',
764+
'Idempotent security migration',
765+
'Re-running the migration on already-migrated storage must be a no-op rather than a '
766+
'second rewrap.',
767+
[]),
768+
]),
769+
675770
('B', 'Bitcoin', '7.0.0',
676771
'Bitcoin is the primary chain and most extensively tested. Covers legacy P2PKH, P2SH-wrapped '
677772
'SegWit, native SegWit (bech32), and Taproot (P2TR). Transaction signing validates that the '
@@ -2044,6 +2139,18 @@ def _section_state(s):
20442139
if build_label:
20452140
for line in _w(f'Candidate: {build_label}', 95):
20462141
pb.text(8, line, bold=True)
2142+
# Scope of this document. The catalog is a curated subset, and saying so is
2143+
# the difference between evidence and a misleading completeness claim: an RC
2144+
# audit grepped this PDF for feature keywords, found none, and reported four
2145+
# features as untested when their tests had run green in the same CI run.
2146+
ran = JUNIT_CENSUS['ran']
2147+
if ran:
2148+
pb.gap(3)
2149+
for line in _w('Scope: this report is a curated catalog of %d tests. The CI run executed %d '
2150+
'(%d of them native firmware unit tests). Absence from this report is NOT '
2151+
'evidence that a feature is untested -- check the JUnit artifacts.'
2152+
% (total, ran, JUNIT_CENSUS['native']), 100):
2153+
pb.text(8, line, color=GRAY)
20472154
pb.gap(6)
20482155
pb.text(12, 'Sections', bold=True)
20492156
_hdr_withheld = _hdr_pending = False

0 commit comments

Comments
 (0)