|
| 1 | +# This file is part of the KeepKey project. |
| 2 | +# |
| 3 | +# Regression tests for Ethereum signing pre-image / clear-sign correctness: |
| 4 | +# - EIP-1559 transaction-type vs fee-field / chain_id consistency, and |
| 5 | +# - contract clear-sign handlers must not confirm a prefix while later |
| 6 | +# streamed calldata is signed unshown, nor classify a contract CREATE. |
| 7 | +# |
| 8 | +# These exercise the guards added in the firmware ethereum signing path. |
| 9 | + |
| 10 | +import unittest |
| 11 | +import common |
| 12 | +import binascii |
| 13 | + |
| 14 | +import keepkeylib.messages_ethereum_pb2 as eth_proto |
| 15 | +from keepkeylib.client import CallException |
| 16 | +from keepkeylib.tools import int_to_big_endian |
| 17 | + |
| 18 | +# Sablier proxy address — the withdrawFromSalary clear-sign handler target. |
| 19 | +SABLIER_PROXY = binascii.unhexlify("bd6a40bb904aea5a49c59050b5395f7484a4203d") |
| 20 | +RECIPIENT = binascii.unhexlify("1d1c328764a41bda0492b66baa30c4a339ff85ef") |
| 21 | + |
| 22 | + |
| 23 | +class TestMsgEthereumSigningGuards(common.KeepKeyTest): |
| 24 | + # ---- EIP-1559 type / fee / chain_id pre-image consistency ---- |
| 25 | + |
| 26 | + def test_eip1559_requires_chain_id(self): |
| 27 | + """type=2 with no chain_id: Stage 1 counts chain_id as 1 byte but |
| 28 | + hash_rlp_number(0) hashes nothing -> over-declared list header -> |
| 29 | + wrong/garbage signer. The device must reject rather than sign it.""" |
| 30 | + self.requires_firmware("7.15.1") |
| 31 | + self.requires_fullFeature() |
| 32 | + self.setup_mnemonic_nopin_nopassphrase() |
| 33 | + self.client.apply_policy("AdvancedMode", 1) |
| 34 | + self.assertRaises( |
| 35 | + CallException, |
| 36 | + self.client.ethereum_sign_tx, |
| 37 | + n=[0, 0], |
| 38 | + nonce=0, |
| 39 | + gas_limit=21000, |
| 40 | + max_fee_per_gas=20, |
| 41 | + max_priority_fee_per_gas=1, |
| 42 | + to=RECIPIENT, |
| 43 | + value=10, |
| 44 | + # chain_id intentionally omitted -> chain_id == 0 |
| 45 | + ) |
| 46 | + |
| 47 | + def test_eip1559_no_priority_fee_signs(self): |
| 48 | + """max_priority_fee_per_gas is a mandatory EIP-1559 RLP field; when |
| 49 | + absent it must encode as the empty integer (0x80). Stage 1 always |
| 50 | + counts it, so Stage 2 must always hash it -- the device must still |
| 51 | + produce a valid signature (not desync the list header).""" |
| 52 | + self.requires_firmware("7.15.1") |
| 53 | + self.requires_fullFeature() |
| 54 | + self.setup_mnemonic_nopin_nopassphrase() |
| 55 | + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( |
| 56 | + n=[0, 0], |
| 57 | + nonce=0, |
| 58 | + gas_limit=21000, |
| 59 | + max_fee_per_gas=20, # no max_priority_fee_per_gas |
| 60 | + to=RECIPIENT, |
| 61 | + value=10, |
| 62 | + chain_id=1, |
| 63 | + ) |
| 64 | + self.assertIn(sig_v, (0, 1)) # EIP-1559 recovery-id parity |
| 65 | + self.assertEqual(len(sig_r), 32) |
| 66 | + self.assertEqual(len(sig_s), 32) |
| 67 | + |
| 68 | + def test_type2_without_max_fee_rejected(self): |
| 69 | + """Typed prefix (0x02) is chosen from msg.type but the fee fields from |
| 70 | + has_max_fee_per_gas. A type=2 tx carrying only gas_price would sign a |
| 71 | + malformed (legacy-fee-in-1559-envelope) field list -> reject.""" |
| 72 | + self.requires_firmware("7.15.1") |
| 73 | + self.requires_fullFeature() |
| 74 | + self.setup_mnemonic_nopin_nopassphrase() |
| 75 | + self.client.apply_policy("AdvancedMode", 1) |
| 76 | + msg = eth_proto.EthereumSignTx( |
| 77 | + address_n=[0, 0], |
| 78 | + nonce=int_to_big_endian(0), |
| 79 | + gas_price=int_to_big_endian(20), # legacy fee field ... |
| 80 | + gas_limit=int_to_big_endian(21000), |
| 81 | + value=int_to_big_endian(10), |
| 82 | + chain_id=1, |
| 83 | + type=2, # ... but typed as EIP-1559 |
| 84 | + ) |
| 85 | + msg.to = RECIPIENT |
| 86 | + self.assertRaises(CallException, self.client.call, msg) |
| 87 | + |
| 88 | + def test_legacy_with_max_fee_rejected(self): |
| 89 | + """A legacy tx (type omitted) carrying max_fee_per_gas would hash two |
| 90 | + fee fields into a legacy structure -> reject the mismatch.""" |
| 91 | + self.requires_firmware("7.15.1") |
| 92 | + self.requires_fullFeature() |
| 93 | + self.setup_mnemonic_nopin_nopassphrase() |
| 94 | + self.client.apply_policy("AdvancedMode", 1) |
| 95 | + msg = eth_proto.EthereumSignTx( |
| 96 | + address_n=[0, 0], |
| 97 | + nonce=int_to_big_endian(0), |
| 98 | + max_fee_per_gas=int_to_big_endian(20), |
| 99 | + max_priority_fee_per_gas=int_to_big_endian(1), |
| 100 | + gas_limit=int_to_big_endian(21000), |
| 101 | + value=int_to_big_endian(10), |
| 102 | + chain_id=1, |
| 103 | + # type omitted -> legacy |
| 104 | + ) |
| 105 | + msg.to = RECIPIENT |
| 106 | + self.assertRaises(CallException, self.client.call, msg) |
| 107 | + |
| 108 | + # ---- Contract clear-sign handler gate ---- |
| 109 | + |
| 110 | + 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.""" |
| 120 | + self.requires_firmware("7.15.1") |
| 121 | + self.requires_fullFeature() |
| 122 | + self.setup_mnemonic_nopin_nopassphrase() |
| 123 | + 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) |
| 144 | + |
| 145 | + |
| 146 | +if __name__ == "__main__": |
| 147 | + unittest.main() |
0 commit comments