Skip to content

Commit 3e68c86

Browse files
Merge pull request #212 from keepkey/release/7.15-pyk-reconcile
test(7.15): reconcile the branch with what rc29 actually pins, plus the chain_id regression
2 parents a8e4126 + 2cf5edc commit 3e68c86

4 files changed

Lines changed: 575 additions & 10 deletions

File tree

scripts/generate-test-report.py

Lines changed: 171 additions & 2 deletions
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, 'skipped': 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,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

tests/test_msg_ethereum_signtx.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,65 @@ def test_ethereum_eip_1559(self):
405405
"67297089e0ba53c29dda1aafc23fce64a772c5433e127e5885edc03ece4670c9",
406406
)
407407

408+
def test_ethereum_eip_1559_multibyte_chain_id(self):
409+
"""EIP-1559 must hash the WHOLE chain_id, not just its low byte.
410+
411+
Regression for the multi-byte chain_id bug (firmware ed6db167). The
412+
EIP-1559 hash step used hash_rlp_field((uint8_t*)&chain_id, 1), which on
413+
little-endian ARM fed only the least-significant byte into keccak. For
414+
Base (8453 = 0x2105) that hashed 0x05, so the signature recovered to an
415+
unrelated address with no funds. The RLP *length* was computed correctly
416+
from the full value and the legacy EIP-155 path was always correct —
417+
only the EIP-1559 hash was wrong. Affected: Base (8453), Arbitrum
418+
(42161), Avalanche (43114). Unaffected: ETH (1), OP (10), BSC (56),
419+
Polygon (137) — all single-byte.
420+
421+
Every other EIP-1559 case in this file uses chain_id 1 or 3, so the bug
422+
had no coverage in the file that tests the feature.
423+
424+
A golden r/s would need a device run to produce, so this is a
425+
differential. Sign one identical transaction under two chain ids the
426+
BUGGY firmware cannot tell apart:
427+
428+
8453 = 0x2105 low byte 0x05, two-byte value
429+
4357 = 0x1105 low byte 0x05, two-byte value
430+
431+
Same low byte AND same RLP length header, so the broken code hashes a
432+
byte-identical pre-image for both. Signing is deterministic (RFC 6979),
433+
so buggy firmware returns the SAME signature twice and this fails.
434+
Correct firmware hashes 0x21 0x05 vs 0x11 0x05, which must differ.
435+
436+
Note a comparison against chain_id=5 would NOT work: the RLP length was
437+
always derived from the full value, so the buggy pre-image for 8453 is
438+
malformed rather than equal to a well-formed single-byte encoding. The
439+
twin must match on both low byte and byte-width.
440+
"""
441+
self.requires_fullFeature()
442+
self.requires_firmware("7.15.0")
443+
self.setup_mnemonic_nopin_nopassphrase()
444+
445+
def sign(chain_id):
446+
return self.client.ethereum_sign_tx(
447+
n=[0x80000000 | 44, 0x80000000 | 60, 0x80000000, 0, 0],
448+
nonce=0,
449+
gas_limit=0x5ac3,
450+
max_fee_per_gas=0x16854be509,
451+
max_priority_fee_per_gas=0x540ae480,
452+
to=binascii.unhexlify("fc0cc6e85dff3d75e3985e0cb83b090cfd498dd1"),
453+
value=0x1550f7dca70000,
454+
chain_id=chain_id,
455+
)
456+
457+
_, base_r, base_s = sign(8453)
458+
_, twin_r, twin_s = sign(4357)
459+
460+
self.assertNotEqual(
461+
(binascii.hexlify(base_r), binascii.hexlify(base_s)),
462+
(binascii.hexlify(twin_r), binascii.hexlify(twin_s)),
463+
"chain_id 8453 and 4357 produced the same signature — only the low "
464+
"byte of chain_id reached the EIP-1559 hash",
465+
)
466+
408467
def test_ethereum_signtx_nodata_eip_1559(self):
409468
self.requires_fullFeature()
410469
self.requires_firmware("7.2.1")

tests/test_msg_resetdevice.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,21 @@ def test_reset_device_pin(self):
245245
language='english',
246246
label='test'))
247247

248-
self.assertIsInstance(ret, proto.ButtonRequest)
249-
self.client.debug.press_yes()
250-
ret = self.client.call_raw(proto.ButtonAck())
251-
248+
# display_random=True above is deliberate: the field stays in the wire
249+
# schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no
250+
# entropy display") stopped honouring it -- internal entropy is seed
251+
# pre-image material, and a host that sets the flag and reads that
252+
# screen once can compute SHA256(shown || ext) and derive the seed.
253+
#
254+
# Branch on the version rather than skipping the test: everything below
255+
# (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version-
256+
# independent and must keep running on older firmware.
257+
f = self.client.features
258+
if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0):
259+
# Pre-7.15: the Internal Entropy screen legitimately still exists.
260+
self.assertIsInstance(ret, proto.ButtonRequest)
261+
self.client.debug.press_yes()
262+
ret = self.client.call_raw(proto.ButtonAck())
252263
self.assertIsInstance(ret, proto.PinMatrixRequest)
253264

254265
# Enter PIN for first time
@@ -318,10 +329,21 @@ def test_failed_pin(self):
318329
language='english',
319330
label='test'))
320331

321-
self.assertIsInstance(ret, proto.ButtonRequest)
322-
self.client.debug.press_yes()
323-
ret = self.client.call_raw(proto.ButtonAck())
324-
332+
# display_random=True above is deliberate: the field stays in the wire
333+
# schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no
334+
# entropy display") stopped honouring it -- internal entropy is seed
335+
# pre-image material, and a host that sets the flag and reads that
336+
# screen once can compute SHA256(shown || ext) and derive the seed.
337+
#
338+
# Branch on the version rather than skipping the test: everything below
339+
# (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version-
340+
# independent and must keep running on older firmware.
341+
f = self.client.features
342+
if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0):
343+
# Pre-7.15: the Internal Entropy screen legitimately still exists.
344+
self.assertIsInstance(ret, proto.ButtonRequest)
345+
self.client.debug.press_yes()
346+
ret = self.client.call_raw(proto.ButtonAck())
325347
self.assertIsInstance(ret, proto.PinMatrixRequest)
326348

327349
# Enter PIN for first time

0 commit comments

Comments
 (0)