Skip to content

Commit a8e4126

Browse files
Merge pull request #210 from keepkey/feat/dice-entropy
feat(reset): drive and verify on-device dice-entropy collection
2 parents 7826459 + ee9369f commit a8e4126

7 files changed

Lines changed: 400 additions & 103 deletions

File tree

device-protocol

keepkeylib/debuglink.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ def read_reset_entropy(self):
8787
obj = self._call(proto.DebugLinkGetState())
8888
return obj.reset_entropy
8989

90+
def read_dice_digest(self):
91+
obj = self._call(proto.DebugLinkGetState())
92+
return obj.dice_digest
93+
9094
def read_passphrase_protection(self):
9195
obj = self._call(proto.DebugLinkGetState())
9296
return obj.passphrase_protection
@@ -127,6 +131,13 @@ def press_button(self, yes_no):
127131
def press_yes(self):
128132
self.press_button(True)
129133

134+
def press_input(self, text):
135+
"""Send synthetic keyboard input to an on-device entry flow
136+
(dice rolls: '1'-'6' and 'u' for undo). Keep each chunk within
137+
the firmware's DebugLinkDecision.input max_size (40 chars)."""
138+
self.log("Injecting input", text)
139+
self._call(proto.DebugLinkDecision(yes_no=False, input=text), nowait=True)
140+
130141
def press_no(self):
131142
self.press_button(False)
132143

keepkeylib/messages_pb2.py

Lines changed: 113 additions & 92 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

keepkeylib/types_pb2.py

Lines changed: 9 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/generate-test-report.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -779,17 +779,23 @@ def _arg_shown(a):
779779
'Sign a Taproot key-path spend',
780780
'Spends a BIP-86 P2TR input using BIP-341 SIGHASH_DEFAULT and a BIP-340 Schnorr '
781781
'signature. The 64-byte witness is compared byte-for-byte with an independently '
782-
'computed reference value.',
782+
'computed reference value. The complete 153-byte transaction is then parsed as '
783+
'BIP-144 and must consume every byte, proving the witness stack and the 4-byte '
784+
'locktime footer actually reached the host rather than only the signature field.',
783785
['P2TR recipient confirmation', 'Fee confirmation']),
784786
('B22', 'test_msg_signtx_taproot', 'test_send_p2tr_with_change',
785787
'Sign P2TR with device-derived change',
786788
'Derives m/86\'/0\'/0\'/1/0 on-device, emits a P2TR change output, and verifies '
787-
'the Schnorr witness against an independent BIP-340/341 reference.',
789+
'the Schnorr witness against an independent BIP-340/341 reference. The complete '
790+
'196-byte transaction is parsed as BIP-144 and must consume every byte, and the '
791+
'change output is matched as a full value/length/script triple.',
788792
['P2TR recipient confirmation', 'Fee confirmation']),
789793
('B23', 'test_msg_signtx_taproot', 'test_send_mixed_p2tr_and_legacy',
790794
'Sign mixed Taproot and legacy inputs',
791795
'Commits the P2TR signature to both inputs, including the legacy prevout amount and '
792-
'scriptPubKey, while independently verifying the resulting Schnorr witness.',
796+
'scriptPubKey, while independently verifying the resulting Schnorr witness. The '
797+
'complete 301-byte transaction is parsed as BIP-144; the Taproot input must carry '
798+
'a single 64-byte stack item and the legacy input its empty 0x00 witness.',
793799
[]),
794800
('B24', 'test_msg_signtx_taproot',
795801
'test_mixed_p2tr_requires_every_input_amount',
@@ -2199,13 +2205,29 @@ def screenshot_filter(fw_version):
21992205
return ' or '.join(terms)
22002206

22012207

2208+
# Modules whose tests must actually RUN once the firmware is new enough to be
2209+
# catalogued for them -- a skip is a failure, not a waiver.
2210+
#
2211+
# The general rule below treats 'skip' as a design waiver, which is right for
2212+
# build-flag-gated features (bitcoin-only, zcash-privacy). It is wrong for a
2213+
# capability the build claims to have: every taproot test opens with
2214+
# requires_taproot(), so if that capability regressed, all six would skip and
2215+
# the report would still read green -- the report would be certifying coverage
2216+
# it never obtained. Listing a module here converts that silence into a failure.
2217+
MUST_RUN_MODULES = {
2218+
'test_msg_signtx_taproot',
2219+
'test_msg_getaddress_taproot',
2220+
}
2221+
2222+
22022223
def validate_junit(fw_version, results):
22032224
"""Check SECTIONS tests against JUnit results. Returns (passed, failed_list).
22042225
22052226
A test is considered failed if it appears in SECTIONS for this firmware version
22062227
and the JUnit result is 'fail' or 'error' (not 'skip' or 'pass').
22072228
Tests with no JUnit entry are treated as missing (also a failure).
2208-
Tests that were skipped (gated by requires_message/requires_firmware) are OK.
2229+
Tests that were skipped (gated by requires_message/requires_firmware) are OK,
2230+
unless their module is in MUST_RUN_MODULES.
22092231
"""
22102232
active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)]
22112233
failures = []
@@ -2214,6 +2236,8 @@ def validate_junit(fw_version, results):
22142236
status = _lookup(results, mod, meth)
22152237
if status in ('fail', 'error'):
22162238
failures.append((tid, mod, meth, status))
2239+
elif status == 'skip' and mod in MUST_RUN_MODULES:
2240+
failures.append((tid, mod, meth, 'skipped-but-required'))
22172241
elif not status:
22182242
failures.append((tid, mod, meth, 'missing'))
22192243
return (len(failures) == 0, failures)

tests/test_msg_resetdevice.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
#
1919
# The script has been modified for KeepKey Device.
2020

21+
import time
2122
import unittest
2223
import common
2324
import hashlib
2425

2526
from keepkeylib import messages_pb2 as proto
27+
from keepkeylib import types_pb2 as proto_types
2628
from mnemonic import Mnemonic
2729

2830
def generate_entropy(strength, internal_entropy, external_entropy):
@@ -109,6 +111,129 @@ 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_dice(self):
115+
self.requires_firmware("7.15.0")
116+
117+
external_entropy = b'zlutoucky kun upel divoke ody' * 2
118+
strength = 256 # 99 rolls
119+
120+
ret = self.client.call_raw(proto.ResetDevice(display_random=False,
121+
strength=strength,
122+
passphrase_protection=False,
123+
pin_protection=False,
124+
language='english',
125+
label='dice',
126+
dice_entropy=True))
127+
128+
# Device announces the on-device dice entry screen
129+
self.assertIsInstance(ret, proto.ButtonRequest)
130+
self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll)
131+
132+
# Ack without blocking on the reply: the device only leaves the dice
133+
# screen once the rolls are complete, and input is ignored until the
134+
# ButtonRequest is acked.
135+
self.client.transport.write(proto.ButtonAck())
136+
time.sleep(0.3)
137+
138+
# Inject rolls in max_size-40 chunks, exercising undo ('u') along the
139+
# way. Simulate the same rules host-side to know the expected string.
140+
chunks = [
141+
"123456" * 6 + "1234", # 40 digits
142+
"654321" * 6 + "43u2", # 39 digits + undo
143+
"1234561234561234561u2u3", # more undo churn
144+
"555555555555555555555555", # top up past 99 (extras dropped)
145+
]
146+
expected = []
147+
for chunk in chunks:
148+
for c in chunk:
149+
if c == 'u':
150+
if expected:
151+
expected.pop()
152+
elif len(expected) < 99:
153+
expected.append(c)
154+
self.client.debug.press_input(chunk)
155+
time.sleep(0.2)
156+
expected = ''.join(expected)
157+
self.assertEqual(len(expected), 99)
158+
159+
# Rolls complete -> digest confirmation screen
160+
resp = self.client.transport.read_blocking()
161+
self.assertIsInstance(resp, proto.ButtonRequest)
162+
self.assertEqual(resp.code, proto_types.ButtonRequest_DiceRoll)
163+
164+
# The device-computed digest must cover exactly the injected rolls
165+
dice_digest = self.client.debug.read_dice_digest()
166+
self.assertEqual(dice_digest,
167+
hashlib.sha256(expected.encode('ascii')).digest())
168+
169+
self.client.debug.press_yes()
170+
ret = self.client.call_raw(proto.ButtonAck())
171+
172+
# From here the flow is the standard one: the displayed internal
173+
# entropy is the post-dice-mix value and still binds the seed.
174+
self.assertIsInstance(ret, proto.EntropyRequest)
175+
internal_entropy = self.client.debug.read_reset_entropy()
176+
resp = self.client.call_raw(proto.EntropyAck(entropy=external_entropy))
177+
178+
entropy = generate_entropy(strength, internal_entropy, external_entropy)
179+
expected_mnemonic = Mnemonic('english').to_mnemonic(entropy)
180+
181+
# Explainer dialog, then the paginated backup
182+
self.assertIsInstance(resp, proto.ButtonRequest)
183+
self.client.debug.press_yes()
184+
resp = self.client.call_raw(proto.ButtonAck())
185+
186+
mnemonic = []
187+
while isinstance(resp, proto.ButtonRequest):
188+
mnemonic.append(self.client.debug.read_reset_word())
189+
self.client.debug.press_yes()
190+
resp = self.client.call_raw(proto.ButtonAck())
191+
192+
self.assertIsInstance(resp, proto.Success)
193+
self.assertEqual(' '.join(mnemonic), expected_mnemonic)
194+
195+
def test_reset_reentry_disarms_entropy_ack(self):
196+
"""An aborted reset must not leave EntropyAck armed.
197+
198+
Regression: reset_init aborts (dice cancel, PIN mismatch, ...) left
199+
awaiting_entropy set from an earlier run while zeroing int_entropy,
200+
so a following EntropyAck derived the seed from
201+
sha256(0*32 || host_bytes) -- entirely host-chosen.
202+
"""
203+
self.requires_firmware("7.15.0")
204+
self.client.wipe_device()
205+
206+
# Arm a reset and walk away without acking the entropy request.
207+
ret = self.client.call_raw(proto.ResetDevice(display_random=False,
208+
strength=256,
209+
passphrase_protection=False,
210+
pin_protection=False,
211+
language='english',
212+
label='first'))
213+
self.assertIsInstance(ret, proto.EntropyRequest)
214+
215+
# Re-enter with dice, then abort from the host.
216+
ret = self.client.call_raw(proto.ResetDevice(display_random=False,
217+
strength=256,
218+
passphrase_protection=False,
219+
pin_protection=False,
220+
language='english',
221+
label='second',
222+
dice_entropy=True))
223+
self.assertIsInstance(ret, proto.ButtonRequest)
224+
self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll)
225+
ret = self.client.call_raw(proto.Cancel())
226+
self.assertIsInstance(ret, proto.Failure)
227+
228+
# The abandoned reset must be disarmed, so this cannot generate a seed.
229+
ret = self.client.call_raw(proto.EntropyAck(entropy=b'H' * 32))
230+
self.assertIsInstance(ret, proto.Failure)
231+
self.assertIn('Not in Reset mode', ret.message)
232+
233+
# And the device must still be uninitialized.
234+
ret = self.client.call_raw(proto.Initialize())
235+
self.assertFalse(ret.initialized)
236+
112237
def test_reset_device_pin(self):
113238
external_entropy = b'zlutoucky kun upel divoke ody' * 2
114239
strength = 128

tests/test_msg_signtx_taproot.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,102 @@
5959
)
6060

6161

62+
# Full BIP-144 serializations, captured from the emulator and cross-checked
63+
# against an independent derivation from this file's own inputs and the
64+
# EXPECTED_* witnesses above. These pin the bytes the host would broadcast --
65+
# `signature` alone was populated correctly even while the witness and the
66+
# locktime footer were being dropped on the wire.
67+
EXPECTED_SERIALIZED_TX = (
68+
"0100000000010137eea6e08b6227cd775f08153e291187d0df2a23261dab50752f98"
69+
"113903326e0000000000ffffffff01905f0100000000001976a914759d6677091e97"
70+
"3b9e9d99f19c68fbf43e3f05f988ac0140afe221b16d648a1ad7329f976593073238"
71+
"0cc67765bd73af7ce13b59911468512d9ee77e34af56fe1f59f98372011f7cb400ce"
72+
"d614d808c690c5ba907fb62de900000000"
73+
)
74+
EXPECTED_SERIALIZED_TX_CHANGE = (
75+
"0100000000010137eea6e08b6227cd775f08153e291187d0df2a23261dab50752f98"
76+
"113903326e0000000000ffffffff0250c30000000000001976a914759d6677091e97"
77+
"3b9e9d99f19c68fbf43e3f05f988ac409c000000000000225120882d74e5d0572d5a"
78+
"816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc0140e3c44408fe61256a"
79+
"d406733f100f1ee856eb31854335efa59e60a61ea5d41ab341802f0cccb55f644042"
80+
"a1ab390f0a406b9d3efe3996d05442b4ee43d5355eab00000000"
81+
)
82+
EXPECTED_SERIALIZED_TX_MIXED = (
83+
"01000000000102a4a9ecee1384341b77c2db4d5cc54239854f0efc5f9978f3a2a878"
84+
"2608df1f3e0000000000ffffffffa4a9ecee1384341b77c2db4d5cc54239854f0efc"
85+
"5f9978f3a2a8782608df1f3e010000006a47304402205aa50469308c21e9e1ba0299"
86+
"cd235add026914e4406bcfa6d9c0403c8cc3cf580220764a5832ad1bc36ba6a21020"
87+
"a253c2272bca5aa1643d9c41b12c318b0a38824e012103aaeb52dd7494c361049de6"
88+
"7cc680e83ebcbbbdbeb13637d92cd845f70308af5effffffff01e022020000000000"
89+
"1976a914759d6677091e973b9e9d99f19c68fbf43e3f05f988ac0140b596e1bbefb8"
90+
"55af9852942797075d4f452b2d186cb17a76226892334a497a62adb9a02f7c1b4573"
91+
"e4d48b92e2307bb0b2282c97e2c5350bb3c21619fab855a20000000000"
92+
)
93+
94+
6295
class TestMsgSigntxTaproot(KeepKeyTest):
6396

97+
def assertCompleteSegwitTx(self, raw, signatures, n_in, n_out):
98+
"""Parse the serialized tx strictly; it must consume exactly len(raw).
99+
100+
`signature` and `serialized_tx` are separate nanopb fields on
101+
TxRequestSerializedType, each with its own presence flag. Asserting
102+
only `signature` passes even when the device never transmits the
103+
witness stack -- the host then gets a tx that declares the segwit
104+
marker/flag, carries no witness and no locktime, and every node
105+
rejects it. A structural parse catches that: the marker promises
106+
witnesses, so the stream ends early and the offset check fails.
107+
108+
Returns the witness stacks, one list per input.
109+
"""
110+
pos = [0]
111+
112+
def take(n):
113+
if len(raw) < pos[0] + n:
114+
raise AssertionError(
115+
"tx truncated at offset %d: wanted %d more byte(s) of %d "
116+
"total: %s"
117+
% (pos[0], n, len(raw), hexlify(raw).decode()))
118+
out = raw[pos[0]:pos[0] + n]
119+
pos[0] += n
120+
return out
121+
122+
def varint():
123+
first = take(1)[0]
124+
if first < 0xfd:
125+
return first
126+
width = {0xfd: 2, 0xfe: 4, 0xff: 8}[first]
127+
return int.from_bytes(take(width), "little")
128+
129+
take(4) # nVersion
130+
marker = take(2)
131+
if marker != unhexlify("0001"):
132+
raise AssertionError(
133+
"missing segwit marker/flag: got %s" % hexlify(marker).decode())
134+
if varint() != n_in:
135+
raise AssertionError("unexpected input count")
136+
for _ in range(n_in):
137+
take(32); take(4); take(varint()); take(4) # outpoint, sig, seq
138+
if varint() != n_out:
139+
raise AssertionError("unexpected output count")
140+
for _ in range(n_out):
141+
take(8); take(varint()) # value, scriptPubKey
142+
witnesses = [[take(varint()) for _ in range(varint())]
143+
for _ in range(n_in)]
144+
take(4) # nLockTime footer
145+
if pos[0] != len(raw):
146+
raise AssertionError(
147+
"trailing bytes: parsed %d of %d" % (pos[0], len(raw)))
148+
149+
# Every BIP-340 signature the device reported must actually appear in
150+
# the witness data it serialized.
151+
flat = [item for stack in witnesses for item in stack]
152+
for sig in signatures:
153+
if len(sig) == 64 and sig not in flat:
154+
raise AssertionError(
155+
"schnorr signature absent from serialized_tx witnesses")
156+
return witnesses
157+
64158
def test_send_p2tr(self):
65159
"""Spend a P2TR input and compare the witness byte for byte.
66160
@@ -115,11 +209,15 @@ def test_send_p2tr(self):
115209
request_index=0)),
116210
proto.TxRequest(request_type=proto_types.TXFINISHED),
117211
])
118-
(signatures, _) = self.client.sign_tx(
212+
(signatures, serialized) = self.client.sign_tx(
119213
"Bitcoin", [inp1], [out1])
120214

121215
self.assertEqual(len(signatures), 1)
122216
self.assertEqual(hexlify(signatures[0]).decode(), EXPECTED_WITNESS)
217+
witnesses = self.assertCompleteSegwitTx(serialized, signatures, 1, 1)
218+
# key-path spend: exactly one stack item, the bare 64-byte signature
219+
self.assertEqual(witnesses[0], [signatures[0]])
220+
self.assertEqual(hexlify(serialized).decode(), EXPECTED_SERIALIZED_TX)
123221

124222
def test_send_p2tr_with_change(self):
125223
"""P2TR change is device-derived and omitted from recipient prompts."""
@@ -150,7 +248,14 @@ def test_send_p2tr_with_change(self):
150248

151249
self.assertEqual(hexlify(signatures[0]).decode(),
152250
EXPECTED_CHANGE_WITNESS)
251+
# EXPECTED_CHANGE_SCRIPT is a phase-1 output byte, which the device
252+
# transmits regardless of whether the witness ever reaches the host.
253+
# Assert the whole transaction, not just that prefix.
153254
self.assertIn(unhexlify(EXPECTED_CHANGE_SCRIPT), serialized)
255+
witnesses = self.assertCompleteSegwitTx(serialized, signatures, 1, 2)
256+
self.assertEqual(witnesses[0], [signatures[0]])
257+
self.assertEqual(hexlify(serialized).decode(),
258+
EXPECTED_SERIALIZED_TX_CHANGE)
154259

155260
def test_send_mixed_p2tr_and_legacy(self):
156261
"""A P2TR signature commits to the legacy input's real prevout."""
@@ -178,13 +283,19 @@ def test_send_mixed_p2tr_and_legacy(self):
178283
script_type=proto_types.PAYTOADDRESS,
179284
)
180285

181-
(signatures, _) = self.client.sign_tx(
286+
(signatures, serialized) = self.client.sign_tx(
182287
"Bitcoin", [taproot, legacy], [recipient])
183288

184289
self.assertEqual(len(signatures), 2)
185290
self.assertEqual(hexlify(signatures[0]).decode(),
186291
EXPECTED_MIXED_WITNESS)
187292
self.assertTrue(signatures[1])
293+
witnesses = self.assertCompleteSegwitTx(serialized, signatures, 2, 1)
294+
self.assertEqual(witnesses[0], [signatures[0]])
295+
# the legacy input must still serialize an EMPTY witness (0x00)
296+
self.assertEqual(witnesses[1], [])
297+
self.assertEqual(hexlify(serialized).decode(),
298+
EXPECTED_SERIALIZED_TX_MIXED)
188299

189300
def test_mixed_p2tr_requires_every_input_amount(self):
190301
"""Fail closed instead of signing an incomplete BIP-341 commitment."""

0 commit comments

Comments
 (0)