|
36 | 36 |
|
37 | 37 | from keepkeylib.signed_metadata import ( |
38 | 38 | serialize_metadata, |
| 39 | + serialize_schema_metadata, |
| 40 | + schema_calldata, |
39 | 41 | sign_metadata, |
40 | 42 | build_test_metadata, |
41 | 43 | token_amount_value, |
|
45 | 47 | ARG_FORMAT_BYTES, |
46 | 48 | ARG_FORMAT_STRING, |
47 | 49 | ARG_FORMAT_TOKEN_AMOUNT, |
| 50 | + METADATA_VERSION_SCHEMA, |
48 | 51 | CLASSIFICATION_VERIFIED, |
49 | 52 | CLASSIFICATION_OPAQUE, |
50 | 53 | CLASSIFICATION_MALFORMED, |
@@ -696,6 +699,121 @@ def test_catalog_uses_only_hexfree_formats(self): |
696 | 699 | (flow['key'], arg['name'])) |
697 | 700 |
|
698 | 701 |
|
| 702 | +# ═══════════════════════════════════════════════════════════════════════ |
| 703 | +# v2 static-schema blobs (offline) — no device required |
| 704 | +# |
| 705 | +# v2 attests only the decode SCHEMA (no tx_hash, no arg values); the device |
| 706 | +# decodes the argument values from the calldata it signs. These offline tests |
| 707 | +# pin the wire format serialize_schema_metadata() emits so it can never drift |
| 708 | +# from firmware's parse_v2_args() / decode_v2_args() undetected. |
| 709 | +# ═══════════════════════════════════════════════════════════════════════ |
| 710 | + |
| 711 | +# transfer(to, amount) on USDC — the canonical v2 fixture. amount is a token |
| 712 | +# amount (6 decimals, "USDC"); the value is NOT in the blob, it is decoded from |
| 713 | +# the calldata word by the device. |
| 714 | +USDC_ADDRESS = bytes.fromhex('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') |
| 715 | +ERC20_TRANSFER_SELECTOR = bytes.fromhex('a9059cbb') |
| 716 | +V2_SCHEMA_ARGS = [ |
| 717 | + {'name': 'to', 'format': ARG_FORMAT_ADDRESS}, |
| 718 | + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, |
| 719 | + 'decimals': 6, 'symbol': 'USDC'}, |
| 720 | +] |
| 721 | + |
| 722 | + |
| 723 | +def _v2_transfer_blob(): |
| 724 | + body = serialize_schema_metadata( |
| 725 | + chain_id=1, contract_address=USDC_ADDRESS, |
| 726 | + selector=ERC20_TRANSFER_SELECTOR, method_name='transfer', |
| 727 | + args=V2_SCHEMA_ARGS, timestamp=0, key_id=TEST_KEY_ID) |
| 728 | + return body, sign_metadata(body) |
| 729 | + |
| 730 | + |
| 731 | +class TestClearSignV2SchemaOffline(unittest.TestCase): |
| 732 | + """Offline byte-format tests for the v2 static-schema serializer.""" |
| 733 | + |
| 734 | + def test_version_byte_is_schema(self): |
| 735 | + body, _ = _v2_transfer_blob() |
| 736 | + self.assertEqual(body[0], METADATA_VERSION_SCHEMA) |
| 737 | + |
| 738 | + def test_layout_has_no_tx_hash(self): |
| 739 | + """v2 body = version(1)+chain(4)+contract(20)+selector(4)+method... — |
| 740 | + the selector sits at offset 25, immediately after the contract, with NO |
| 741 | + 32-byte tx_hash in between (that is the whole point of v2).""" |
| 742 | + body, _ = _v2_transfer_blob() |
| 743 | + self.assertEqual(body[1:5], b'\x00\x00\x00\x01') # chain_id |
| 744 | + self.assertEqual(body[5:25], USDC_ADDRESS) # contract |
| 745 | + self.assertEqual(body[25:29], ERC20_TRANSFER_SELECTOR) # selector @25 |
| 746 | + # method_len(2) + 'transfer'(8) then num_args |
| 747 | + self.assertEqual(body[29:31], b'\x00\x08') |
| 748 | + self.assertEqual(body[31:39], b'transfer') |
| 749 | + self.assertEqual(body[39], len(V2_SCHEMA_ARGS)) |
| 750 | + |
| 751 | + def test_token_arg_carries_static_decimals_symbol_not_value(self): |
| 752 | + """The token arg encodes name + format + decimals + symbol, and NO |
| 753 | + value — decimals/symbol are static (a property of the contract), the |
| 754 | + amount is decoded on-device from the calldata.""" |
| 755 | + body, _ = _v2_transfer_blob() |
| 756 | + # after num_args @39: arg0 'to' = len(1)+'to'(2)+format(1) = 4 bytes |
| 757 | + p = 40 |
| 758 | + self.assertEqual(body[p], 2) # name_len 'to' |
| 759 | + self.assertEqual(body[p + 1:p + 3], b'to') |
| 760 | + self.assertEqual(body[p + 3], ARG_FORMAT_ADDRESS) |
| 761 | + p += 4 |
| 762 | + # arg1 'amount' = len(1)+'amount'(6)+format(1)+decimals(1)+symlen(1)+'USDC'(4) |
| 763 | + self.assertEqual(body[p], 6) |
| 764 | + self.assertEqual(body[p + 1:p + 7], b'amount') |
| 765 | + self.assertEqual(body[p + 7], ARG_FORMAT_TOKEN_AMOUNT) |
| 766 | + self.assertEqual(body[p + 8], 6) # decimals |
| 767 | + self.assertEqual(body[p + 9], 4) # symbol_len |
| 768 | + self.assertEqual(body[p + 10:p + 14], b'USDC') |
| 769 | + |
| 770 | + def test_signed_blob_is_body_plus_65(self): |
| 771 | + body, blob = _v2_transfer_blob() |
| 772 | + self.assertEqual(len(blob), len(body) + 65) |
| 773 | + |
| 774 | + def test_frozen_body_snapshot(self): |
| 775 | + """Freeze the canonical v2 UNSIGNED body's length + sha256. The body is |
| 776 | + key-independent (no signature) and deterministic (timestamp=0), so this |
| 777 | + is a pure wire-format drift gate: it trips iff serialize_schema_metadata() |
| 778 | + changes the bytes, which must stay in lockstep with firmware's |
| 779 | + parse_v2_args(). (The signature is exercised separately.)""" |
| 780 | + body, _ = _v2_transfer_blob() |
| 781 | + got = (len(body), hashlib.sha256(body).hexdigest()) |
| 782 | + self.assertEqual(got, V2_BODY_SNAPSHOT, |
| 783 | + 'v2 body drift: only update V2_BODY_SNAPSHOT if the wire ' |
| 784 | + 'format intentionally changed (and firmware too)') |
| 785 | + |
| 786 | + def test_calldata_matches_schema_shape(self): |
| 787 | + """schema_calldata() builds selector + one 32-byte word per arg, so the |
| 788 | + device decodes exactly num_args words (the structural binding).""" |
| 789 | + cd = schema_calldata(ERC20_TRANSFER_SELECTOR, [ |
| 790 | + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, |
| 791 | + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, |
| 792 | + ]) |
| 793 | + self.assertEqual(len(cd), 4 + 32 * 2) |
| 794 | + self.assertEqual(cd[:4], ERC20_TRANSFER_SELECTOR) |
| 795 | + self.assertEqual(cd[4:16], b'\x00' * 12) # address left-padding |
| 796 | + self.assertEqual(cd[16:36], VITALIK) |
| 797 | + self.assertEqual(int.from_bytes(cd[36:68], 'big'), 1500000) |
| 798 | + |
| 799 | + def test_rejects_dynamic_format(self): |
| 800 | + """v2 only encodes fixed single-word types; STRING/BYTES are rejected by |
| 801 | + the serializer (they have no fixed on-chain word).""" |
| 802 | + with self.assertRaises(AssertionError): |
| 803 | + serialize_schema_metadata( |
| 804 | + chain_id=1, contract_address=USDC_ADDRESS, |
| 805 | + selector=ERC20_TRANSFER_SELECTOR, method_name='x', |
| 806 | + args=[{'name': 'label', 'format': ARG_FORMAT_STRING}]) |
| 807 | + |
| 808 | + |
| 809 | +# Frozen len + sha256 of the canonical v2 UNSIGNED transfer body (timestamp=0, |
| 810 | +# key-independent). Regenerate ONLY on an intentional wire-format change: |
| 811 | +# python3 -c "from tests.test_msg_ethereum_clear_signing import _v2_transfer_blob; \ |
| 812 | +# import hashlib; b,_=_v2_transfer_blob(); print(len(b), hashlib.sha256(b).hexdigest())" |
| 813 | +V2_BODY_SNAPSHOT = ( |
| 814 | + 64, '01a24001460f8a69684f3d2a10f75b14e7449d8912a3833f7f8758e8fccadc05') |
| 815 | + |
| 816 | + |
699 | 817 | # ═══════════════════════════════════════════════════════════════════════ |
700 | 818 | # Device tests — require KeepKey connected with test firmware |
701 | 819 | # ═══════════════════════════════════════════════════════════════════════ |
@@ -1102,6 +1220,61 @@ def test_load_signer_key_id_out_of_range_rejected(self): |
1102 | 1220 | alias=CI_SIGNER_ALIAS) |
1103 | 1221 |
|
1104 | 1222 |
|
| 1223 | +class TestClearSignV2Device(common.KeepKeyTest): |
| 1224 | + """Device integration for v2 (static schema) blobs. |
| 1225 | +
|
| 1226 | + A v2 blob attests only the decode schema; the device decodes the argument |
| 1227 | + values from the calldata it signs. This exercises the full round-trip: load |
| 1228 | + signer -> send v2 metadata -> sign a matching transfer() tx -> the signature |
| 1229 | + recovers to this device's signer over the tx digest (so the who/what/why |
| 1230 | + shown was bound to the exact tx, with no committed tx_hash). |
| 1231 | +
|
| 1232 | + v2 (METADATA_VERSION_SCHEMA) lands in the in-progress 7.15.0 line, so this |
| 1233 | + runs against the develop firmware alongside the v1 clear-sign device tests. |
| 1234 | + """ |
| 1235 | + |
| 1236 | + V2_FIRMWARE = "7.15.0" |
| 1237 | + |
| 1238 | + def setUp(self): |
| 1239 | + super().setUp() |
| 1240 | + self.requires_firmware(self.V2_FIRMWARE) |
| 1241 | + self.requires_message("EthereumTxMetadata") |
| 1242 | + self.requires_message("LoadClearsignSigner") |
| 1243 | + self.setup_mnemonic_nopin_nopassphrase() |
| 1244 | + self.client.load_clearsign_signer( |
| 1245 | + key_id=TEST_KEY_ID, pubkey=test_signer_compressed_pubkey(), |
| 1246 | + alias=CI_SIGNER_ALIAS) |
| 1247 | + self._drop_setup_screenshots() |
| 1248 | + |
| 1249 | + def test_v2_transfer_decodes_signs_and_recovers(self): |
| 1250 | + self.client.apply_policy("AdvancedMode", 0) |
| 1251 | + self._drop_setup_screenshots() |
| 1252 | + n = parse_path(DEVICE_PATH) |
| 1253 | + chain_id, nonce, gas_price, gas_limit, value = 1, 3, 20000000000, 250000, 0 |
| 1254 | + # transfer(to=VITALIK, amount=1.5 USDC) — the device decodes both from |
| 1255 | + # the calldata using the v2 schema (address word + token-amount word). |
| 1256 | + args = [ |
| 1257 | + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, |
| 1258 | + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, |
| 1259 | + ] |
| 1260 | + data = schema_calldata(ERC20_TRANSFER_SELECTOR, args) |
| 1261 | + _, blob = _v2_transfer_blob() |
| 1262 | + |
| 1263 | + resp = self.client.ethereum_send_tx_metadata( |
| 1264 | + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) |
| 1265 | + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) |
| 1266 | + |
| 1267 | + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( |
| 1268 | + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, |
| 1269 | + to=USDC_ADDRESS, value=value, data=data, chain_id=chain_id) |
| 1270 | + self.assertIsNotNone(sig_r) |
| 1271 | + self.assertIsNotNone(sig_s) |
| 1272 | + tx_hash = eth_sighash_legacy(nonce, gas_price, gas_limit, USDC_ADDRESS, |
| 1273 | + value, data, chain_id) |
| 1274 | + signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) |
| 1275 | + self.assertEqual(signer, self.client.ethereum_get_address(n)) |
| 1276 | + |
| 1277 | + |
1105 | 1278 | # ═══════════════════════════════════════════════════════════════════════ |
1106 | 1279 | # Dynamically generate one full-confirm device test per CLEARSIGN_FLOWS |
1107 | 1280 | # entry (mirrors keepkey-sdk tests/evm-clearsign): every real-world flow a |
|
0 commit comments