Skip to content

Commit 7388ae2

Browse files
Merge pull request #21 from BitHighlander/feat/hive-tests
test(hive): vendored SLIP-0048 multi-key + account-op device tests
2 parents e717f10 + e9e4a2e commit 7388ae2

1 file changed

Lines changed: 307 additions & 0 deletions

File tree

tests/test_msg_hive.py

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
# This file is part of the KeepKey project.
2+
#
3+
# Copyright (C) 2026 KeepKey
4+
#
5+
# This library is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This library is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public License
16+
# along with this library. If not, see <http://www.gnu.org/licenses/>.
17+
18+
"""Hive (SLIP-0048) device tests — multi-role keys + account operations.
19+
20+
Uses the standard 12-word test seed (mnemonic12, "alcohol ... aisle") via
21+
setup_mnemonic_nopin_nopassphrase().
22+
23+
The account_create / account_update / transfer tests are self-validating: they
24+
recover the signer from the 65-byte device signature over
25+
SHA256(chain_id || serialized_tx) and assert it equals the device-derived
26+
signing key. This exercises the device AND validates the attestation-digest
27+
contract documented in keepkey-vault docs/HIVE-ATTESTATION-DIGEST-SPEC.md —
28+
no precomputed golden vector required, and not circular (recovery is an
29+
independent cryptographic check).
30+
"""
31+
32+
import hashlib
33+
import unittest
34+
35+
import common
36+
37+
from ecdsa import SECP256k1, VerifyingKey
38+
from ecdsa.util import sigdecode_string
39+
40+
from keepkeylib import hive
41+
from keepkeylib.tools import parse_path
42+
43+
# Hive mainnet chain id: beeab0de followed by 28 zero bytes (32 bytes).
44+
HIVE_CHAIN_ID = bytes.fromhex("beeab0de" + "00" * 28)
45+
46+
# SLIP-0048 roles (hardened offsets within the role component).
47+
ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING = 0, 1, 3, 4
48+
49+
HIVE_OP_TRANSFER = 2
50+
HIVE_OP_ACCOUNT_CREATE = 9
51+
HIVE_OP_ACCOUNT_UPDATE = 10
52+
53+
54+
def hive_path(role, account_index=0):
55+
"""m/48'/13'/role'/account'/0' — all five components hardened."""
56+
h = 0x80000000
57+
return [h + 48, h + 13, h + role, h + account_index, h]
58+
59+
60+
def recover_compressed(serialized_tx, sig65):
61+
"""Recover the 33-byte compressed signer pubkey from a Hive device signature.
62+
63+
Mirrors HIVE-ATTESTATION-DIGEST-SPEC.md §1-2:
64+
digest = SHA256(chain_id || serialized_tx)
65+
sig[0] = 27 + recovery_id + 4 -> recovery_id = sig[0] - 31
66+
sig[1:65] = r || s
67+
"""
68+
assert len(sig65) == 65, "Hive signature must be 65 bytes"
69+
recid = sig65[0] - 31
70+
assert 0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0]
71+
digest = hashlib.sha256(HIVE_CHAIN_ID + serialized_tx).digest()
72+
candidates = VerifyingKey.from_public_key_recovery_with_digest(
73+
sig65[1:], digest, SECP256k1, hashfunc=hashlib.sha256, sigdecode=sigdecode_string
74+
)
75+
return candidates[recid].to_string("compressed")
76+
77+
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
143+
144+
145+
class TestMsgHive(common.KeepKeyTest):
146+
147+
def test_hive_get_public_key_active(self):
148+
"""Active-role key derives and returns an STM-prefixed key + 33-byte raw."""
149+
self.requires_firmware("7.15.0")
150+
self.requires_message("HiveGetPublicKey")
151+
self.setup_mnemonic_nopin_nopassphrase()
152+
153+
resp = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False)
154+
self.assertTrue(resp.public_key.startswith("STM"), "expected STM-prefixed key")
155+
self.assertEqual(len(resp.raw_public_key), 33)
156+
self.assertIn(resp.raw_public_key[0], (2, 3), "compressed pubkey prefix")
157+
158+
def test_hive_get_public_keys_all_roles(self):
159+
"""All four role keys derive, are distinct, and STM-formatted."""
160+
self.requires_firmware("7.15.0")
161+
self.requires_message("HiveGetPublicKeys")
162+
self.setup_mnemonic_nopin_nopassphrase()
163+
164+
resp = hive.get_public_keys(self.client, account_index=0, show_display=False)
165+
keys = [resp.owner_key, resp.active_key, resp.memo_key, resp.posting_key]
166+
for k in keys:
167+
self.assertTrue(k.startswith("STM"), "expected STM-prefixed key, got %r" % k)
168+
self.assertEqual(len(set(keys)), 4, "the four role keys must be distinct")
169+
170+
# The single-key path must agree with the bulk path for the active role.
171+
single = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False)
172+
self.assertEqual(single.public_key, resp.active_key)
173+
174+
def test_hive_sign_transfer(self):
175+
"""Transfer (op 2) signs and the signature recovers to the active key."""
176+
self.requires_firmware("7.15.0")
177+
self.requires_message("HiveSignTx")
178+
self.setup_mnemonic_nopin_nopassphrase()
179+
180+
active = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False)
181+
resp = hive.sign_tx(
182+
self.client,
183+
address_n=hive_path(ROLE_ACTIVE),
184+
chain_id=HIVE_CHAIN_ID,
185+
ref_block_num=12345,
186+
ref_block_prefix=67890,
187+
expiration=1700000000,
188+
sender="kktester",
189+
recipient="kkrecipient",
190+
amount=1000, # 1.000 HIVE
191+
decimals=3,
192+
asset_symbol="HIVE",
193+
memo="kktest",
194+
)
195+
self.assertEqual(len(resp.signature), 65)
196+
self.assertIn(resp.signature[0], (31, 32))
197+
self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key)
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()
210+
211+
def test_hive_sign_account_create(self):
212+
"""account_create (op 9): signs, recovers to owner key, binds the 4 keys + name.
213+
214+
This is the attestation a Pioneer sponsor verifies before spending an ACT.
215+
"""
216+
self.requires_firmware("7.15.0")
217+
self.requires_message("HiveSignAccountCreate")
218+
self.requires_message("HiveGetPublicKeys")
219+
self.setup_mnemonic_nopin_nopassphrase()
220+
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)}
224+
keys = hive.get_public_keys(self.client, account_index=0, show_display=False)
225+
226+
resp = hive.sign_account_create(
227+
self.client,
228+
address_n=hive_path(ROLE_OWNER),
229+
chain_id=HIVE_CHAIN_ID,
230+
ref_block_num=12345,
231+
ref_block_prefix=67890,
232+
expiration=1700000000,
233+
creator="kksponsor",
234+
new_account_name="kktestacct",
235+
fee_amount=3000,
236+
owner_key=keys.owner_key,
237+
active_key=keys.active_key,
238+
posting_key=keys.posting_key,
239+
memo_key=keys.memo_key,
240+
)
241+
self.assertEqual(len(resp.signature), 65)
242+
self.assertIn(resp.signature[0], (31, 32))
243+
244+
# Attestation: signature recovers to the device owner key.
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()
262+
263+
def test_hive_sign_account_update(self):
264+
"""account_update (op 10): signs and recovers to the owner key."""
265+
self.requires_firmware("7.15.0")
266+
self.requires_message("HiveSignAccountUpdate")
267+
self.requires_message("HiveGetPublicKeys")
268+
self.setup_mnemonic_nopin_nopassphrase()
269+
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)}
272+
keys = hive.get_public_keys(self.client, account_index=0, show_display=False)
273+
274+
resp = hive.sign_account_update(
275+
self.client,
276+
address_n=hive_path(ROLE_OWNER),
277+
chain_id=HIVE_CHAIN_ID,
278+
ref_block_num=12345,
279+
ref_block_prefix=67890,
280+
expiration=1700000000,
281+
account="kktestacct",
282+
new_owner_key=keys.owner_key,
283+
new_active_key=keys.active_key,
284+
new_posting_key=keys.posting_key,
285+
new_memo_key=keys.memo_key,
286+
)
287+
self.assertEqual(len(resp.signature), 65)
288+
self.assertIn(resp.signature[0], (31, 32))
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()
304+
305+
306+
if __name__ == "__main__":
307+
unittest.main()

0 commit comments

Comments
 (0)