77#
88# These exercise the guards added in the firmware ethereum signing path.
99
10+ import time
1011import unittest
1112import common
1213import binascii
1314
1415import keepkeylib .messages_ethereum_pb2 as eth_proto
1516from keepkeylib .client import CallException
1617from 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.
1923SABLIER_PROXY = binascii .unhexlify ("bd6a40bb904aea5a49c59050b5395f7484a4203d" )
2024RECIPIENT = 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+
2377class 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
146274if __name__ == "__main__" :
0 commit comments