Skip to content

Commit e58eba5

Browse files
committed
feat(eip712): python client for the device-driven walk
The third implementation of the same protocol, and the point of it is that there are now three: firmware C, hdwallet TypeScript, and this. Two implementations built to one spec can share a misreading and agree with each other forever; a third that disagrees turns that into a test failure. Mirrors packages/hdwallet-keepkey/src/eip712Streaming.ts deliberately, function for function, so a divergence shows up as a failing test in one of them rather than as a bad signature in the field. Verified against the TS behaviour: uint256 max -> ff * 32 (the unlimited approval the old path refused) int16 -2 -> fffe (two's complement at the declared width) uint0256 -> refused, "Non-canonical integer width" uint256[0] -> refused, "Malformed array dimension" uint -> refused, "Integer type must state its width" bytes032 -> refused, "Non-canonical bytes width" Bindings regenerated with the PINNED protoc 3.5.1 in kktech/firmware:v8, the way build_pb.sh does it, producing old-style _descriptor.FileDescriptor output. Not with a modern protoc: that produced AddSerializedFile bindings that the Alpine 3.8 / Python 3.6 CI container cannot load, and it broke every alpha run until it was reverted. Two notes for whoever runs this next: - the image's `python` is Python 2 and has protobuf; `python3` does not. Install it explicitly. - protobuf 3.20.3 is NOT available for that image's python3 -- the index tops out at 4.21.0rc2 with 3.19.6 the last usable 3.x. Anything pinning 3.20.3 will fail to resolve.
1 parent 9fad463 commit e58eba5

4 files changed

Lines changed: 861 additions & 135 deletions

File tree

keepkeylib/eip712_stream.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
"""Host half of the device-driven structured EIP-712 walk.
2+
3+
The DEVICE leads. It asks for one struct definition, or one leaf value, at a
4+
time, and hashes each value in the same pass that displays it. This module
5+
answers whatever it asks until a signature comes back.
6+
7+
The host never chooses the order, and that is the property rather than an
8+
accident of the API: a host that answered a different question than the one
9+
asked would produce a digest that does not verify.
10+
11+
Mirrors packages/hdwallet-keepkey/src/eip712Streaming.ts. The two are
12+
deliberately parallel so a divergence shows up as a test failure in one of
13+
them rather than as a bad signature in the field.
14+
"""
15+
16+
import re
17+
18+
from . import messages_ethereum_pb2 as eth_proto
19+
20+
DataType = eth_proto.EthereumTypedDataStructAck
21+
22+
UINT = DataType.UINT
23+
INT = DataType.INT
24+
BYTES = DataType.BYTES
25+
STRING = DataType.STRING
26+
BOOL = DataType.BOOL
27+
ADDRESS = DataType.ADDRESS
28+
STRUCT = DataType.STRUCT
29+
30+
# EthereumTypedDataValueAck.value max_size in messages-ethereum.options, and
31+
# EIP712_MAX_LEAF on the device.
32+
MAX_LEAF_BYTES = 1024
33+
34+
_ARRAY_GROUP = re.compile(r'\[([0-9]*)\]')
35+
_CANONICAL_DIGITS = re.compile(r'^[1-9][0-9]*$')
36+
_IDENTIFIER = re.compile(r'^[A-Za-z_$][A-Za-z0-9_$]*$')
37+
38+
39+
class Eip712Error(Exception):
40+
pass
41+
42+
43+
def parse_solidity_type(type_str):
44+
""""uint256", "bytes32", "Person[3]", "int16[2][][4]" -> field descriptor.
45+
46+
Raises rather than guessing. An unparseable type must never become a
47+
signature.
48+
"""
49+
bracket = type_str.find('[')
50+
base = type_str if bracket == -1 else type_str[:bracket]
51+
suffix = '' if bracket == -1 else type_str[bracket:]
52+
53+
levels = []
54+
if suffix:
55+
consumed = 0
56+
for m in _ARRAY_GROUP.finditer(suffix):
57+
if m.start() != consumed:
58+
raise Eip712Error('Malformed array type: %s' % type_str)
59+
digits = m.group(1)
60+
if digits == '':
61+
levels.append(0) # dynamic
62+
else:
63+
# 0 is the wire's DYNAMIC sentinel, so a fixed dimension of 0
64+
# has no spelling and "[0]" would be hashed as "[]" -- a
65+
# different type string. Leading zeros re-spell the same way.
66+
if not _CANONICAL_DIGITS.match(digits):
67+
raise Eip712Error('Malformed array dimension: %s' % type_str)
68+
levels.append(int(digits))
69+
consumed = m.end()
70+
if consumed != len(suffix):
71+
raise Eip712Error('Malformed array type: %s' % type_str)
72+
73+
if base == 'string':
74+
return {'data_type': STRING, 'array_levels': levels}
75+
if base == 'bool':
76+
return {'data_type': BOOL, 'array_levels': levels}
77+
if base == 'address':
78+
return {'data_type': ADDRESS, 'array_levels': levels}
79+
if base == 'bytes':
80+
return {'data_type': BYTES, 'array_levels': levels}
81+
82+
m = re.match(r'^bytes([0-9]*)$', base)
83+
if m:
84+
if not _CANONICAL_DIGITS.match(m.group(1)):
85+
raise Eip712Error('Non-canonical bytes width: %s' % base)
86+
n = int(m.group(1))
87+
if n < 1 or n > 32:
88+
raise Eip712Error('Invalid fixed bytes width: %s' % base)
89+
return {'data_type': BYTES, 'size': n, 'array_levels': levels}
90+
91+
# Anchored to digits, so a struct named "interest" is not caught here.
92+
m = re.match(r'^(u?)int([0-9]*)$', base)
93+
if m:
94+
if m.group(2) == '':
95+
raise Eip712Error('Integer type must state its width: %s' % base)
96+
if not _CANONICAL_DIGITS.match(m.group(2)):
97+
raise Eip712Error('Non-canonical integer width: %s' % base)
98+
bits = int(m.group(2))
99+
if bits < 8 or bits > 256 or bits % 8:
100+
raise Eip712Error('Invalid integer width: %s' % base)
101+
return {
102+
'data_type': UINT if m.group(1) == 'u' else INT,
103+
'size': bits // 8,
104+
'array_levels': levels,
105+
}
106+
107+
if not _IDENTIFIER.match(base):
108+
raise Eip712Error('Unparseable EIP-712 type: %s' % type_str)
109+
return {'data_type': STRUCT, 'struct_name': base, 'array_levels': levels}
110+
111+
112+
def _to_int(value, what):
113+
if isinstance(value, bool):
114+
raise Eip712Error('%s is a bool, not an integer' % what)
115+
if isinstance(value, int):
116+
return value
117+
if isinstance(value, str):
118+
s = value.strip()
119+
if re.match(r'^-?[0-9]+$', s):
120+
return int(s, 10)
121+
if re.match(r'^0x[0-9a-fA-F]+$', s):
122+
return int(s, 16)
123+
raise Eip712Error('%s is not an integer: %r' % (what, value))
124+
125+
126+
def _hex_bytes(value, what):
127+
if isinstance(value, (bytes, bytearray)):
128+
return bytes(value)
129+
if not isinstance(value, str):
130+
raise Eip712Error('%s must be hex or bytes' % what)
131+
h = value[2:] if value[:2] in ('0x', '0X') else value
132+
if len(h) % 2 or (h and not re.match(r'^[0-9a-fA-F]+$', h)):
133+
raise Eip712Error('%s is not valid hex: %s' % (what, value))
134+
return bytes(bytearray.fromhex(h))
135+
136+
137+
def encode_value(field, value):
138+
"""One leaf, as the exact bytes the device will hash and display.
139+
140+
Raw big-endian at the declared width, never a decimal string: the device
141+
does no number parsing at all, which is what removes the old path's
142+
2**63-1 ceiling and any chance of the two sides disagreeing about what a
143+
decimal meant.
144+
"""
145+
dt = field['data_type']
146+
147+
if dt in (UINT, INT):
148+
width = field.get('size')
149+
if width is None:
150+
raise Eip712Error('Integer field has no width')
151+
n = _to_int(value, 'Integer field')
152+
bits = width * 8
153+
if dt == INT:
154+
lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
155+
if n < lo or n > hi:
156+
raise Eip712Error('Value out of range for int%d' % bits)
157+
if n < 0:
158+
n += 1 << bits
159+
else:
160+
if n < 0:
161+
raise Eip712Error('Negative value for uint%d' % bits)
162+
if n >= 1 << bits:
163+
raise Eip712Error('Value out of range for uint%d' % bits)
164+
out = bytearray(width)
165+
for i in range(width - 1, -1, -1):
166+
out[i] = n & 0xFF
167+
n >>= 8
168+
return bytes(out)
169+
170+
if dt == BOOL:
171+
if not isinstance(value, bool):
172+
raise Eip712Error('Not a boolean: %r' % (value,))
173+
return b'\x01' if value else b'\x00'
174+
175+
if dt == ADDRESS:
176+
b = _hex_bytes(value, 'Address')
177+
if len(b) != 20:
178+
raise Eip712Error('Address must be 20 bytes, got %d' % len(b))
179+
return b
180+
181+
if dt == BYTES:
182+
b = _hex_bytes(value, 'bytes')
183+
size = field.get('size')
184+
if size is not None:
185+
if len(b) != size:
186+
raise Eip712Error('bytes%d must be %d bytes, got %d' % (size, size, len(b)))
187+
return b
188+
if len(b) > MAX_LEAF_BYTES:
189+
raise Eip712Error('bytes value is %d bytes, over the %d-byte wire limit'
190+
% (len(b), MAX_LEAF_BYTES))
191+
return b
192+
193+
if dt == STRING:
194+
if not isinstance(value, str):
195+
raise Eip712Error('string field must be a string')
196+
b = value.encode('utf-8')
197+
if len(b) > MAX_LEAF_BYTES:
198+
raise Eip712Error('string value is %d bytes, over the %d-byte wire limit'
199+
% (len(b), MAX_LEAF_BYTES))
200+
return b
201+
202+
raise Eip712Error('Cannot encode data type %r as a leaf' % (dt,))
203+
204+
205+
def encode_array_length(n):
206+
"""Big-endian uint16, the wire form of an array length."""
207+
if n < 0 or n > 0xFFFF:
208+
raise Eip712Error('Array length out of range: %d' % n)
209+
return bytes(bytearray([(n >> 8) & 0xFF, n & 0xFF]))
210+
211+
212+
def struct_members(typed_data, name):
213+
"""Member list for one struct, in DECLARATION order.
214+
215+
Order is part of the signature: it sets both encodeType and the order
216+
encodeData concatenates members.
217+
"""
218+
members = typed_data['types'].get(name)
219+
if members is None:
220+
raise Eip712Error('Unknown struct: %s' % name)
221+
return [{'name': m['name'], 'type': parse_solidity_type(m['type'])} for m in members]
222+
223+
224+
def resolve_member_path(typed_data, path):
225+
"""Resolve a device-supplied member_path against the document.
226+
227+
path[0] is 0 for the domain and 1 for the message. A path stopping on an
228+
ARRAY is the device asking for its length; a path stopping on a STRUCT is a
229+
protocol error, because the device walks into structs.
230+
"""
231+
if not path:
232+
raise Eip712Error('Empty member_path')
233+
root = path[0]
234+
if root not in (0, 1):
235+
raise Eip712Error('Unknown member_path root: %d' % root)
236+
237+
field = {'data_type': STRUCT,
238+
'struct_name': 'EIP712Domain' if root == 0 else typed_data['primaryType'],
239+
'array_levels': []}
240+
value = typed_data['domain'] if root == 0 else typed_data.get('message', {})
241+
levels_used = 0
242+
243+
for i in range(1, len(path)):
244+
index = path[i]
245+
if levels_used < len(field['array_levels']):
246+
declared = field['array_levels'][levels_used]
247+
if not isinstance(value, list):
248+
raise Eip712Error('Expected an array at %r' % (path[:i],))
249+
if declared and len(value) != declared:
250+
raise Eip712Error('Fixed array declares %d elements, document has %d'
251+
% (declared, len(value)))
252+
if index >= len(value):
253+
raise Eip712Error('Array index %d out of range' % index)
254+
value = value[index]
255+
levels_used += 1
256+
continue
257+
258+
if field['data_type'] != STRUCT:
259+
raise Eip712Error('Cannot descend into a leaf at %r' % (path[:i],))
260+
members = typed_data['types'].get(field['struct_name'])
261+
if members is None:
262+
raise Eip712Error('Unknown struct: %s' % field['struct_name'])
263+
if index >= len(members):
264+
raise Eip712Error('Member index %d out of range for %s'
265+
% (index, field['struct_name']))
266+
member = members[index]
267+
field = parse_solidity_type(member['type'])
268+
levels_used = 0
269+
value = value[member['name']]
270+
271+
if levels_used < len(field['array_levels']):
272+
declared = field['array_levels'][levels_used]
273+
if not isinstance(value, list):
274+
raise Eip712Error('Expected an array for a length request')
275+
if declared and len(value) != declared:
276+
raise Eip712Error('Fixed array declares %d elements, document has %d'
277+
% (declared, len(value)))
278+
return ('length', len(value))
279+
if field['data_type'] == STRUCT:
280+
raise Eip712Error('Device asked for a struct as a value')
281+
return ('value', field, value)
282+
283+
284+
def build_struct_ack(members):
285+
"""Members, in the shape EthereumTypedDataStructAck wants."""
286+
ack = eth_proto.EthereumTypedDataStructAck()
287+
for m in members:
288+
entry = ack.members.add()
289+
entry.name = m['name']
290+
entry.type.data_type = m['type']['data_type']
291+
if 'size' in m['type']:
292+
entry.type.size = m['type']['size']
293+
if 'struct_name' in m['type']:
294+
entry.type.struct_name = m['type']['struct_name']
295+
for lvl in m['type']['array_levels']:
296+
entry.type.array_levels.append(lvl)
297+
return ack

0 commit comments

Comments
 (0)