Skip to content

Commit a37a96a

Browse files
Merge pull request #198 from keepkey/feat/hive-clearsign-ops-phase3
test(hive): phase-3 clear-sign op device tests + wire-symbol fix
2 parents 4a1de6e + e98228f commit a37a96a

1 file changed

Lines changed: 305 additions & 4 deletions

File tree

tests/test_msg_hive.py

Lines changed: 305 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,104 @@ def _op_custom_json(required_auths, required_posting_auths, id_, json_):
127127
return out + _string(id_) + _string(json_)
128128

129129

130+
# The 2020 rebrand renamed the tokens but not their on-chain serialization:
131+
# hived still writes "STEEM" and "SBD" (confirmed against
132+
# condenser_api.get_transaction_hex). Call sites below pass the display names
133+
# because that is what a reader expects; this is the one place that knows the
134+
# wire spelling.
135+
_WIRE_SYMBOL = {"HIVE": "STEEM", "HBD": "SBD"}
136+
137+
138+
def _asset(amount, symbol):
139+
"""int64 LE amount + uint8 precision + 7-byte NUL-padded WIRE symbol.
140+
141+
Precision is pinned per symbol exactly as firmware's cur_asset requires;
142+
passing the wrong one is what the negative tests below exercise.
143+
"""
144+
precision = 6 if symbol == "VESTS" else 3
145+
wire = _WIRE_SYMBOL.get(symbol, symbol)
146+
return (struct.pack("<q", amount) + bytes([precision]) +
147+
wire.encode("ascii").ljust(7, b"\x00"))
148+
149+
150+
def _asset_raw(amount, precision, symbol):
151+
"""Asset with a caller-chosen precision, for malformed-input tests.
152+
153+
Still maps to the wire symbol: these tests target the precision and amount
154+
checks, and leaving the display spelling here would trip the symbol check
155+
first, so they would pass while testing nothing.
156+
"""
157+
wire = _WIRE_SYMBOL.get(symbol, symbol)
158+
return (struct.pack("<q", amount) + bytes([precision]) +
159+
wire.encode("ascii").ljust(7, b"\x00"))
160+
161+
162+
def _op_transfer_to_vesting(from_, to, amount):
163+
return (_varint(3) + _string(from_) + _string(to) + _asset(amount, "HIVE"))
164+
165+
166+
def _op_withdraw_vesting(account, vesting_shares):
167+
return _varint(4) + _string(account) + _asset(vesting_shares, "VESTS")
168+
169+
170+
def _op_limit_order_create(owner, orderid, sell, sell_sym, recv, recv_sym,
171+
fill_or_kill, expiration):
172+
return (_varint(5) + _string(owner) + struct.pack("<I", orderid) +
173+
_asset(sell, sell_sym) + _asset(recv, recv_sym) +
174+
bytes([1 if fill_or_kill else 0]) + struct.pack("<I", expiration))
175+
176+
177+
def _op_limit_order_cancel(owner, orderid):
178+
return _varint(6) + _string(owner) + struct.pack("<I", orderid)
179+
180+
181+
def _op_convert(owner, requestid, amount):
182+
return (_varint(8) + _string(owner) + struct.pack("<I", requestid) +
183+
_asset(amount, "HBD"))
184+
185+
186+
def _op_comment_options(author, permlink, max_payout, percent_hbd,
187+
allow_votes=True, allow_curation=True,
188+
beneficiaries=None):
189+
out = (_varint(19) + _string(author) + _string(permlink) +
190+
_asset(max_payout, "HBD") + struct.pack("<H", percent_hbd) +
191+
bytes([1 if allow_votes else 0, 1 if allow_curation else 0]))
192+
if not beneficiaries:
193+
return out + _varint(0)
194+
out += _varint(1) + _varint(0) + _varint(len(beneficiaries))
195+
for account, weight in beneficiaries:
196+
out += _string(account) + struct.pack("<H", weight)
197+
return out
198+
199+
200+
def _op_transfer_to_savings(from_, to, amount, symbol, memo=""):
201+
return (_varint(32) + _string(from_) + _string(to) +
202+
_asset(amount, symbol) + _string(memo))
203+
204+
205+
def _op_transfer_from_savings(from_, request_id, to, amount, symbol, memo=""):
206+
return (_varint(33) + _string(from_) + struct.pack("<I", request_id) +
207+
_string(to) + _asset(amount, symbol) + _string(memo))
208+
209+
210+
def _op_claim_reward_balance(account, hive_amt, hbd_amt, vests_amt):
211+
return (_varint(39) + _string(account) + _asset(hive_amt, "HIVE") +
212+
_asset(hbd_amt, "HBD") + _asset(vests_amt, "VESTS"))
213+
214+
215+
def _op_delegate_vesting_shares(delegator, delegatee, vesting_shares):
216+
return (_varint(40) + _string(delegator) + _string(delegatee) +
217+
_asset(vesting_shares, "VESTS"))
218+
219+
220+
def _op_account_update2(account, json_metadata, posting_json_metadata,
221+
authority_present=False):
222+
return (_varint(43) + _string(account) +
223+
bytes([1 if authority_present else 0, 0, 0, 0]) +
224+
_string(json_metadata) + _string(posting_json_metadata) +
225+
_varint(0))
226+
227+
130228
class _Reader:
131229
"""Cursor over the device-emitted Graphene bytes. Matches firmware
132230
serialization exactly (see hive.c append_* helpers)."""
@@ -697,8 +795,12 @@ def test_hive_sign_ops_rejects_excluded_and_unknown_ops(self):
697795
for op_type in (2, 9, 10):
698796
self._assert_ops_fails("dedicated message",
699797
_ops_tx([_varint(op_type)]))
798+
# 49 = recurrent_transfer: a real Hive op deliberately kept out of the
799+
# table. (This previously used op 3, mislabelled "comment_options";
800+
# op 3 is transfer_to_vesting and is now clear-signed, so it no longer
801+
# exercises the unknown-op path.)
700802
self._assert_ops_fails("unsupported operation",
701-
_ops_tx([_varint(3)])) # comment_options
803+
_ops_tx([_varint(49)]))
702804

703805
def test_hive_sign_ops_rejects_malformed_structure(self):
704806
"""Zero ops, >4 ops, nonzero extensions, trailing bytes, overlong
@@ -715,9 +817,9 @@ def test_hive_sign_ops_rejects_malformed_structure(self):
715817
self._assert_ops_fails("trailing bytes", _ops_tx([vote]) + b"\x00")
716818
# op_count as an overlong 6-byte varint encoding of 1
717819
head = struct.pack("<HII", 12345, 67890, 1700000000)
718-
self._assert_ops_fails("malformed op count",
820+
self._assert_ops_fails("malformed operation",
719821
head + b"\x81\x80\x80\x80\x80\x00" + vote + b"\x00")
720-
self._assert_ops_fails("weight out of range",
822+
self._assert_ops_fails("value out of range",
721823
_ops_tx([_op_vote("kkvoter", "author", "permlink", 10001)]))
722824

723825
def test_hive_sign_ops_role_fences(self):
@@ -742,7 +844,7 @@ def test_hive_sign_ops_role_fences(self):
742844
self._assert_ops_fails("mixed posting/active", mixed, path=hive_path(ROLE_ACTIVE))
743845

744846
both_auths = _ops_tx([_op_custom_json(["kkadmin"], ["kkplayer"], "x-id", '{"a":1}')])
745-
self._assert_ops_fails("mixed active+posting auths", both_auths,
847+
self._assert_ops_fails("mixed posting/active", both_auths,
746848
path=hive_path(ROLE_ACTIVE))
747849

748850
def test_hive_sign_ops_rejects_oversize(self):
@@ -758,6 +860,205 @@ def test_hive_sign_ops_rejects_oversize(self):
758860
hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx,
759861
chain_id=HIVE_CHAIN_ID)
760862

863+
# ── Phase-3 op table ─────────────────────────────────────────────────
864+
# Every tx below is built by THIS file's serializer, never by firmware, so
865+
# a parser bug and a serializer bug cannot cancel out.
866+
867+
def _ops_signs_with(self, tx, role):
868+
"""Sign tx with `role` and assert the signature recovers to that key."""
869+
key = hive.get_public_key(self.client, hive_path(role), show_display=False)
870+
resp = hive.sign_operations(self.client, hive_path(role), tx,
871+
chain_id=HIVE_CHAIN_ID)
872+
self.assertEqual(self._recover_ops_signer(tx, resp.signature),
873+
key.raw_public_key)
874+
875+
def test_hive_sign_ops_limit_order_create(self):
876+
"""The op that motivated phase 3: a HIVE->HBD internal-market swap.
877+
Active tier, since it moves funds."""
878+
self.requires_firmware("7.15.0")
879+
self.requires_message("HiveSignOperations")
880+
self.setup_mnemonic_nopin_nopassphrase()
881+
882+
tx = _ops_tx([_op_limit_order_create("kktrader", 42, 1500, "HIVE",
883+
400, "HBD", True, 1700003600)])
884+
self._ops_signs_with(tx, ROLE_ACTIVE)
885+
886+
def test_hive_sign_ops_limit_order_cancel(self):
887+
self.requires_firmware("7.15.0")
888+
self.requires_message("HiveSignOperations")
889+
self.setup_mnemonic_nopin_nopassphrase()
890+
891+
self._ops_signs_with(_ops_tx([_op_limit_order_cancel("kktrader", 42)]),
892+
ROLE_ACTIVE)
893+
894+
def test_hive_sign_ops_active_tier_value_ops(self):
895+
"""The active-tier ops that move or lock value all sign with active."""
896+
self.requires_firmware("7.15.0")
897+
self.requires_message("HiveSignOperations")
898+
self.setup_mnemonic_nopin_nopassphrase()
899+
900+
for op in (
901+
_op_transfer_to_vesting("kkuser", "kkuser", 1000),
902+
_op_convert("kkuser", 7, 2500),
903+
_op_transfer_to_savings("kkuser", "kkfriend", 1500, "HBD", "rent"),
904+
_op_transfer_from_savings("kkuser", 7, "kkfriend", 1500, "HIVE"),
905+
_op_delegate_vesting_shares("kkuser", "kkfriend", 1000000),
906+
_op_withdraw_vesting("kkuser", 5000000),
907+
):
908+
self._ops_signs_with(_ops_tx([op]), ROLE_ACTIVE)
909+
910+
def test_hive_sign_ops_posting_tier_ops(self):
911+
"""claim_reward_balance is posting tier — claiming is not spending."""
912+
self.requires_firmware("7.15.0")
913+
self.requires_message("HiveSignOperations")
914+
self.setup_mnemonic_nopin_nopassphrase()
915+
916+
tx = _ops_tx([_op_claim_reward_balance("kkuser", 1234, 5678, 90123456)])
917+
self._ops_signs_with(tx, ROLE_POSTING)
918+
919+
def test_hive_sign_ops_zero_amount_semantics(self):
920+
"""Zero means something for these two and nothing for the rest, so the
921+
parser must not apply one blanket rule."""
922+
self.requires_firmware("7.15.0")
923+
self.requires_message("HiveSignOperations")
924+
self.setup_mnemonic_nopin_nopassphrase()
925+
926+
# 0 VESTS withdraw_vesting cancels an in-progress power-down.
927+
self._ops_signs_with(_ops_tx([_op_withdraw_vesting("kkuser", 0)]),
928+
ROLE_ACTIVE)
929+
# 0 VESTS delegation removes an existing delegation.
930+
self._ops_signs_with(
931+
_ops_tx([_op_delegate_vesting_shares("kkuser", "kkfriend", 0)]),
932+
ROLE_ACTIVE)
933+
# A zero power-up, by contrast, does nothing and is refused.
934+
self._assert_ops_fails("amount must be greater than zero",
935+
_ops_tx([_op_transfer_to_vesting("kkuser", "kkuser", 0)]),
936+
path=hive_path(ROLE_ACTIVE))
937+
# Nothing to claim.
938+
self._assert_ops_fails("no effect",
939+
_ops_tx([_op_claim_reward_balance("kkuser", 0, 0, 0)]))
940+
941+
def test_hive_sign_ops_asset_symbol_and_precision_pinned(self):
942+
"""A swapped symbol hides a ~2000x value difference behind an
943+
identical-looking number; a wrong precision moves the decimal point
944+
relative to what the chain applies. Both must be refused."""
945+
self.requires_firmware("7.15.0")
946+
self.requires_message("HiveSignOperations")
947+
self.setup_mnemonic_nopin_nopassphrase()
948+
active = hive_path(ROLE_ACTIVE)
949+
950+
# transfer_to_vesting is HIVE-only.
951+
wrong_symbol = (_varint(3) + _string("kkuser") + _string("kkuser") +
952+
_asset(1000, "HBD"))
953+
self._assert_ops_fails("malformed operation", _ops_tx([wrong_symbol]),
954+
path=active)
955+
# Right symbol, wrong precision.
956+
wrong_precision = (_varint(3) + _string("kkuser") + _string("kkuser") +
957+
_asset_raw(1000, 6, "HIVE"))
958+
self._assert_ops_fails("malformed operation", _ops_tx([wrong_precision]),
959+
path=active)
960+
# Negative int64 would render as an enormous positive amount.
961+
negative = (_varint(3) + _string("kkuser") + _string("kkuser") +
962+
_asset_raw(-1000, 3, "HIVE"))
963+
self._assert_ops_fails("malformed operation", _ops_tx([negative]),
964+
path=active)
965+
# An order priced VESTS-for-HBD is not a market that exists.
966+
vests_order = (_varint(5) + _string("kktrader") + struct.pack("<I", 1) +
967+
_asset(100, "VESTS") + _asset(100, "HBD") +
968+
bytes([0]) + struct.pack("<I", 1))
969+
self._assert_ops_fails("malformed operation", _ops_tx([vests_order]),
970+
path=active)
971+
# Same symbol on both sides of an order is a no-op trade that still fills.
972+
same_symbol = _op_limit_order_create("kktrader", 1, 100, "HIVE",
973+
100, "HIVE", False, 1)
974+
self._assert_ops_fails("symbols must differ", _ops_tx([same_symbol]),
975+
path=active)
976+
977+
def test_hive_sign_ops_comment_options_binds_to_its_comment(self):
978+
"""comment_options redirects a post's payout. Detached from its
979+
comment it could retarget a post published earlier that the user is
980+
not reviewing on screen, so firmware requires it to follow one with a
981+
matching author and permlink."""
982+
self.requires_firmware("7.15.0")
983+
self.requires_message("HiveSignOperations")
984+
self.setup_mnemonic_nopin_nopassphrase()
985+
986+
comment = _op_comment("", "hive-100", "kkauthor", "my-post",
987+
"Title", b"Body", "{}")
988+
options = _op_comment_options("kkauthor", "my-post", 1000000, 10000)
989+
990+
# Standing alone: refused.
991+
self._assert_ops_fails("must follow its comment", _ops_tx([options]))
992+
# Following a comment for a DIFFERENT post: refused.
993+
other = _op_comment_options("kkauthor", "other-post", 1000000, 10000)
994+
self._assert_ops_fails("must follow its comment",
995+
_ops_tx([comment, other]))
996+
# Correctly paired, with beneficiaries: signs on the posting key.
997+
paired = _ops_tx([comment, _op_comment_options(
998+
"kkauthor", "my-post", 1000000, 10000,
999+
beneficiaries=[("aaron", 1000), ("zoe", 500)])])
1000+
self._ops_signs_with(paired, ROLE_POSTING)
1001+
1002+
def test_hive_sign_ops_comment_options_beneficiary_rules(self):
1003+
"""hived requires strictly ascending, unique beneficiaries summing to
1004+
<= 100%; signing anything else only wastes a device confirmation."""
1005+
self.requires_firmware("7.15.0")
1006+
self.requires_message("HiveSignOperations")
1007+
self.setup_mnemonic_nopin_nopassphrase()
1008+
1009+
comment = _op_comment("", "hive-100", "kkauthor", "my-post",
1010+
"Title", b"Body", "{}")
1011+
for bens in ([("zoe", 500), ("aaron", 1000)], # unsorted
1012+
[("aaron", 500), ("aaron", 500)], # duplicate
1013+
[("aaron", 6000), ("zoe", 5000)]): # > 100%
1014+
tx = _ops_tx([comment, _op_comment_options(
1015+
"kkauthor", "my-post", 1000000, 10000, beneficiaries=bens)])
1016+
self._assert_ops_fails("beneficiaries", tx)
1017+
1018+
def test_hive_sign_ops_account_update2_rejects_authority_change(self):
1019+
"""account_update2 can rotate account keys. Only the profile-metadata
1020+
form is in the table — the same device-derived-keys invariant that
1021+
keeps ops 9/10 out, applied field-level."""
1022+
self.requires_firmware("7.15.0")
1023+
self.requires_message("HiveSignOperations")
1024+
self.setup_mnemonic_nopin_nopassphrase()
1025+
1026+
self._assert_ops_fails(
1027+
"authority changes",
1028+
_ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "",
1029+
authority_present=True)]),
1030+
path=hive_path(ROLE_ACTIVE))
1031+
1032+
# json_metadata is an active-key field...
1033+
self._ops_signs_with(
1034+
_ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "")]),
1035+
ROLE_ACTIVE)
1036+
# ...while a posting-metadata-only profile edit stays posting tier.
1037+
self._ops_signs_with(
1038+
_ops_tx([_op_account_update2("kkuser", "", '{"profile":{}}')]),
1039+
ROLE_POSTING)
1040+
1041+
def test_hive_sign_ops_truncated_bodies_rejected(self):
1042+
"""The signature covers the whole buffer, so a short read would mean
1043+
signing bytes the device never displayed. Every truncation must be
1044+
refused rather than partially parsed."""
1045+
self.requires_firmware("7.15.0")
1046+
self.requires_message("HiveSignOperations")
1047+
self.setup_mnemonic_nopin_nopassphrase()
1048+
1049+
for op in (_op_limit_order_create("kktrader", 1, 100, "HIVE", 50,
1050+
"HBD", False, 9),
1051+
_op_claim_reward_balance("kkuser", 1, 1, 1),
1052+
_op_transfer_from_savings("kkuser", 7, "kkfriend", 1500,
1053+
"HBD", "memo")):
1054+
# One byte short is the boundary case; a deeper cut exercises the
1055+
# length-prefixed string readers.
1056+
for cut in (1, 5):
1057+
if cut >= len(op):
1058+
continue
1059+
self._assert_ops_fails(None, _ops_tx([op[:-cut]]),
1060+
path=hive_path(ROLE_ACTIVE))
1061+
7611062
def test_hive_sign_message_rejects_chain_id_prefix(self):
7621063
"""A 'message' that begins with the mainnet chain id would hash to a
7631064
broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)).

0 commit comments

Comments
 (0)