Skip to content

Commit 314cfe7

Browse files
committed
test: make presign OLED evidence semantic
1 parent 5ef90f5 commit 314cfe7

8 files changed

Lines changed: 214 additions & 57 deletions

scripts/generate-test-report.py

Lines changed: 90 additions & 34 deletions
Large diffs are not rendered by default.

tests/common.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@
3535
tx_api.configure_offline_fixtures(TX_FIXTURE_DIR)
3636
VERBOSE = False
3737

38+
39+
def reset_screenshot_capture(client):
40+
"""Start a fresh evidence sequence after fixture-only device setup.
41+
42+
KeepKeyTest.setUp() wipes the device and the setup_mnemonic_* helpers load
43+
a public test seed. Those confirmations are prerequisites, not evidence
44+
for the test that follows. Leaving them in the per-test directory allowed
45+
a Wipe/import/lock frame to satisfy an unrelated OLED requirement.
46+
"""
47+
if os.environ.get('KEEPKEY_SCREENSHOT') != '1':
48+
return
49+
screenshot_dir = getattr(client, 'screenshot_dir', None)
50+
if not screenshot_dir:
51+
raise RuntimeError('screenshot capture has no per-test directory')
52+
os.makedirs(screenshot_dir, exist_ok=True)
53+
for name in os.listdir(screenshot_dir):
54+
if (name.startswith('btn') and name.endswith('.png') and
55+
len(name) == len('btn00000.png')) or name == 'frames.json':
56+
os.unlink(os.path.join(screenshot_dir, name))
57+
client.screenshot_id = 0
58+
3859
class KeepKeyTest(unittest.TestCase):
3960
def setUp(self):
4061
transport = config.TRANSPORT(*config.TRANSPORT_ARGS, **config.TRANSPORT_KWARGS)
@@ -77,28 +98,35 @@ def setUp(self):
7798
self.pin8 = '45678978'
7899

79100
self.client.wipe_device()
101+
reset_screenshot_capture(self.client)
80102

81103
if VERBOSE:
82104
print("Setup finished")
83105
print("--------------")
84106

85107
def setup_mnemonic_allallall(self):
86108
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic_all, pin='', passphrase_protection=False, label='test', language='english')
109+
reset_screenshot_capture(self.client)
87110

88111
def setup_mnemonic_abandon(self):
89112
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic_abandon, pin='', passphrase_protection=False, label='test', language='english')
113+
reset_screenshot_capture(self.client)
90114

91115
def setup_mnemonic_nopin_nopassphrase(self):
92116
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic12, pin='', passphrase_protection=False, label='test', language='english')
117+
reset_screenshot_capture(self.client)
93118

94119
def setup_mnemonic_vuln20007(self):
95120
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic20007, pin='', passphrase_protection=False, label='test', language='english')
121+
reset_screenshot_capture(self.client)
96122

97123
def setup_mnemonic_pin_nopassphrase(self):
98124
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic12, pin=self.pin4, passphrase_protection=False, label='test', language='english')
125+
reset_screenshot_capture(self.client)
99126

100127
def setup_mnemonic_pin_passphrase(self):
101128
self.client.load_device_by_mnemonic(mnemonic=self.mnemonic12, pin=self.pin4, passphrase_protection=True, label='test', language='english')
129+
reset_screenshot_capture(self.client)
102130

103131
def tearDown(self):
104132
self.client.close()

tests/conftest.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
conftest.py -- pytest plugin for per-test OLED screenshot directories.
33
44
When KEEPKEY_SCREENSHOT=1, patches KeepKeyTest.setUp to set per-test
5-
screenshot directories BEFORE setUp runs (so wipe_device captures go
6-
to the right place).
5+
screenshot directories. Fixture-only wipe/import captures are cleared before
6+
the test action begins, so they cannot masquerade as the test's OLED evidence.
77
88
FAIL-FAST: If KEEPKEY_SCREENSHOT=1 and zero PNGs are captured after
99
all tests complete, the session exits non-zero. This prevents silent
@@ -19,14 +19,45 @@
1919

2020
import requests
2121

22+
23+
def pytest_collection_modifyitems(config, items):
24+
"""Select exact report test IDs for the screenshot-only pytest phase."""
25+
if os.environ.get('KEEPKEY_SCREENSHOT') != '1':
26+
return
27+
encoded = os.environ.get('KEEPKEY_SCREENSHOT_TESTS', '')
28+
if not encoded:
29+
raise pytest.UsageError(
30+
'KEEPKEY_SCREENSHOT_TESTS must list exact module::method pairs')
31+
selected_pairs = set()
32+
for line in encoded.splitlines():
33+
if not line:
34+
continue
35+
parts = line.split('::')
36+
if len(parts) != 2 or not all(parts):
37+
raise pytest.UsageError(
38+
'invalid KEEPKEY_SCREENSHOT_TESTS entry %r' % line)
39+
selected_pairs.add(tuple(parts))
40+
selected = []
41+
deselected = []
42+
for item in items:
43+
module = os.path.splitext(os.path.basename(item.location[0]))[0]
44+
method = getattr(item, 'originalname', None) or item.name.split('[', 1)[0]
45+
if (module, method) in selected_pairs:
46+
selected.append(item)
47+
else:
48+
deselected.append(item)
49+
if deselected:
50+
config.hook.pytest_deselected(items=deselected)
51+
items[:] = selected
52+
2253
if os.environ.get('KEEPKEY_SCREENSHOT') == '1':
2354
import common
2455

2556
_orig_setUp = common.KeepKeyTest.setUp
2657

2758
def _patched_setUp(self):
28-
# Derive per-test screenshot directory BEFORE setUp runs,
29-
# so captures during wipe_device/load_device go to the right place.
59+
# Derive the per-test directory before setUp. common.KeepKeyTest.setUp
60+
# clears fixture-only frames after its initial wipe completes.
3061
test_id = self.id()
3162
# pytest: "tests.test_msg_wipedevice.TestDeviceWipe.test_wipe_device"
3263
# unittest: "test_msg_wipedevice.TestDeviceWipe.test_wipe_device"

tests/test_msg_ethereum_getaddress.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,13 @@ def test_ethereum_getaddress(self):
3434
self.assertEqual(binascii.hexlify(self.client.ethereum_get_address([-9, 0])), 'f68804ac9eca9483ab4241d3e4751590d2c05102')
3535
self.assertEqual(binascii.hexlify(self.client.ethereum_get_address([0, 9999999])), '7a6366ecfcaf0d5dcc1539c171696c6cdd1eb8ed')
3636

37+
def test_ethereum_show_address(self):
38+
self.requires_fullFeature()
39+
self.setup_mnemonic_nopin_nopassphrase()
40+
# The legacy display response is empty after the ButtonAck; address
41+
# correctness is covered above. This case exists to retain the actual
42+
# operation-specific OLED sequence for visual review.
43+
self.client.ethereum_get_address([], show_display=True)
44+
3745
if __name__ == '__main__':
3846
unittest.main()

tests/test_msg_ethereum_signtx.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,12 @@ def test_ethereum_signtx_data(self):
9999
self.client.apply_policy("AdvancedMode", 0)
100100

101101
def test_ethereum_blind_sign_blocked(self):
102-
"""AdvancedMode OFF + contract data = device refuses to sign (7.15+).
102+
"""AdvancedMode OFF + contract data = device refuses to sign (7.14.2+).
103103
104-
OLED shows 'Blind signing disabled' then Failure.
104+
OLED explains that arbitrary contract data requires AdvancedMode, then
105+
the device returns the policy failure without requesting approval.
105106
"""
106-
self.requires_firmware("7.15.0")
107+
self.requires_firmware("7.14.2")
107108
self.requires_fullFeature()
108109
self.setup_mnemonic_nopin_nopassphrase()
109110
self.client.apply_policy("AdvancedMode", 0)
@@ -121,10 +122,11 @@ def test_ethereum_blind_sign_blocked(self):
121122
)
122123
self.fail("Expected Failure -- blind signing should be blocked")
123124
except CallException as e:
124-
self.assertIn("Blind signing disabled", str(e))
125+
self.assertIn("Arbitrary contract data signing disabled by policy",
126+
str(e))
125127

126128
def test_ethereum_blind_sign_allowed(self):
127-
"""AdvancedMode ON + contract data = device shows BLIND SIGNATURE warning (7.15+).
129+
"""AdvancedMode ON permits opaque contract-data signing (7.14.2+).
128130
129131
OLED shows 'BLIND SIGNATURE' before signing.
130132
"""

tests/test_msg_recoverydevice_cipher.py

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

5050
# Reminder UI
5151
assert isinstance(ret, proto.ButtonRequest)
52+
self.client._capture_oled()
5253
self.client.debug.press_yes()
5354
ret = self.client.call_raw(proto.ButtonAck())
5455

@@ -57,6 +58,7 @@ def test_pin_passphrase(self):
5758
for index, word in enumerate(mnemonic_words):
5859
for character in word:
5960
self.assertIsInstance(ret, proto.CharacterRequest)
61+
self.client._capture_oled()
6062
cipher = self.client.debug.read_recovery_cipher()
6163

6264
encoded_character = cipher[ord(character) - 97]
@@ -107,6 +109,7 @@ def test_nopin_nopassphrase(self):
107109

108110
# Reminder UI
109111
assert isinstance(ret, proto.ButtonRequest)
112+
self.client._capture_oled()
110113
self.client.debug.press_yes()
111114
ret = self.client.call_raw(proto.ButtonAck())
112115

@@ -115,6 +118,7 @@ def test_nopin_nopassphrase(self):
115118
for index, word in enumerate(mnemonic_words):
116119
for character in word:
117120
self.assertIsInstance(ret, proto.CharacterRequest)
121+
self.client._capture_oled()
118122
cipher = self.client.debug.read_recovery_cipher()
119123

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

tests/test_msg_resetdevice.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,8 @@ def generate_entropy(strength, internal_entropy, external_entropy):
5454
return entropy_stripped
5555

5656
class TestDeviceReset(common.KeepKeyTest):
57-
def test_reset_device(self):
58-
# No PIN, no passphrase
57+
def _reset_without_pin_and_capture(self, strength):
5958
external_entropy = b'zlutoucky kun upel divoke ody' * 2
60-
strength = 128
61-
6259
ret = self.client.call_raw(proto.ResetDevice(display_random=False,
6360
strength=strength,
6461
passphrase_protection=False,
@@ -77,27 +74,32 @@ def test_reset_device(self):
7774

7875
# Explainer Dialog
7976
self.assertIsInstance(resp, proto.ButtonRequest)
77+
self.client._capture_oled()
8078
self.client.debug.press_yes()
8179
resp = self.client.call_raw(proto.ButtonAck())
8280

8381
mnemonic = []
8482
while isinstance(resp, proto.ButtonRequest):
85-
mnemonic.append(self.client.debug.read_reset_word())
83+
self.client._capture_oled()
84+
words = self.client.debug.read_reset_word()
85+
# 7.14.2's debug build exposes each physical subpage as a separate
86+
# ButtonRequest for evidence capture. All subpages in one legacy
87+
# word group intentionally report the same reset_word value.
88+
if not mnemonic or mnemonic[-1] != words:
89+
mnemonic.append(words)
8690
self.client.debug.press_yes()
8791
resp = self.client.call_raw(proto.ButtonAck())
8892

8993
mnemonic = ' '.join(mnemonic)
9094

91-
# Compare that device generated proper mnemonic for given entropies
9295
self.assertEqual(mnemonic, expected_mnemonic)
93-
9496
self.assertIsInstance(resp, proto.Success)
97+
self.assertEqual(strength // 32 * 3, len(mnemonic.split()))
98+
return self.client.call_raw(proto.Initialize())
9599

96-
# Compare that second pass printed out the same mnemonic once again
97-
self.assertEqual(mnemonic, expected_mnemonic)
98-
99-
# Check if device is properly initialized
100-
resp = self.client.call_raw(proto.Initialize())
100+
def test_reset_device(self):
101+
# 128-bit entropy produces the 12-word ceremony.
102+
resp = self._reset_without_pin_and_capture(128)
101103
self.assertFalse(resp.pin_protection)
102104
self.assertFalse(resp.passphrase_protection)
103105

@@ -109,6 +111,16 @@ def test_reset_device(self):
109111
resp = self.client.call_raw(proto.Ping(pin_protection=True))
110112
self.assertIsInstance(resp, proto.Success)
111113

114+
def test_reset_device_18_words(self):
115+
resp = self._reset_without_pin_and_capture(192)
116+
self.assertFalse(resp.pin_protection)
117+
self.assertFalse(resp.passphrase_protection)
118+
119+
def test_reset_device_24_words(self):
120+
resp = self._reset_without_pin_and_capture(256)
121+
self.assertFalse(resp.pin_protection)
122+
self.assertFalse(resp.passphrase_protection)
123+
112124
def test_reset_device_pin(self):
113125
external_entropy = b'zlutoucky kun upel divoke ody' * 2
114126
strength = 128
@@ -131,11 +143,13 @@ def test_reset_device_pin(self):
131143
ret = self.client.call_raw(proto.ButtonAck())
132144

133145
self.assertIsInstance(ret, proto.PinMatrixRequest)
146+
self.client._capture_oled()
134147

135148
# Enter PIN for first time
136149
pin_encoded = self.client.debug.encode_pin('654')
137150
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
138151
self.assertIsInstance(ret, proto.PinMatrixRequest)
152+
self.client._capture_oled()
139153

140154
# Enter PIN for second time
141155
pin_encoded = self.client.debug.encode_pin('654')
@@ -152,12 +166,16 @@ def test_reset_device_pin(self):
152166

153167
# Explainer Dialog
154168
self.assertIsInstance(resp, proto.ButtonRequest)
169+
self.client._capture_oled()
155170
self.client.debug.press_yes()
156171
resp = self.client.call_raw(proto.ButtonAck())
157172

158173
mnemonic = []
159174
while isinstance(resp, proto.ButtonRequest):
160-
mnemonic.append(self.client.debug.read_reset_word())
175+
self.client._capture_oled()
176+
words = self.client.debug.read_reset_word()
177+
if not mnemonic or mnemonic[-1] != words:
178+
mnemonic.append(words)
161179
self.client.debug.press_yes()
162180
resp = self.client.call_raw(proto.ButtonAck())
163181

@@ -206,11 +224,13 @@ def test_failed_pin(self):
206224
ret = self.client.call_raw(proto.ButtonAck())
207225

208226
self.assertIsInstance(ret, proto.PinMatrixRequest)
227+
self.client._capture_oled()
209228

210229
# Enter PIN for first time
211230
pin_encoded = self.client.debug.encode_pin(self.pin4)
212231
ret = self.client.call_raw(proto.PinMatrixAck(pin=pin_encoded))
213232
self.assertIsInstance(ret, proto.PinMatrixRequest)
233+
self.client._capture_oled()
214234

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

tests/test_msg_ripple_get_address.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ def test_ripple_get_address(self):
3535
address = self.client.ripple_get_address(parse_path("m/44'/144'/1'/0/0"))
3636
self.assertEqual(address, "rJX2KwzaLJDyFhhtXKi3htaLfaUH2tptEX")
3737

38+
def test_ripple_show_address(self):
39+
self.requires_fullFeature()
40+
self.requires_firmware("6.4.0")
41+
self.setup_mnemonic_allallall()
42+
# The legacy display response is empty after the ButtonAck; address
43+
# correctness is covered above. This case retains the actual OLED.
44+
self.client.ripple_get_address(
45+
parse_path("m/44'/144'/0'/0/0"), show_display=True)
46+
3847
def test_ripple_get_address_other(self):
3948
self.requires_fullFeature()
4049
self.requires_firmware("6.4.0")
@@ -54,4 +63,3 @@ def test_ripple_get_address_other(self):
5463

5564
if __name__ == '__main__':
5665
unittest.main()
57-

0 commit comments

Comments
 (0)