|
| 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