Skip to content

Commit 766070b

Browse files
committed
test: make OLED ceremony evidence semantic
1 parent 3a3e0b3 commit 766070b

5 files changed

Lines changed: 82 additions & 28 deletions

File tree

keepkeylib/client.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,13 @@ def set_mnemonic(self, mnemonic):
432432

433433
def call_raw(self, msg):
434434

435-
# Screenshot capture disabled in call_raw (captures idle screens, adds latency).
436-
# Real confirmation screenshots are captured in callback_ButtonRequest instead.
437-
# Exception: capture on Failure (rejection screens like invalid BIP-39 word).
435+
# Screenshot capture is disabled in call_raw: a wire Failure is often
436+
# emitted after the UI has already returned to the lock/home screen.
437+
# Treating that framebuffer as operation evidence created convincing
438+
# but unrelated blank/lock frames. Tests that claim a visible rejection
439+
# capture it explicitly at the point the firmware renders it.
438440

439441
resp = super(DebugLinkMixin, self).call_raw(msg)
440-
if isinstance(resp, proto.Failure):
441-
self._capture_oled()
442442
self._check_request(resp)
443443
return resp
444444

@@ -501,6 +501,35 @@ def _capture_oled(self, layout=None):
501501
traceback.print_exc(file=sys.stderr)
502502
raise
503503

504+
def _capture_oled_after_animation(self, seconds, required_region=None):
505+
"""Capture the completed PIN/cipher frame, not its initial blank state."""
506+
if not SCREENSHOT:
507+
return
508+
# The emulator's PIN/recovery loop blocks while waiting for host
509+
# input. Wall-clock sleep alone therefore does not repaint: each
510+
# DebugLink request wakes the loop for one 20 ms animation tick.
511+
# Drive every required tick and retain the layout from the final poll.
512+
layout = None
513+
for _tick in range(int(seconds / 0.020) + 2):
514+
time.sleep(0.025)
515+
layout = self.debug.read_layout()
516+
if required_region:
517+
x0, x1, y0, y1 = required_region
518+
lit = 0
519+
for y in range(y0, y1):
520+
for x in range(x0, x1):
521+
byte_index = x + (y // 8) * 256
522+
value = layout[byte_index]
523+
if not isinstance(value, int):
524+
value = ord(value)
525+
lit += (value >> (y % 8)) & 1
526+
area = (x1 - x0) * (y1 - y0)
527+
if lit < 32 or area - lit < 32:
528+
raise RuntimeError(
529+
"animated OLED evidence lacks grid contrast "
530+
"(%d lit of %d pixels)" % (lit, area))
531+
self._capture_oled(layout)
532+
504533
def callback_ButtonRequest(self, msg):
505534
if self.verbose:
506535
log("ButtonRequest code: " + get_buttonrequest_value(msg.code))
@@ -520,6 +549,9 @@ def callback_ButtonRequest(self, msg):
520549
return proto.ButtonAck()
521550

522551
def callback_PinMatrixRequest(self, msg):
552+
# Firmware animates the randomized grid for PIN_MAX_ANIMATION_MS
553+
# (1000 ms). Sampling immediately captures only the prompt/blank mask.
554+
self._capture_oled_after_animation(1.05, (192, 256, 0, 64))
523555
if self.pin_correct:
524556
pin = self.debug.read_pin_encoded()
525557
else:

scripts/generate-test-report.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ def finish(self):
180180
self._flush()
181181

182182
def _lookup(results, mod, meth):
183-
"""Look up test result by module::method (precise), then bare method (fallback)."""
184-
return results.get(f'{mod}::{meth}') or results.get(meth) or ''
183+
"""Look up only the exact catalog module::method identity."""
184+
return results.get(f'{mod}::{meth}') or ''
185185

186186
def ver_t(s): return tuple(int(x) for x in s.replace('v','').split('.')[:3])
187187
def ver_ge(a, b): return ver_t(a) >= ver_t(b)
@@ -205,8 +205,7 @@ def detect_fw():
205205
except: return None
206206

207207
def parse_junit(path):
208-
"""Parse junit XML for pass/fail. Returns dict keyed by 'module::method' (precise)
209-
and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo."""
208+
"""Parse JUnit status under exact module::method and native-suite keys."""
210209
if not path or not os.path.exists(path): return {}
211210
import xml.etree.ElementTree as ET
212211
results = {}
@@ -217,12 +216,15 @@ def parse_junit(path):
217216
elif tc.find('error') is not None: status = 'error'
218217
elif tc.find('skipped') is not None: status = 'skip'
219218
else: status = 'pass'
220-
# Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo
219+
# Extract the test module from a pytest classname such as
220+
# tests.test_protection_levels.TestProtectionLevels. Exact lookup is
221+
# safe across every test_* module and avoids collision-prone method
222+
# fallback.
221223
mod = ''
222224
if cls:
223225
parts = cls.split('.')
224226
for p in parts:
225-
if p.startswith('test_msg_') or p.startswith('test_sign_') or p.startswith('test_verify_'):
227+
if p.startswith('test_'):
226228
mod = p
227229
break
228230
results[f'{cls}.{name}'] = status
@@ -233,9 +235,6 @@ def parse_junit(path):
233235
# Key by module::method (disambiguates collisions like test_sign_btc_eth_swap)
234236
if mod:
235237
results[f'{mod}::{name}'] = status
236-
# Bare method fallback -- only set if no collision
237-
if name not in results or status == 'pass':
238-
results[name] = status
239238
return results
240239

241240

@@ -379,6 +378,10 @@ def junit_reconciliation(path):
379378
('test_msg_resetdevice', 'test_reset_device_pin'),
380379
('test_msg_resetdevice', 'test_reset_device_18_words'),
381380
('test_msg_resetdevice', 'test_reset_device_24_words'),
381+
('test_msg_recoverydevice_cipher', 'test_nopin_nopassphrase'),
382+
('test_msg_recoverydevice_cipher', 'test_pin_passphrase'),
383+
('test_msg_changepin', 'test_set_pin'),
384+
('test_msg_ethereum_signtx', 'test_ethereum_blind_sign_allowed'),
382385
('test_msg_ethereum_signtx', 'test_ethereum_blind_sign_blocked'),
383386
('BodyFits',
384387
'ConstantPowerSeedRowsAreCompleteAndPagedAtRowBoundaries'),
@@ -607,6 +610,8 @@ def junit_reconciliation(path):
607610
'Generate 12-word seed',
608611
'Device generates 128 bits of entropy from its RNG, combines it with host entropy, '
609612
'and displays the resulting 12-word BIP-39 mnemonic on every physical OLED page. '
613+
'Each 256x64 image is one physical frame; its opposing-polarity halves are the '
614+
'intentional constant-power layout, not a stitched composite. '
610615
'Words are NEVER sent over the normal host transport. '
611616
'User writes them down as their backup.',
612617
['Seed word display']),
@@ -623,7 +628,8 @@ def junit_reconciliation(path):
623628
('C3', 'test_msg_resetdevice', 'test_reset_device_pin',
624629
'Generate seed with PIN',
625630
'Same as C2 but also sets a PIN. PIN is entered twice for confirmation via the '
626-
'randomized 3x3 grid on OLED. Verifies PIN is stored and required for subsequent operations.',
631+
'fully animated randomized 3x3 grid on OLED. Verifies PIN is stored and required for '
632+
'subsequent operations.',
627633
['PIN entry grid']),
628634
('C4', 'test_msg_resetdevice', 'test_failed_pin',
629635
'PIN mismatch rejects setup',
@@ -655,14 +661,16 @@ def junit_reconciliation(path):
655661
[]),
656662
('C10', 'test_msg_recoverydevice_cipher', 'test_nopin_nopassphrase',
657663
'Cipher recovery (no PIN)',
658-
'Recovery via scrambled keyboard on OLED. The letter grid is randomized per-character, '
664+
'Recovery via the fully animated scrambled keyboard on OLED. The letter grid is '
665+
'randomized per-character, '
659666
'so even a compromised host cannot determine which letters the user selected. After all '
660-
'words are entered, device verifies BIP-39 checksum and reconstructs the seed.',
667+
'words are entered, the terminal Success proves checksum validation and seed '
668+
'reconstruction; cipher entry itself may end on an auto-completed word prefix.',
661669
['Cipher grid on OLED']),
662670
('C11', 'test_msg_recoverydevice_cipher', 'test_pin_passphrase',
663671
'Cipher recovery with PIN + passphrase',
664-
'Same recovery flow as C10 but also sets PIN and enables passphrase protection during '
665-
'the recovery process.',
672+
'Same recovery flow as C10 but also captures the completed PIN grids and enables '
673+
'passphrase protection as a stored setting; no passphrase value is entered during recovery.',
666674
['Cipher + PIN entry']),
667675
('C12', 'test_msg_recoverydevice_cipher', 'test_character_fail',
668676
'Invalid character rejection',
@@ -1117,7 +1125,7 @@ def junit_reconciliation(path):
11171125
'Blind sign permitted (AdvancedMode ON)',
11181126
'Contract data with AdvancedMode enabled. Device allows signing only after opaque-policy '
11191127
'and complete-calldata hash disclosure.',
1120-
[]),
1128+
['Opaque policy warning', 'Complete calldata hash approval']),
11211129
('V9', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_blocked',
11221130
'Opaque contract data blocked (AdvancedMode OFF)',
11231131
'On 7.14.2 the same arbitrary calldata is refused by policy before any signing approval. '
@@ -1513,6 +1521,7 @@ def render(output_path, fw_version, results, screenshot_dir=None,
15131521
pb.text(7, f'Generator blob SHA: {provenance.get("generator_blob_sha", "MISSING")}')
15141522
pb.text(7, f'Generator SHA-256: {provenance.get("generator_sha256", "MISSING")}')
15151523
pb.text(7, f'ARM manifest SHA-256: {provenance.get("arm_manifest_sha256", "MISSING")}')
1524+
pb.text(7, f'Report controls SHA-256: {provenance.get("report_controls_sha256", "MISSING")}')
15161525
pb.text(7, f'Build: {provenance.get("build_label", "MISSING")}')
15171526
for line in _w(f'CI run: {provenance.get("run_url", "MISSING")}', 88):
15181527
pb.text(7, line)
@@ -1950,6 +1959,8 @@ def main():
19501959
p.add_argument('--run-url', default=None, help='immutable CI run URL')
19511960
p.add_argument('--arm-manifest-sha256', default=None,
19521961
help='SHA-256 of the exact-head ARM binary manifest')
1962+
p.add_argument('--report-controls-sha256', default=None,
1963+
help='SHA-256 of the passing report mutation controls')
19531964
p.add_argument('--completeness-output', default=None,
19541965
help='write machine-readable catalog/frame completeness JSON')
19551966
p.add_argument('--required-tests-manifest', default=None,
@@ -2024,6 +2035,7 @@ def main():
20242035
'build_label': args.build_label,
20252036
'run_url': args.run_url,
20262037
'arm_manifest_sha256': args.arm_manifest_sha256,
2038+
'report_controls_sha256': args.report_controls_sha256,
20272039
}
20282040
missing_provenance = [k for k, value in required_provenance.items() if not value]
20292041
if missing_provenance:
@@ -2053,6 +2065,12 @@ def main():
20532065
print('ERROR: ARM manifest SHA-256 is not exact: %s' %
20542066
args.arm_manifest_sha256, file=sys.stderr)
20552067
sys.exit(2)
2068+
if (len(args.report_controls_sha256) != 64 or
2069+
any(c not in '0123456789abcdef'
2070+
for c in args.report_controls_sha256.lower())):
2071+
print('ERROR: report controls SHA-256 is not exact: %s' %
2072+
args.report_controls_sha256, file=sys.stderr)
2073+
sys.exit(2)
20562074
catalog_errors = catalog_integrity(fw, derived_sources)
20572075
if catalog_errors:
20582076
print('ERROR: release evidence catalog is inconsistent:', file=sys.stderr)

tests/test_msg_changepin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@ def test_set_pin(self):
4646

4747
# Send the PIN for first time
4848
self.assertIsInstance(ret, proto.PinMatrixRequest)
49+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
4950
pin_encoded = self.client.debug.encode_pin(self.pin6)
5051
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
5152

5253
# Send the PIN for second time
5354
self.assertIsInstance(ret, proto.PinMatrixRequest)
55+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
5456
pin_encoded = self.client.debug.encode_pin(self.pin6)
5557
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
5658

tests/test_msg_recoverydevice_cipher.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_pin_passphrase(self):
4949

5050
# Reminder UI
5151
assert isinstance(ret, proto.ButtonRequest)
52-
self.client._capture_oled()
52+
self.client._capture_oled_after_animation(0.35, (76, 256, 0, 64))
5353
self.client.debug.press_yes()
5454
ret = self.client.call_raw(proto.ButtonAck())
5555

@@ -58,7 +58,8 @@ def test_pin_passphrase(self):
5858
for index, word in enumerate(mnemonic_words):
5959
for character in word:
6060
self.assertIsInstance(ret, proto.CharacterRequest)
61-
self.client._capture_oled()
61+
self.client._capture_oled_after_animation(
62+
0.35, (76, 256, 0, 64))
6263
cipher = self.client.debug.read_recovery_cipher()
6364

6465
encoded_character = cipher[ord(character) - 97]
@@ -109,7 +110,7 @@ def test_nopin_nopassphrase(self):
109110

110111
# Reminder UI
111112
assert isinstance(ret, proto.ButtonRequest)
112-
self.client._capture_oled()
113+
self.client._capture_oled_after_animation(0.35, (76, 256, 0, 64))
113114
self.client.debug.press_yes()
114115
ret = self.client.call_raw(proto.ButtonAck())
115116

@@ -118,7 +119,8 @@ def test_nopin_nopassphrase(self):
118119
for index, word in enumerate(mnemonic_words):
119120
for character in word:
120121
self.assertIsInstance(ret, proto.CharacterRequest)
121-
self.client._capture_oled()
122+
self.client._capture_oled_after_animation(
123+
0.35, (76, 256, 0, 64))
122124
cipher = self.client.debug.read_recovery_cipher()
123125

124126
encoded_character = cipher[ord(character) - 97]

tests/test_msg_resetdevice.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,13 @@ def test_reset_device_pin(self):
143143
ret = self.client.call_raw(proto.ButtonAck())
144144

145145
self.assertIsInstance(ret, proto.PinMatrixRequest)
146-
self.client._capture_oled()
146+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
147147

148148
# Enter PIN for first time
149149
pin_encoded = self.client.debug.encode_pin('654')
150150
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
151151
self.assertIsInstance(ret, proto.PinMatrixRequest)
152-
self.client._capture_oled()
152+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
153153

154154
# Enter PIN for second time
155155
pin_encoded = self.client.debug.encode_pin('654')
@@ -224,13 +224,13 @@ def test_failed_pin(self):
224224
ret = self.client.call_raw(proto.ButtonAck())
225225

226226
self.assertIsInstance(ret, proto.PinMatrixRequest)
227-
self.client._capture_oled()
227+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
228228

229229
# Enter PIN for first time
230230
pin_encoded = self.client.debug.encode_pin(self.pin4)
231231
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
232232
self.assertIsInstance(ret, proto.PinMatrixRequest)
233-
self.client._capture_oled()
233+
self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64))
234234

235235
# Enter PIN for second time
236236
pin_encoded = self.client.debug.encode_pin(self.pin6)

0 commit comments

Comments
 (0)