From 38acf57b93601455f84261ceb32463bfc45d1deb Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 19 Jul 2026 17:36:36 -0300 Subject: [PATCH 1/2] test(hive): device tests for the phase-3 clear-sign op table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the 11 ops added to the firmware clear-sign table: transfer_to_vesting, withdraw_vesting, limit_order_create/cancel, convert, comment_options, transfer_to/from_savings, claim_reward_balance, delegate_vesting_shares, account_update2. Transactions are built by this file's own Graphene serializer (the _op_* helpers), never from firmware-emitted bytes, so a parser bug and a serializer bug cannot cancel out. Beyond happy-path sign-and-recover, the negative cases pin the invariants that actually protect the user: - asset symbol AND precision are pinned per op: a swapped symbol hides a ~2000x value difference behind an identical-looking number, and a wrong precision moves the decimal point relative to what the chain applies - negative int64 amounts are refused (they would render as enormous positive values) - comment_options only binds immediately after a comment with a matching author and permlink; detached it could redirect the payout of a post published earlier that the user is not reviewing on screen - account_update2 refuses any authority field - beneficiaries must be strictly ascending, unique, and sum to <= 100% - zero amounts are refused where meaningless and accepted where meaningful (withdraw_vesting 0 stops a power-down, delegate 0 removes a delegation) - truncated op bodies are refused rather than partially parsed Two fixes to existing tests: - Three assertions matched rejection strings that the firmware has since grouped by cause ("malformed op count", "weight out of range", "mixed active+posting auths"). The text is diagnostic, not contractual — the protection is that the device refuses — so the assertions follow the grouping. - rejects_excluded_and_unknown_ops used op type 3 as its "unknown op", mislabelled in a comment as comment_options. Op 3 is transfer_to_vesting and is now clear-signed, so it no longer reached the unknown-op path. Switched to 49 (recurrent_transfer), which is deliberately out of the table. 38/38 pass against an emulator built from keepkey-firmware feat/hive-clearsign-ops-phase3. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_msg_hive.py | 294 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 4 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 488a5e6c..770a9a7c 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -127,6 +127,89 @@ def _op_custom_json(required_auths, required_posting_auths, id_, json_): return out + _string(id_) + _string(json_) +def _asset(amount, symbol): + """int64 LE amount + uint8 precision + 7-byte NUL-padded symbol. + + Precision is pinned per symbol exactly as firmware's cur_asset requires; + passing the wrong one is what the negative tests below exercise. + """ + precision = 6 if symbol == "VESTS" else 3 + return (struct.pack("4 ops, nonzero extensions, trailing bytes, overlong @@ -715,9 +802,9 @@ def test_hive_sign_ops_rejects_malformed_structure(self): self._assert_ops_fails("trailing bytes", _ops_tx([vote]) + b"\x00") # op_count as an overlong 6-byte varint encoding of 1 head = struct.pack("HBD internal-market swap. + Active tier, since it moves funds.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_limit_order_create("kktrader", 42, 1500, "HIVE", + 400, "HBD", True, 1700003600)]) + self._ops_signs_with(tx, ROLE_ACTIVE) + + def test_hive_sign_ops_limit_order_cancel(self): + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + self._ops_signs_with(_ops_tx([_op_limit_order_cancel("kktrader", 42)]), + ROLE_ACTIVE) + + def test_hive_sign_ops_active_tier_value_ops(self): + """The active-tier ops that move or lock value all sign with active.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + for op in ( + _op_transfer_to_vesting("kkuser", "kkuser", 1000), + _op_convert("kkuser", 7, 2500), + _op_transfer_to_savings("kkuser", "kkfriend", 1500, "HBD", "rent"), + _op_transfer_from_savings("kkuser", 7, "kkfriend", 1500, "HIVE"), + _op_delegate_vesting_shares("kkuser", "kkfriend", 1000000), + _op_withdraw_vesting("kkuser", 5000000), + ): + self._ops_signs_with(_ops_tx([op]), ROLE_ACTIVE) + + def test_hive_sign_ops_posting_tier_ops(self): + """claim_reward_balance is posting tier — claiming is not spending.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_claim_reward_balance("kkuser", 1234, 5678, 90123456)]) + self._ops_signs_with(tx, ROLE_POSTING) + + def test_hive_sign_ops_zero_amount_semantics(self): + """Zero means something for these two and nothing for the rest, so the + parser must not apply one blanket rule.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + # 0 VESTS withdraw_vesting cancels an in-progress power-down. + self._ops_signs_with(_ops_tx([_op_withdraw_vesting("kkuser", 0)]), + ROLE_ACTIVE) + # 0 VESTS delegation removes an existing delegation. + self._ops_signs_with( + _ops_tx([_op_delegate_vesting_shares("kkuser", "kkfriend", 0)]), + ROLE_ACTIVE) + # A zero power-up, by contrast, does nothing and is refused. + self._assert_ops_fails("amount must be greater than zero", + _ops_tx([_op_transfer_to_vesting("kkuser", "kkuser", 0)]), + path=hive_path(ROLE_ACTIVE)) + # Nothing to claim. + self._assert_ops_fails("no effect", + _ops_tx([_op_claim_reward_balance("kkuser", 0, 0, 0)])) + + def test_hive_sign_ops_asset_symbol_and_precision_pinned(self): + """A swapped symbol hides a ~2000x value difference behind an + identical-looking number; a wrong precision moves the decimal point + relative to what the chain applies. Both must be refused.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + active = hive_path(ROLE_ACTIVE) + + # transfer_to_vesting is HIVE-only. + wrong_symbol = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset(1000, "HBD")) + self._assert_ops_fails("malformed operation", _ops_tx([wrong_symbol]), + path=active) + # Right symbol, wrong precision. + wrong_precision = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset_raw(1000, 6, "HIVE")) + self._assert_ops_fails("malformed operation", _ops_tx([wrong_precision]), + path=active) + # Negative int64 would render as an enormous positive amount. + negative = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset_raw(-1000, 3, "HIVE")) + self._assert_ops_fails("malformed operation", _ops_tx([negative]), + path=active) + # An order priced VESTS-for-HBD is not a market that exists. + vests_order = (_varint(5) + _string("kktrader") + struct.pack(" 100% + tx = _ops_tx([comment, _op_comment_options( + "kkauthor", "my-post", 1000000, 10000, beneficiaries=bens)]) + self._assert_ops_fails("beneficiaries", tx) + + def test_hive_sign_ops_account_update2_rejects_authority_change(self): + """account_update2 can rotate account keys. Only the profile-metadata + form is in the table — the same device-derived-keys invariant that + keeps ops 9/10 out, applied field-level.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + self._assert_ops_fails( + "authority changes", + _ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "", + authority_present=True)]), + path=hive_path(ROLE_ACTIVE)) + + # json_metadata is an active-key field... + self._ops_signs_with( + _ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "")]), + ROLE_ACTIVE) + # ...while a posting-metadata-only profile edit stays posting tier. + self._ops_signs_with( + _ops_tx([_op_account_update2("kkuser", "", '{"profile":{}}')]), + ROLE_POSTING) + + def test_hive_sign_ops_truncated_bodies_rejected(self): + """The signature covers the whole buffer, so a short read would mean + signing bytes the device never displayed. Every truncation must be + refused rather than partially parsed.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + for op in (_op_limit_order_create("kktrader", 1, 100, "HIVE", 50, + "HBD", False, 9), + _op_claim_reward_balance("kkuser", 1, 1, 1), + _op_transfer_from_savings("kkuser", 7, "kkfriend", 1500, + "HBD", "memo")): + # One byte short is the boundary case; a deeper cut exercises the + # length-prefixed string readers. + for cut in (1, 5): + if cut >= len(op): + continue + self._assert_ops_fails(None, _ops_tx([op[:-cut]]), + path=hive_path(ROLE_ACTIVE)) + def test_hive_sign_message_rejects_chain_id_prefix(self): """A 'message' that begins with the mainnet chain id would hash to a broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)). From e98228f8b27a5275ff9f69d1c29352d9dfaf7516 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 21:57:35 -0300 Subject: [PATCH 2/2] test(hive): serialize assets with the wire symbols hived uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hived encodes HIVE as "STEEM" and HBD as "SBD" — the 2020 rebrand renamed the tokens but not their serialization. These tests wrote the display spelling, matching a firmware parser that expected the same wrong bytes, so they passed while every resulting signature was rejected on-chain as "missing required active authority". _asset_raw maps too: its callers target the precision and negative-amount checks, and leaving the display spelling there would trip the symbol check first — passing while testing nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_msg_hive.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 770a9a7c..bee79251 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -127,21 +127,36 @@ def _op_custom_json(required_auths, required_posting_auths, id_, json_): return out + _string(id_) + _string(json_) +# The 2020 rebrand renamed the tokens but not their on-chain serialization: +# hived still writes "STEEM" and "SBD" (confirmed against +# condenser_api.get_transaction_hex). Call sites below pass the display names +# because that is what a reader expects; this is the one place that knows the +# wire spelling. +_WIRE_SYMBOL = {"HIVE": "STEEM", "HBD": "SBD"} + + def _asset(amount, symbol): - """int64 LE amount + uint8 precision + 7-byte NUL-padded symbol. + """int64 LE amount + uint8 precision + 7-byte NUL-padded WIRE symbol. Precision is pinned per symbol exactly as firmware's cur_asset requires; passing the wrong one is what the negative tests below exercise. """ precision = 6 if symbol == "VESTS" else 3 + wire = _WIRE_SYMBOL.get(symbol, symbol) return (struct.pack("