Skip to content

Commit d469ea6

Browse files
committed
test(ethereum): bind the signing-guard tests to the signed pre-image
Both tests asserted only that r and s were 32 bytes. The regressions they describe -- an RLP list header desynced by a priority-fee field that Stage 1 counts and Stage 2 skips, and a clear-sign handler hashing the confirmed prefix instead of the full streamed calldata -- both still produce a perfectly well-formed 32-byte r/s, so those assertions could not fail. Reconstruct the intended digest and recover the signer against ethereum_get_address: eth_sighash_eip1559 with an explicit zero priority fee for the 1559 case, eth_sighash_legacy over the COMPLETE calldata for the streamed case. Verified offline that a prefix-only digest does not recover to the same address, so the recovery genuinely discriminates. Adds test_streamed_handler_calldata_is_not_clear_signed for the second half of the old docstring's claim, which nothing tested: the Sablier summary must not be drawn for calldata the handler never saw. Compared against a no-handler baseline of identical shape rather than a hardcoded screen count, because the raw-data screen paginates with the calldata.
1 parent 73f96be commit d469ea6

1 file changed

Lines changed: 158 additions & 30 deletions

File tree

tests/test_msg_ethereum_signing_guards.py

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,73 @@
77
#
88
# These exercise the guards added in the firmware ethereum signing path.
99

10+
import time
1011
import unittest
1112
import common
1213
import binascii
1314

1415
import keepkeylib.messages_ethereum_pb2 as eth_proto
1516
from keepkeylib.client import CallException
1617
from keepkeylib.tools import int_to_big_endian
18+
from keepkeylib.signed_metadata import (
19+
eth_sighash_eip1559, eth_sighash_legacy, keccak256,
20+
)
1721

1822
# Sablier proxy address — the withdrawFromSalary clear-sign handler target.
1923
SABLIER_PROXY = binascii.unhexlify("bd6a40bb904aea5a49c59050b5395f7484a4203d")
2024
RECIPIENT = binascii.unhexlify("1d1c328764a41bda0492b66baa30c4a339ff85ef")
2125

2226

27+
def recover_eth_signer(sig_r, sig_s, recovery_id, digest):
28+
"""Recover the 20-byte signer from (r, s, recovery_id) over `digest`.
29+
30+
Same approach as test_msg_thorchain_signtx.py. Recovering the signer --
31+
rather than asserting r/s are 32 bytes -- is what makes these tests able to
32+
fail: the regressions they describe (a desynced RLP list header, a prefix
33+
hashed instead of the full calldata) still produce a perfectly well-formed
34+
32-byte r/s, so a length assertion passes while the device signs a
35+
pre-image that is not the transaction under test.
36+
"""
37+
from ecdsa import VerifyingKey, SECP256k1, util
38+
keys = VerifyingKey.from_public_key_recovery_with_digest(
39+
sig_r + sig_s, digest, SECP256k1, hashfunc=None,
40+
sigdecode=util.sigdecode_string,
41+
)
42+
return keccak256(keys[recovery_id].to_string())[-20:]
43+
44+
45+
class _ScreenRecorder(object):
46+
"""Record the framebuffer of every confirm screen an operation draws.
47+
48+
Mirrors ScreenRecorder in test_msg_ethereum_clearsign_additive.py.
49+
"""
50+
51+
SETTLE = 0.3
52+
53+
def __init__(self, client):
54+
self.client = client
55+
self.frames = []
56+
57+
def __enter__(self):
58+
original = self.client.callback_ButtonRequest
59+
60+
def record(msg):
61+
time.sleep(self.SETTLE)
62+
self.frames.append((msg.code, bytes(self.client.debug.read_layout())))
63+
return original(msg)
64+
65+
self.client.callback_ButtonRequest = record
66+
return self
67+
68+
def __exit__(self, *exc):
69+
del self.client.callback_ButtonRequest
70+
return False
71+
72+
@property
73+
def layouts(self):
74+
return [layout for _, layout in self.frames]
75+
76+
2377
class TestMsgEthereumSigningGuards(common.KeepKeyTest):
2478
# ---- EIP-1559 type / fee / chain_id pre-image consistency ----
2579

@@ -52,8 +106,9 @@ def test_eip1559_no_priority_fee_signs(self):
52106
self.requires_firmware("7.15.0")
53107
self.requires_fullFeature()
54108
self.setup_mnemonic_nopin_nopassphrase()
109+
address_n = [0, 0]
55110
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
56-
n=[0, 0],
111+
n=address_n,
57112
nonce=0,
58113
gas_limit=21000,
59114
max_fee_per_gas=20, # no max_priority_fee_per_gas
@@ -64,6 +119,20 @@ def test_eip1559_no_priority_fee_signs(self):
64119
self.assertIn(sig_v, (0, 1)) # EIP-1559 recovery-id parity
65120
self.assertEqual(len(sig_r), 32)
66121
self.assertEqual(len(sig_s), 32)
122+
# The regression this test names -- Stage 1 counting the priority-fee
123+
# field while Stage 2 skips hashing it -- desyncs the RLP list header
124+
# and yields a signature over a DIFFERENT pre-image. That signature is
125+
# still 32+32 bytes, so only reconstructing the intended digest and
126+
# recovering the signer can detect it. The absent field must encode as
127+
# the empty integer, i.e. exactly max_priority_fee_per_gas = 0.
128+
digest = eth_sighash_eip1559(
129+
chain_id=1, nonce=0, max_priority_fee_per_gas=0,
130+
max_fee_per_gas=20, gas_limit=21000, to=RECIPIENT,
131+
value=10, data=b'',
132+
)
133+
signer = recover_eth_signer(sig_r, sig_s, sig_v, digest)
134+
# NB: KeepKeyTest's assertEqual override takes no msg argument.
135+
self.assertEqual(signer, self.client.ethereum_get_address(address_n))
67136

68137
def test_type2_without_max_fee_rejected(self):
69138
"""Typed prefix (0x02) is chosen from msg.type but the fee fields from
@@ -107,40 +176,99 @@ def test_legacy_with_max_fee_rejected(self):
107176

108177
# ---- Contract clear-sign handler gate ----
109178

179+
# withdrawFromSalary selector + 2 words, then padded past 1024 bytes so
180+
# data_total != data_initial_chunk.size (forces the streaming path).
181+
STREAMED_TAIL = (
182+
binascii.unhexlify(
183+
"0000000000000000000000000000000000000000000000000000000000001210"
184+
"0000000000000000000000000000000000000000000000000000000000000001"
185+
) + b"\x00" * 1100
186+
)
187+
HANDLER_SELECTOR = binascii.unhexlify("fea7c53f") # withdrawFromSalary
188+
# A selector the device has no clear-sign handler for. Same length, same
189+
# streaming path, same `to` -- so the only thing that can change the screen
190+
# sequence is whether the handler fired.
191+
NO_HANDLER_SELECTOR = binascii.unhexlify("deadbeef")
192+
193+
STREAM_TX = dict(
194+
n=[2147483692, 2147483708, 2147483648, 0, 0],
195+
nonce=0xAB,
196+
gas_price=0x24C988AC00,
197+
gas_limit=0x26249,
198+
value=0,
199+
to=SABLIER_PROXY,
200+
address_type=0,
201+
chain_id=1,
202+
)
203+
204+
def _sign_streamed(self, selector):
205+
"""Sign the streaming-path tx with `selector`, recording its screens."""
206+
data = selector + self.STREAMED_TAIL
207+
with _ScreenRecorder(self.client) as rec:
208+
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
209+
data=data, **self.STREAM_TX)
210+
return rec, data, (sig_v, sig_r, sig_s)
211+
212+
def _assert_signed_full_calldata(self, data, sig):
213+
"""Recover the signer against a digest over the COMPLETE calldata."""
214+
sig_v, sig_r, sig_s = sig
215+
self.assertEqual(len(sig_r), 32)
216+
self.assertEqual(len(sig_s), 32)
217+
self.assertIn(sig_v, [37, 38]) # EIP-155, chain_id = 1
218+
digest = eth_sighash_legacy(
219+
self.STREAM_TX['nonce'], self.STREAM_TX['gas_price'],
220+
self.STREAM_TX['gas_limit'], SABLIER_PROXY,
221+
self.STREAM_TX['value'], data, 1,
222+
)
223+
signer = recover_eth_signer(sig_r, sig_s, sig_v - 37, digest)
224+
# NB: KeepKeyTest's assertEqual override takes no msg argument.
225+
self.assertEqual(
226+
signer, self.client.ethereum_get_address(self.STREAM_TX['n']))
227+
110228
def test_contract_handler_streamed_calldata_signs_full_data(self):
111-
"""A handler selector (sablier withdrawFromSalary) whose calldata is
112-
larger than the initial chunk must NOT be clear-signed from the prefix.
113-
The device falls back to generic raw-data confirmation and signs the
114-
full streamed calldata.
115-
116-
Asserts here that signing completes over the full (streamed) calldata;
117-
the screen-level assertion (no 'Sablier' clear-sign summary appears for
118-
streamed calldata) is verified on-device / on the emulator via
119-
DebugLink layout."""
229+
"""A handler selector whose calldata is larger than the initial chunk
230+
must sign the FULL streamed calldata, not the confirmed prefix.
231+
232+
Recovering the signer against a digest built over the complete `data`
233+
is what makes this test able to fail: if the device hashed only the
234+
first chunk it would still return a well-formed 32-byte r/s, and the
235+
length assertions this test used to make would pass.
236+
"""
120237
self.requires_firmware("7.15.0")
121238
self.requires_fullFeature()
122239
self.setup_mnemonic_nopin_nopassphrase()
123240
self.client.apply_policy("AdvancedMode", 1)
124-
# withdrawFromSalary selector + 2 words, then padded past 1024 bytes so
125-
# data_total != data_initial_chunk.size (forces the streaming path).
126-
data = binascii.unhexlify(
127-
"fea7c53f"
128-
+ "0000000000000000000000000000000000000000000000000000000000001210"
129-
+ "0000000000000000000000000000000000000000000000000000000000000001"
130-
) + b"\x00" * 1100
131-
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
132-
n=[2147483692, 2147483708, 2147483648, 0, 0],
133-
nonce=0xAB,
134-
gas_price=0x24C988AC00,
135-
gas_limit=0x26249,
136-
value=0,
137-
to=SABLIER_PROXY,
138-
address_type=0,
139-
chain_id=1,
140-
data=data,
141-
)
142-
self.assertEqual(len(sig_r), 32)
143-
self.assertEqual(len(sig_s), 32)
241+
242+
_, data, sig = self._sign_streamed(self.HANDLER_SELECTOR)
243+
self._assert_signed_full_calldata(data, sig)
244+
245+
def test_streamed_handler_calldata_is_not_clear_signed(self):
246+
"""The Sablier summary must NOT be drawn for streamed calldata.
247+
248+
The handler may only clear-sign what it actually verified, and it
249+
cannot verify calldata it has not seen. So the streamed run must fall
250+
back to the ordinary raw-data review -- the same screens a selector
251+
with no handler at all draws.
252+
253+
Compared against a no-handler baseline of identical shape (same `to`,
254+
same calldata length, same streaming path) rather than a hardcoded
255+
screen count, because the raw-data screen paginates with the calldata.
256+
A clear-signed run would add summary frames the baseline does not have.
257+
"""
258+
self.requires_firmware("7.15.0")
259+
self.requires_fullFeature()
260+
self.setup_mnemonic_nopin_nopassphrase()
261+
self.client.apply_policy("AdvancedMode", 1)
262+
263+
baseline, base_data, base_sig = self._sign_streamed(
264+
self.NO_HANDLER_SELECTOR)
265+
self._assert_signed_full_calldata(base_data, base_sig)
266+
267+
observed, data, sig = self._sign_streamed(self.HANDLER_SELECTOR)
268+
self._assert_signed_full_calldata(data, sig)
269+
270+
# Any clear-sign summary would be one or more EXTRA confirm screens.
271+
self.assertEqual(len(observed.frames), len(baseline.frames))
144272

145273

146274
if __name__ == "__main__":

0 commit comments

Comments
 (0)