Skip to content

Commit e9e4a2e

Browse files
committed
test(hive): parse serialized_tx and bind every field by position
Addresses review: substring-presence was too weak — a role swap (both keys present), a creator rewrite, or an amount change could still pass. Add a cursor-based Graphene reader matching the firmware append_* layout exactly (incl. account_update's 0x01 optional-present flags, asset symbol padding, and the no-wrapper memo_key) and rewrite all three signing tests to parse and assert each field at its expected position + assert_end() for no trailing bytes: - transfer: from / to / amount / precision / symbol / memo - account_create: fee / creator / name / owner|active|posting authority slots / memo_key - account_update: account / each replacement key in its slot / memo_key Recovery assertions retained. Parser validated offline against hand-built firmware-format bytes.
1 parent e3fb2ff commit e9e4a2e

1 file changed

Lines changed: 116 additions & 25 deletions

File tree

tests/test_msg_hive.py

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,74 @@ def recover_compressed(serialized_tx, sig65):
7575
return candidates[recid].to_string("compressed")
7676

7777

78-
class TestMsgHive(common.KeepKeyTest):
78+
class _Reader:
79+
"""Cursor over the device-emitted Graphene bytes. Matches firmware
80+
serialization exactly (see hive.c append_* helpers)."""
81+
82+
def __init__(self, data):
83+
self.d = data
84+
self.i = 0
85+
86+
def take(self, n):
87+
v = self.d[self.i:self.i + n]
88+
assert len(v) == n, "truncated serialized_tx"
89+
self.i += n
90+
return v
91+
92+
def u8(self):
93+
return self.take(1)[0]
94+
95+
def u16le(self):
96+
return int.from_bytes(self.take(2), "little")
97+
98+
def u32le(self):
99+
return int.from_bytes(self.take(4), "little")
100+
101+
def u64le(self):
102+
return int.from_bytes(self.take(8), "little")
103+
104+
def varint(self):
105+
shift = result = 0
106+
while True:
107+
b = self.u8()
108+
result |= (b & 0x7F) << shift
109+
if not (b & 0x80):
110+
return result
111+
shift += 7
112+
113+
def string(self):
114+
return self.take(self.varint())
115+
116+
def asset(self):
117+
amount = self.u64le()
118+
precision = self.u8()
119+
symbol = self.take(7).rstrip(b"\x00").decode()
120+
return amount, precision, symbol
121+
122+
def authority(self):
123+
# weight_threshold=1, 0 account auths, 1 key auth, key(33), weight=1
124+
assert self.u32le() == 1, "weight_threshold must be 1"
125+
assert self.varint() == 0, "expected 0 account_auths"
126+
assert self.varint() == 1, "expected 1 key_auth"
127+
key = self.take(33)
128+
assert self.u16le() == 1, "key weight must be 1"
129+
return key
130+
131+
def assert_end(self):
132+
assert self.i == len(self.d), "trailing bytes after operation (offset %d/%d)" % (self.i, len(self.d))
133+
134+
135+
def _parse_header(r, expected_op):
136+
ref_block_num = r.u16le()
137+
ref_block_prefix = r.u32le()
138+
expiration = r.u32le()
139+
assert r.varint() == 1, "expected exactly one operation"
140+
op_type = r.varint()
141+
assert op_type == expected_op, "op_type %d != expected %d" % (op_type, expected_op)
142+
return ref_block_num, ref_block_prefix, expiration
79143

80-
def _owner_raw(self):
81-
"""Device-derived owner key (33-byte compressed) at account 0."""
82-
resp = hive.get_public_key(self.client, hive_path(ROLE_OWNER), show_display=False)
83-
self.assertEqual(len(resp.raw_public_key), 33)
84-
return resp.raw_public_key
144+
145+
class TestMsgHive(common.KeepKeyTest):
85146

86147
def test_hive_get_public_key_active(self):
87148
"""Active-role key derives and returns an STM-prefixed key + 33-byte raw."""
@@ -133,10 +194,19 @@ def test_hive_sign_transfer(self):
133194
)
134195
self.assertEqual(len(resp.signature), 65)
135196
self.assertIn(resp.signature[0], (31, 32))
136-
self.assertTrue(len(resp.serialized_tx) > 0)
137197
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key)
138-
# op byte sits right after header (u16 + u32 + u32) and the 0x01 op-count varint.
139-
self.assertEqual(resp.serialized_tx[11], HIVE_OP_TRANSFER)
198+
199+
# Parse the transfer op and bind EVERY field — a rewritten recipient,
200+
# amount, or asset must fail, not just a missing substring.
201+
r = _Reader(resp.serialized_tx)
202+
ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_TRANSFER)
203+
self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000))
204+
self.assertEqual(r.string(), b"kktester") # from
205+
self.assertEqual(r.string(), b"kkrecipient") # to
206+
self.assertEqual(r.asset(), (1000, 3, "HIVE"))
207+
self.assertEqual(r.string(), b"kktest") # memo
208+
self.assertEqual(r.varint(), 0) # extensions
209+
r.assert_end()
140210

141211
def test_hive_sign_account_create(self):
142212
"""account_create (op 9): signs, recovers to owner key, binds the 4 keys + name.
@@ -148,7 +218,9 @@ def test_hive_sign_account_create(self):
148218
self.requires_message("HiveGetPublicKeys")
149219
self.setup_mnemonic_nopin_nopassphrase()
150220

151-
owner_raw = self._owner_raw()
221+
# Device-derived raw keys per role, for slot-exact comparison.
222+
raw = {role: hive.get_public_key(self.client, hive_path(role), show_display=False).raw_public_key
223+
for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_POSTING, ROLE_MEMO)}
152224
keys = hive.get_public_keys(self.client, account_index=0, show_display=False)
153225

154226
resp = hive.sign_account_create(
@@ -170,17 +242,23 @@ def test_hive_sign_account_create(self):
170242
self.assertIn(resp.signature[0], (31, 32))
171243

172244
# Attestation: signature recovers to the device owner key.
173-
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), owner_raw)
174-
175-
tx = resp.serialized_tx
176-
self.assertEqual(tx[11], HIVE_OP_ACCOUNT_CREATE)
177-
# The new account name and all four device-raw role keys are bound into the
178-
# signed bytes (per spec §3); a sponsor parses these to confirm what it creates.
179-
self.assertIn(b"kktestacct", tx)
180-
single = hive.get_public_key # local alias
181-
for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_POSTING, ROLE_MEMO):
182-
raw = single(self.client, hive_path(role), show_display=False).raw_public_key
183-
self.assertIn(raw, tx, "role %d key must be embedded in account_create" % role)
245+
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), raw[ROLE_OWNER])
246+
247+
# Parse op 9 and bind EVERY field at its position. A firmware bug that
248+
# swaps roles, rewrites the creator, or alters the fee must fail here.
249+
r = _Reader(resp.serialized_tx)
250+
ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_ACCOUNT_CREATE)
251+
self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000))
252+
self.assertEqual(r.asset(), (3000, 3, "HIVE")) # fee
253+
self.assertEqual(r.string(), b"kksponsor") # creator
254+
self.assertEqual(r.string(), b"kktestacct") # new_account_name
255+
self.assertEqual(r.authority(), raw[ROLE_OWNER], "owner authority slot")
256+
self.assertEqual(r.authority(), raw[ROLE_ACTIVE], "active authority slot")
257+
self.assertEqual(r.authority(), raw[ROLE_POSTING], "posting authority slot")
258+
self.assertEqual(r.take(33), raw[ROLE_MEMO], "memo_key slot")
259+
self.assertEqual(r.string(), b"") # json_metadata
260+
self.assertEqual(r.varint(), 0) # extensions
261+
r.assert_end()
184262

185263
def test_hive_sign_account_update(self):
186264
"""account_update (op 10): signs and recovers to the owner key."""
@@ -189,7 +267,8 @@ def test_hive_sign_account_update(self):
189267
self.requires_message("HiveGetPublicKeys")
190268
self.setup_mnemonic_nopin_nopassphrase()
191269

192-
owner_raw = self._owner_raw()
270+
raw = {role: hive.get_public_key(self.client, hive_path(role), show_display=False).raw_public_key
271+
for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_POSTING, ROLE_MEMO)}
193272
keys = hive.get_public_keys(self.client, account_index=0, show_display=False)
194273

195274
resp = hive.sign_account_update(
@@ -207,9 +286,21 @@ def test_hive_sign_account_update(self):
207286
)
208287
self.assertEqual(len(resp.signature), 65)
209288
self.assertIn(resp.signature[0], (31, 32))
210-
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), owner_raw)
211-
self.assertEqual(resp.serialized_tx[11], HIVE_OP_ACCOUNT_UPDATE)
212-
self.assertIn(b"kktestacct", resp.serialized_tx)
289+
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), raw[ROLE_OWNER])
290+
291+
# Parse op 10 and bind the replacement keys to their slots. A bad impl
292+
# that updates the wrong authorities must fail even if op/name are right.
293+
r = _Reader(resp.serialized_tx)
294+
ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_ACCOUNT_UPDATE)
295+
self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000))
296+
self.assertEqual(r.string(), b"kktestacct") # account
297+
for role, label in ((ROLE_OWNER, "owner"), (ROLE_ACTIVE, "active"), (ROLE_POSTING, "posting")):
298+
self.assertEqual(r.u8(), 0x01, "%s optional-present flag" % label)
299+
self.assertEqual(r.authority(), raw[role], "%s authority slot" % label)
300+
self.assertEqual(r.take(33), raw[ROLE_MEMO], "memo_key slot")
301+
self.assertEqual(r.string(), b"") # json_metadata
302+
self.assertEqual(r.varint(), 0) # extensions
303+
r.assert_end()
213304

214305

215306
if __name__ == "__main__":

0 commit comments

Comments
 (0)