Skip to content

Commit d58dc63

Browse files
committed
fix: address review on the 7.15 harness
Two correctness defects and four test-integrity ones. eip712_stream: multidimensional arrays were validated against the WRONG dimension. Solidity nests right-to-left -- in T[k][j] the outer array holds j -- so int16[2][][4] parses to [2, 0, 4] while the first list a walker meets holds 4. Levels are now consumed from the END. Single-dimension arrays are unaffected (both ends coincide), which is why every existing test passed. clearsign_abi: signed integers were encoded as unsigned. intN was rejected for every negative value and ACCEPTED at or above 2^(N-1), which the EVM reads back as negative -- calldata that does not mean what its declared type says. Split the paths; intN is now range-checked to [-2^(N-1), 2^(N-1)-1] and sign-extended. test_msg_ethereum_thorchain_deposit: assertRaises((CallException, Exception)) accepts every failure, including a fixture that fails to build, so a security gate could pass without the firmware ever refusing. Narrowed to CallException. test_msg_solana_lut_attestation: two tests compared a degraded run against a baseline without reloading the RAM-only signer that the preceding signing tore down -- so they compared two identical baseline flows and would pass even if bad signatures were accepted. The attested test above them already documents this exact trap; the other two now reload too. test_msg_thorchain_signtx: restores real verification. Both tests asserted only r/s LENGTHS, which a wrong router, wrong calldata or wrong sighash would also satisfy. They now reconstruct the legacy sighash and recover the signer, comparing it to ethereum_get_address -- the pattern already proven in the mayachain suite. Stronger than the frozen vectors this replaced, and it stays correct across router changes. The superseded 7.14.2 vectors are kept as comments. test_msg_recoverydevice_cipher: gate raised to 7.15.1 to match its docstring. .gitmodules: device-protocol tracks master again, not up/release-protocol. NOT taken: forwarding a non-rune denom through thorchain_sign_tx. The firmware this targets hardcodes "denom":"rune" in its sign-doc; only 7.15+ reads one. nanopb SKIPS unknown fields, so forwarding it to older firmware would be silently ignored and the device would sign a RUNE transfer while the host believed otherwise. The refusal is fail-closed and stays, now with the reason recorded at the guard.
1 parent a08dbd9 commit d58dc63

8 files changed

Lines changed: 114 additions & 42 deletions

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[submodule "device-protocol"]
22
path = device-protocol
33
url = https://github.com/keepkey/device-protocol.git
4-
branch = up/release-protocol
4+
branch = master
55
[submodule "keepkeylib/eth/ethereum-lists"]
66
path = keepkeylib/eth/ethereum-lists
77
url = https://github.com/keepkey/ethereum-lists.git

keepkeylib/clearsign_abi.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,27 @@ def encode_static_args(types, values):
5454
for typ, val in zip(types, values):
5555
if typ == 'address':
5656
out += _addr_word(val)
57-
elif typ.startswith('uint') or typ.startswith('int'):
58-
digits = typ[4:] if typ.startswith('uint') else typ[3:]
57+
elif typ.startswith('uint'):
58+
digits = typ[4:]
5959
bits = int(digits) if digits else 256
6060
n = int(val)
61-
assert 0 <= n < (1 << bits), 'value %r out of range for %s' % (val, typ)
61+
assert 0 <= n < (1 << bits), (
62+
'value %r out of range for %s' % (val, typ))
6263
out += n.to_bytes(32, 'big')
64+
elif typ.startswith('int'):
65+
# Signed types are NOT unsigned ones with a wider range. intN holds
66+
# [-2^(N-1), 2^(N-1)-1] and is encoded two's-complement, sign-
67+
# extended to the full word. Treating it as unsigned both rejected
68+
# every negative value and silently accepted values at or above
69+
# 2^(N-1), which the EVM reads back as NEGATIVE -- calldata that
70+
# does not mean what the declared type says.
71+
digits = typ[3:]
72+
bits = int(digits) if digits else 256
73+
n = int(val)
74+
lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
75+
assert lo <= n <= hi, (
76+
'value %r out of range for %s (%d..%d)' % (val, typ, lo, hi))
77+
out += n.to_bytes(32, 'big', signed=True)
6378
elif typ == 'bool':
6479
out += (1 if val else 0).to_bytes(32, 'big')
6580
elif typ.startswith('bytes') and typ != 'bytes' and not typ.endswith('[]'):

keepkeylib/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,17 @@ def thorchain_sign_tx(
12041204
raise CallException("Thorchain.MsgSend", "Multiple amounts per send msg not supported")
12051205

12061206
denom = msg['value']['amount'][0]['denom']
1207+
# Fail CLOSED on any other denomination, deliberately.
1208+
#
1209+
# ThorchainMsgSend carries a `denom` field, but the firmware
1210+
# this talks to builds its amino sign-doc with the string
1211+
# "rune" HARDCODED (lib/firmware/thorchain.c) -- only 7.15+
1212+
# reads a denom and validates it. nanopb SKIPS unknown fields
1213+
# rather than rejecting them, so forwarding `denom` to older
1214+
# firmware would be silently ignored and the device would sign
1215+
# a rune transfer while the host believed it had sent another
1216+
# asset. Refusing is the only safe answer until the capability
1217+
# can be detected; do not "fix" this by passing denom through.
12071218
if denom != 'rune':
12081219
raise CallException("Thorchain.MsgSend", "Unsupported denomination: " + denom)
12091220

keepkeylib/eip712_stream.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ class Eip712Error(Exception):
4040
pass
4141

4242

43+
def _dimension(levels, used):
44+
"""The declared size of the array level being entered.
45+
46+
Solidity nests right-to-left: in `T[k][j]` the OUTER array has j elements,
47+
so `int16[2][][4]` parses to [2, 0, 4] but the first list a walker meets
48+
holds 4. Levels are therefore consumed from the END. With a single
49+
dimension both ends coincide, which is why this went unnoticed.
50+
"""
51+
return levels[len(levels) - 1 - used]
52+
53+
4354
def parse_solidity_type(type_str):
4455
""""uint256", "bytes32", "Person[3]", "int16[2][][4]" -> field descriptor.
4556
@@ -243,7 +254,7 @@ def resolve_member_path(typed_data, path):
243254
for i in range(1, len(path)):
244255
index = path[i]
245256
if levels_used < len(field['array_levels']):
246-
declared = field['array_levels'][levels_used]
257+
declared = _dimension(field['array_levels'], levels_used)
247258
if not isinstance(value, list):
248259
raise Eip712Error('Expected an array at %r' % (path[:i],))
249260
if declared and len(value) != declared:
@@ -269,7 +280,7 @@ def resolve_member_path(typed_data, path):
269280
value = value[member['name']]
270281

271282
if levels_used < len(field['array_levels']):
272-
declared = field['array_levels'][levels_used]
283+
declared = _dimension(field['array_levels'], levels_used)
273284
if not isinstance(value, list):
274285
raise Eip712Error('Expected an array for a length request')
275286
if declared and len(value) != declared:

tests/test_msg_ethereum_thorchain_deposit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def test_deposit_with_expiry_non_thor_address_blind_sign_blocked(self):
126126
import keepkeylib.types_pb2 as types
127127

128128
# No AdvancedMode, random contract address — should be rejected
129-
with self.assertRaises((CallException, Exception)):
129+
with self.assertRaises(CallException):
130130
self.client.ethereum_sign_tx(
131131
n=parse_path("m/44'/60'/0'/0/0"),
132132
nonce=3,
@@ -195,7 +195,7 @@ def test_deposit_unpinned_chain_blind_sign_blocked(self):
195195
memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0"
196196
data = _build_deposit_with_expiry_calldata(memo)
197197

198-
with self.assertRaises((CallException, Exception)):
198+
with self.assertRaises(CallException):
199199
self.client.ethereum_sign_tx(
200200
n=parse_path("m/44'/60'/0'/0/0"),
201201
nonce=5,

tests/test_msg_recoverydevice_cipher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def test_invalid_bip39_word_rejected(self):
174174
BIP-39 wordlist must return Failure immediately.
175175
Requires firmware 7.15.1+ (per-word validation).
176176
"""
177-
self.requires_firmware("7.15.0")
177+
self.requires_firmware("7.15.1")
178178
ret = self.client.call_raw(proto.RecoveryDevice(word_count=12,
179179
passphrase_protection=False,
180180
pin_protection=False,

tests/test_msg_solana_lut_attestation.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ def test_bad_signature_degrades_to_todays_flow(self):
169169
accounts = [b'\x51' * 32]
170170

171171
base_codes, _ = self._screens(raw_tx=raw)
172+
# Same reload as the attested case above: a completed signing tears the
173+
# RAM-only session down, so without this the second run would find no
174+
# signer, verify nothing, and pass vacuously by comparing two identical
175+
# baseline flows -- which is exactly what this test must not do.
176+
self._load_signer()
172177
bad_codes, resp = self._screens(
173178
raw_tx=raw, lut_account=accounts,
174179
lut_signature=b'\x00' * 64, lut_signer_key_id=SLOT)
@@ -190,6 +195,11 @@ def test_attestation_does_not_replay_onto_another_transaction(self):
190195
sig_for_a = self._attest(raw_a, accounts)
191196

192197
base_codes, _ = self._screens(raw_tx=raw_b)
198+
# Same reload as the attested case above: a completed signing tears the
199+
# RAM-only session down, so without this the second run would find no
200+
# signer, verify nothing, and pass vacuously by comparing two identical
201+
# baseline flows -- which is exactly what this test must not do.
202+
self._load_signer()
193203
replay_codes, _ = self._screens(
194204
raw_tx=raw_b, lut_account=accounts,
195205
lut_signature=sig_for_a, lut_signer_key_id=SLOT)

tests/test_msg_thorchain_signtx.py

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@
77
import keepkeylib.messages_pb2 as proto
88
import keepkeylib.types_pb2 as proto_types
99
from keepkeylib.tools import parse_path
10+
from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256
11+
12+
13+
def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id):
14+
"""Recover the 20-byte Ethereum signer from a legacy (EIP-155) signature.
15+
16+
Same helper as test_msg_mayachain_signtx.py. Recovering the signer -- rather
17+
than asserting r/s lengths -- means a wrong digest, wrong calldata, wrong
18+
key or wrong curve fails the test, and it stays correct across router
19+
changes without re-freezing vectors, which a frozen (r,s) pair does not.
20+
"""
21+
from ecdsa import VerifyingKey, SECP256k1, util
22+
rec = sig_v - (35 + 2 * chain_id) if chain_id else sig_v - 27
23+
keys = VerifyingKey.from_public_key_recovery_with_digest(
24+
sig_r + sig_s, digest, SECP256k1, hashfunc=None,
25+
sigdecode=util.sigdecode_string,
26+
)
27+
return keccak256(keys[rec].to_string())[-20:]
1028

1129
DEFAULT_BIP32_PATH = "m/44h/931h/0h/0/0"
1230

@@ -72,16 +90,13 @@ def test_sign_eth_btc_swap(self):
7290
self.requires_fullFeature()
7391
self.requires_firmware("7.1.0")
7492
self.setup_mnemonic_nopin_nopassphrase()
75-
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
76-
n=[2147483692,2147483708,2147483648,0,0],
77-
nonce=0x0,
78-
gas_price=0x5FB9ACA00,
79-
gas_limit=0x186A0,
80-
value=0x00,
81-
to=unhexlify('d37bbe5744d730a1d98d8dc97c42f0ca46ad7146'), # THORChain router v4.1.1 (firmware-pinned)
82-
address_type=0,
83-
chain_id=1,
84-
data=unhexlify('1fece7b4' +
93+
address_n = [2147483692,2147483708,2147483648,0,0]
94+
nonce = 0x0
95+
gas_price = 0x5FB9ACA00
96+
gas_limit = 0x186A0
97+
value = 0x00
98+
to = unhexlify('d37bbe5744d730a1d98d8dc97c42f0ca46ad7146') # THORChain router v4.1.1
99+
data = unhexlify('1fece7b4' +
85100
'000000000000000000000000345b297ec83add7ff74d2f7933651bffa037d956' + # asgard vault address
86101
'0000000000000000000000000000000000000000000000000000000000000000' + # asset ETH
87102
'000000000000000000000000000000000000000000000065945acd2b867ef000' + # amount
@@ -90,13 +105,20 @@ def test_sign_eth_btc_swap(self):
90105
# SWAP:BTC.BTC:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420
91106
'535741503a4254432e4254433a30783431653535363030353438323465613662' + # thorchain transaction memo
92107
'30373332653635366533616436346532306539346534353a3432300000000000')
93-
)
94-
# `to` updated to the firmware-pinned THORChain router; exact r/s
95-
# change with it, so assert structure here and regenerate exact vectors
96-
# on-device.
108+
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
109+
n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit,
110+
value=value, to=to, address_type=0, chain_id=1, data=data)
111+
# Verify the signature is over the EXACT transaction above and by
112+
# THIS device's key. Length checks alone would also pass for a wrong
113+
# router, wrong calldata or wrong sighash; recovery would not.
97114
self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1
98115
self.assertEqual(len(sig_r), 32)
99116
self.assertEqual(len(sig_s), 32)
117+
digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value,
118+
data, 1)
119+
signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1)
120+
# NB: KeepKeyTest's assertEqual override takes no msg argument.
121+
self.assertEqual(signer, self.client.ethereum_get_address(address_n))
100122

101123

102124
def test_sign_btc_add_liquidity(self):
@@ -123,16 +145,13 @@ def test_sign_eth_add_liquidity(self):
123145
self.requires_fullFeature()
124146
self.requires_firmware("7.0.2")
125147
self.setup_mnemonic_nopin_nopassphrase()
126-
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
127-
n=[2147483692,2147483708,2147483648,0,0],
128-
nonce=0x0,
129-
gas_price=0x5FB9ACA00,
130-
gas_limit=0x186A0,
131-
value=0x00,
132-
to=unhexlify('d37bbe5744d730a1d98d8dc97c42f0ca46ad7146'), # THORChain router v4.1.1 (firmware-pinned)
133-
address_type=0,
134-
chain_id=1,
135-
data=unhexlify('1fece7b4' +
148+
address_n = [2147483692,2147483708,2147483648,0,0]
149+
nonce = 0x0
150+
gas_price = 0x5FB9ACA00
151+
gas_limit = 0x186A0
152+
value = 0x00
153+
to = unhexlify('d37bbe5744d730a1d98d8dc97c42f0ca46ad7146') # THORChain router v4.1.1
154+
data = unhexlify('1fece7b4' +
136155
'0000000000000000000000000000000000000000000000000000000000000000' +
137156
'0000000000000000000000000000000000000000000000000000000000000000' +
138157
'0000000000000000000000000000000000000000000000000000000000000000' +
@@ -141,22 +160,28 @@ def test_sign_eth_add_liquidity(self):
141160
# ADD:ETH.ETH:0xc5b2608927ea95ed43f842f553e3a27b09c050e8:420
142161
'4144443a4554482e4554483a3078633562323630383932376561393565643433' +
143162
'663834326635353365336132376230396330353065383a343230000000000000')
144-
145-
)
146-
# `to` updated to the firmware-pinned THORChain router; exact r/s
147-
# change with it, so assert structure here and regenerate exact vectors
148-
# on-device.
163+
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
164+
n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit,
165+
value=value, to=to, address_type=0, chain_id=1, data=data)
166+
# Verify the signature is over the EXACT transaction above and by
167+
# THIS device's key. Length checks alone would also pass for a wrong
168+
# router, wrong calldata or wrong sighash; recovery would not.
149169
#
150-
# 7.14.2 regenerated exact vectors for this calldata, but against the
151-
# OLD `to` (0x41e5560054824ea6b0732e656e3ad64e20e94e45). `to` is an RLP
152-
# field of the sighash, so they do not describe the tx signed above.
153-
# Retained as the oracle for that superseded fixture:
170+
# 7.14.2 froze exact vectors for this calldata against the OLD `to`
171+
# (0x41e5560054824ea6b0732e656e3ad64e20e94e45). `to` is an RLP field of
172+
# the sighash, so they describe a different transaction. Kept as the
173+
# oracle for that superseded fixture:
154174
# sig_v 37
155175
# r 7adc5bda6e66b37a81962557c844509c4bfaa1e9217fc6d05968286d60b67dbf
156176
# s 613479150c4cfbcdc8243055aa5137afc89826c4176c420a60409f139171831b
157177
self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1
158178
self.assertEqual(len(sig_r), 32)
159179
self.assertEqual(len(sig_s), 32)
180+
digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value,
181+
data, 1)
182+
signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1)
183+
# NB: KeepKeyTest's assertEqual override takes no msg argument.
184+
self.assertEqual(signer, self.client.ethereum_get_address(address_n))
160185

161186
def test_thorchain_remove_liquidity(self):
162187
self.requires_fullFeature()

0 commit comments

Comments
 (0)