Skip to content

Commit a395561

Browse files
Merge pull request #63 from BitHighlander/reconcile/alpha-upstream-20260826
reconcile: carry upstream presigning tests into alpha line
2 parents 75028c4 + 2ed8354 commit a395561

8 files changed

Lines changed: 298 additions & 25 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
# Actions integration lane. Bump deliberately with that workflow.
3333
git init .
3434
git remote add origin https://github.com/BitHighlander/keepkey-firmware.git
35-
git fetch --depth 1 origin 54b169a7036b29db22944d962fb666b50aef9083
35+
git fetch --depth 1 origin e07a95e7d069553c273b24552c9bc436f60f5d91
3636
git checkout --detach FETCH_HEAD
3737
3838
# Initialise firmware submodules

.github/workflows/ci.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,14 @@ jobs:
124124
# change this PR's result with no Python commit, which makes a green
125125
# run unciteable. This SHA is alpha at the time of pinning.
126126
#
127-
# NOTE: this is 7.16.0, NOT 7.15.0/RC18. The suite needs firmware
127+
# NOTE: this is the exact staged 7.16.0 candidate, NOT
128+
# 7.15.0/RC18. The suite needs firmware
128129
# that only exists after RC18 -- variant_getName() returning
129130
# "EmulatorBTC" (required by requires_bitcoinOnly, so by the whole
130131
# integration-btc job) and the Ironwood known-answer vectors. So this
131132
# job validates 7.16.0; it does not validate the RC18 dependency
132133
# graph. Bump deliberately, and re-read that claim when you do.
133-
ref: 54b169a7036b29db22944d962fb666b50aef9083
134+
ref: e07a95e7d069553c273b24552c9bc436f60f5d91
134135
path: keepkey-firmware
135136

136137
# NOT `submodules: recursive`. trezor-firmware carries a micropython
@@ -542,13 +543,14 @@ jobs:
542543
# change this PR's result with no Python commit, which makes a green
543544
# run unciteable. This SHA is alpha at the time of pinning.
544545
#
545-
# NOTE: this is 7.16.0, NOT 7.15.0/RC18. The suite needs firmware
546+
# NOTE: this is the exact staged 7.16.0 candidate, NOT
547+
# 7.15.0/RC18. The suite needs firmware
546548
# that only exists after RC18 -- variant_getName() returning
547549
# "EmulatorBTC" (required by requires_bitcoinOnly, so by the whole
548550
# integration-btc job) and the Ironwood known-answer vectors. So this
549551
# job validates 7.16.0; it does not validate the RC18 dependency
550552
# graph. Bump deliberately, and re-read that claim when you do.
551-
ref: a710bb5777f3ad888bb489b383dbafab800d55c6
553+
ref: e07a95e7d069553c273b24552c9bc436f60f5d91
552554
path: keepkey-firmware
553555

554556
# Same non-recursive init as the regular job: trezor-firmware's

keepkeylib/eip712_stream.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
# EthereumTypedDataValueAck.value max_size in messages-ethereum.options, and
3131
# EIP712_MAX_LEAF on the device.
3232
MAX_LEAF_BYTES = 1024
33+
MAX_IDENTIFIER_BYTES = 31
3334

3435
_ARRAY_GROUP = re.compile(r'\[([0-9]*)\]')
3536
_CANONICAL_DIGITS = re.compile(r'^[1-9][0-9]*$')
@@ -115,7 +116,7 @@ def parse_solidity_type(type_str):
115116
'array_levels': levels,
116117
}
117118

118-
if not _IDENTIFIER.match(base):
119+
if not _IDENTIFIER.match(base) or len(base) > MAX_IDENTIFIER_BYTES:
119120
raise Eip712Error('Unparseable EIP-712 type: %s' % type_str)
120121
return {'data_type': STRUCT, 'struct_name': base, 'array_levels': levels}
121122

@@ -226,10 +227,23 @@ def struct_members(typed_data, name):
226227
Order is part of the signature: it sets both encodeType and the order
227228
encodeData concatenates members.
228229
"""
230+
if not isinstance(name, str) or not _IDENTIFIER.match(name) or len(name) > MAX_IDENTIFIER_BYTES:
231+
raise Eip712Error('Struct name is not a canonical EIP-712 identifier')
229232
members = typed_data['types'].get(name)
230233
if members is None:
231234
raise Eip712Error('Unknown struct: %s' % name)
232-
return [{'name': m['name'], 'type': parse_solidity_type(m['type'])} for m in members]
235+
result = []
236+
seen = set()
237+
for member in members:
238+
member_name = member.get('name')
239+
if (not isinstance(member_name, str) or not _IDENTIFIER.match(member_name)
240+
or len(member_name) > MAX_IDENTIFIER_BYTES):
241+
raise Eip712Error('Member name in %s is not a canonical EIP-712 identifier' % name)
242+
if member_name in seen:
243+
raise Eip712Error('Duplicate EIP-712 member %s.%s' % (name, member_name))
244+
seen.add(member_name)
245+
result.append({'name': member_name, 'type': parse_solidity_type(member['type'])})
246+
return result
233247

234248

235249
def resolve_member_path(typed_data, path):

scripts/generate-test-report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2956,7 +2956,7 @@ def _arg_shown(a):
29562956
'accept a different one and it signs a document whose type declares another, with nothing '
29572957
'downstream able to notice.',
29582958
[]),
2959-
('TD4', 'test_msg_eip712_streaming', 'test_advanced_mode_gates_the_endpoint',
2959+
('TD4', 'test_msg_eip712_streaming', 'test_advanced_mode_is_not_required_for_structured_review',
29602960
'The endpoint is gated behind AdvancedMode',
29612961
'Structured display is strictly MORE information than the blind path it replaces, so the '
29622962
'gate is not about the feature being dangerous. It is about new parser surface reachable '

tests/test_msg_eip712_streaming.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,29 @@
5252

5353
class TestEip712StreamHelpers(unittest.TestCase):
5454

55+
def test_review_identifiers_are_exact_and_unambiguous(self):
56+
doc = {
57+
'types': {
58+
'Permit': [
59+
{'name': 'value', 'type': 'uint256'},
60+
{'name': 'value', 'type': 'uint256'},
61+
],
62+
},
63+
}
64+
with self.assertRaises(es.Eip712Error) as duplicate:
65+
es.struct_members(doc, 'Permit')
66+
self.assertIn('Duplicate', str(duplicate.exception))
67+
68+
doc['types']['Permit'][1]['name'] = 'identifier_that_would_be_truncated'
69+
with self.assertRaises(es.Eip712Error) as overlong:
70+
es.struct_members(doc, 'Permit')
71+
self.assertIn('canonical EIP-712 identifier', str(overlong.exception))
72+
73+
doc['types']['Permit'][1]['name'] = 'amount%08x'
74+
with self.assertRaises(es.Eip712Error) as malformed:
75+
es.struct_members(doc, 'Permit')
76+
self.assertIn('canonical EIP-712 identifier', str(malformed.exception))
77+
5578
def test_multidimensional_arrays_are_walked_outermost_first(self):
5679
doc = {
5780
'types': {
@@ -139,11 +162,14 @@ def _walk(self, doc, max_steps=400):
139162

140163
def setUp(self):
141164
super(TestMsgEip712Streaming, self).setUp()
142-
self.requires_firmware("7.15.0")
165+
self.requires_firmware("7.16.0")
143166
self.requires_fullFeature()
144167
self.requires_structured_eip712()
145168
self.setup_mnemonic_nopin_nopassphrase()
146-
self.client.apply_policy('AdvancedMode', 1)
169+
# The device-driven stream validates, displays and hashes the same
170+
# bytes. It is not the blind precomputed-hash endpoint and must work
171+
# with Advanced Mode disabled.
172+
self.client.apply_policy('AdvancedMode', 0)
147173
# The report entries describe typed-data fields, not the policy prompt.
148174
self.client.reset_screenshots()
149175

@@ -186,6 +212,61 @@ def test_array_of_structs_walks(self):
186212
self.assertIsInstance(resp, eth.EthereumTypedDataSignature)
187213
self.assertEqual(len(resp.signature), 65)
188214

215+
def test_permit2_batch_walks_realistic_nested_array(self):
216+
"""The production Permit2 Batch shape, including trailing root fields.
217+
218+
The smaller Basket fixture proves the array primitive, but does not
219+
exercise a multi-field child struct followed by more members on the
220+
parent. That is the shape Uniswap and swap providers actually send.
221+
"""
222+
doc = {
223+
"types": {
224+
"EIP712Domain": [
225+
{"name": "name", "type": "string"},
226+
{"name": "chainId", "type": "uint256"},
227+
{"name": "verifyingContract", "type": "address"},
228+
],
229+
"PermitDetails": [
230+
{"name": "token", "type": "address"},
231+
{"name": "amount", "type": "uint160"},
232+
{"name": "expiration", "type": "uint48"},
233+
{"name": "nonce", "type": "uint48"},
234+
],
235+
"PermitBatch": [
236+
{"name": "details", "type": "PermitDetails[]"},
237+
{"name": "spender", "type": "address"},
238+
{"name": "sigDeadline", "type": "uint256"},
239+
],
240+
},
241+
"primaryType": "PermitBatch",
242+
"domain": {
243+
"name": "Permit2",
244+
"chainId": 1,
245+
"verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
246+
},
247+
"message": {
248+
"details": [
249+
{
250+
"token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
251+
"amount": "250000000",
252+
"expiration": "1893456000",
253+
"nonce": "1",
254+
},
255+
{
256+
"token": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
257+
"amount": "500000000000000000000",
258+
"expiration": "1893456000",
259+
"nonce": "2",
260+
},
261+
],
262+
"spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD",
263+
"sigDeadline": "1893456000",
264+
},
265+
}
266+
resp = self._walk(doc)
267+
self.assertIsInstance(resp, eth.EthereumTypedDataSignature)
268+
self.assertEqual(len(resp.signature), 65)
269+
189270
def test_fixed_array_length_must_match_the_declared_size(self):
190271
"""A declared dimension is part of the type string and so of typeHash.
191272
@@ -207,17 +288,13 @@ def test_fixed_array_length_must_match_the_declared_size(self):
207288
self._walk(doc)
208289
self.assertIn('declares 2 elements', str(ctx.exception))
209290

210-
def test_advanced_mode_gates_the_endpoint(self):
211-
"""New parser surface reachable from a website stays behind the gate
212-
until there is hardware evidence for it."""
291+
def test_advanced_mode_is_not_required_for_structured_review(self):
292+
"""Exact device-driven review is available with blind signing off."""
213293
self.client.apply_policy('AdvancedMode', 0)
214-
msg = eth.EthereumSignTypedData()
215-
for n in PATH:
216-
msg.address_n.append(n)
217-
msg.primary_type = 'Mail'
218-
resp = self.client.call_raw(msg)
219-
self.assertIsInstance(resp, proto.Failure)
220-
self.assertIn('AdvancedMode', resp.message)
294+
resp = self._walk(SPEC_MAIL)
295+
self.assertIsInstance(resp, eth.EthereumTypedDataSignature)
296+
self.assertEqual(resp.domain_separator_hash.hex(), SPEC_DOMAIN_SEPARATOR)
297+
self.assertEqual(resp.message_hash.hex(), SPEC_MESSAGE_HASH)
221298

222299

223300
if __name__ == '__main__':

tests/test_msg_ping.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,38 @@ def test_ping_caching(self):
133133
res = self.client.ping('random data', button_protection=True, pin_protection=True, passphrase_protection=True)
134134
self.assertEqual(res, 'random data')
135135

136+
def test_authenticator_passphrase_cancel_is_terminal(self):
137+
"""Cancelling auth unlock must not fall through to cached auth data."""
138+
self.requires_firmware("7.14.2")
139+
self.setup_mnemonic_pin_passphrase()
140+
141+
# Populate both persistent auth storage and the firmware's decrypted
142+
# local cache. This is the precondition that made the stale-data path
143+
# reachable after ClearSession.
144+
self.client.ping('\x19wipeAuthdata:')
145+
# Alpha rejects TOTP seeds below the 128-bit minimum. Use a 160-bit
146+
# RFC 4648 Base32 fixture so this test reaches the cancellation path
147+
# it is intended to exercise.
148+
init_auth = (
149+
'\x15initializeAuth:example.com:alice:'
150+
'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'
151+
)
152+
self.client.ping(init_auth)
153+
self.client.clear_session()
154+
155+
resp = self.client.call_raw(proto.Ping(message='\x17getAccount:0'))
156+
self.assertIsInstance(resp, proto.PinMatrixRequest)
157+
resp = self.client.call_raw(self.client.callback_PinMatrixRequest(resp))
158+
self.assertIsInstance(resp, proto.PassphraseRequest)
159+
resp = self.client.call_raw(proto.Cancel())
160+
self.assertIsInstance(resp, proto.Failure)
161+
self.assertEqual(resp.code, proto_types.Failure_ActionCancelled)
162+
163+
# Before the fix fsm_msgPing continued after the Failure and queued a
164+
# Success carrying the cached account. The next request would receive
165+
# that stale Success instead of its own response.
166+
resp = self.client.call_raw(proto.Initialize())
167+
self.assertIsInstance(resp, proto.Features)
168+
136169
if __name__ == '__main__':
137170
unittest.main()

0 commit comments

Comments
 (0)