Skip to content

Commit 5307888

Browse files
BitHighlanderclaude
andcommitted
clearsign v2: static-schema serializer, tests, and PDF report coverage
Adds the python-keepkey test-framework support for firmware v2 (static schema, METADATA_VERSION_SCHEMA): a blob that attests only the decode schema (no tx_hash, no arg values) so it can be signed once offline — the device decodes the arg values from the calldata it signs. - keepkeylib/signed_metadata.py: serialize_schema_metadata() (v2 wire format) + schema_calldata() (ABI fixed-word calldata a v2 schema decodes). - tests: TestClearSignV2SchemaOffline (7 offline byte-format tests incl. a frozen body snapshot as a drift gate) + TestClearSignV2Device (on-device decode+sign+ recover, gated requires_firmware('7.16.0') so it skips until a v2 firmware ships). - report: VS1-VS5 in the EVM Clear-Signing section documenting v2 (no tx_hash, static decimals/symbol, frozen format, fixed-word scope, on-device round-trip). Mirrors firmware feat/clearsign-static-schema (keepkey-firmware PR #284). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3d20db3 commit 5307888

3 files changed

Lines changed: 323 additions & 0 deletions

File tree

keepkeylib/signed_metadata.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,111 @@ def serialize_metadata(
246246
return bytes(buf)
247247

248248

249+
# ── v2: static schema (no tx_hash, no values; device decodes calldata) ──
250+
#
251+
# METADATA_VERSION_SCHEMA blobs attest only HOW to decode a curated
252+
# (chainId, contract, selector): the method label and, per argument, a name +
253+
# display format (+ static decimals/symbol for token amounts). They carry NO
254+
# tx_hash and NO argument values — the device decodes the values from the exact
255+
# calldata it is about to sign. Signed once, OFFLINE; no per-tx signer.
256+
#
257+
# Firmware format (parse_v2_args in lib/firmware/signed_metadata.c):
258+
# version(1)=0x02 + chain_id(4 BE) + contract(20) + selector(4) +
259+
# method_len(2 BE) + method + num_args(1) +
260+
# [per arg: name_len(1) + name + display_format(1) +
261+
# (if TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol)] +
262+
# classification(1) + timestamp(4 BE) + key_id(1) + signature(64) + recovery(1)
263+
#
264+
# Supported display formats (fixed single ABI word at offset 4 + 32*i):
265+
# ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT.
266+
METADATA_VERSION_SCHEMA = 2
267+
268+
269+
def serialize_schema_metadata(
270+
chain_id: int,
271+
contract_address: bytes,
272+
selector: bytes,
273+
method_name: str,
274+
args: list,
275+
classification: int = CLASSIFICATION_VERIFIED,
276+
timestamp: int = None,
277+
key_id: int = 3,
278+
) -> bytes:
279+
"""Serialize a v2 (static schema) metadata payload (unsigned).
280+
281+
Args mirror serialize_metadata(), minus tx_hash. Each entry of `args` is a
282+
dict: {name, format, [decimals, symbol]} — NO 'value' (the device decodes it
283+
from the calldata). `decimals`/`symbol` are required for TOKEN_AMOUNT and
284+
ignored otherwise. Call sign_metadata() on the result.
285+
"""
286+
if timestamp is None:
287+
timestamp = int(time.time())
288+
289+
assert len(contract_address) == 20
290+
assert len(selector) == 4
291+
assert len(method_name.encode('utf-8')) <= 64
292+
assert len(args) <= 8
293+
294+
buf = bytearray()
295+
buf.append(METADATA_VERSION_SCHEMA)
296+
buf.extend(struct.pack('>I', chain_id))
297+
buf.extend(contract_address)
298+
buf.extend(selector)
299+
300+
name_bytes = method_name.encode('utf-8')
301+
buf.extend(struct.pack('>H', len(name_bytes)))
302+
buf.extend(name_bytes)
303+
304+
buf.append(len(args))
305+
for arg in args:
306+
arg_name = arg['name'].encode('utf-8')
307+
assert len(arg_name) <= 32
308+
buf.append(len(arg_name))
309+
buf.extend(arg_name)
310+
311+
fmt = arg['format']
312+
assert fmt in (ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT,
313+
ARG_FORMAT_TOKEN_AMOUNT), \
314+
'v2 supports only fixed-word ADDRESS/AMOUNT/TOKEN_AMOUNT'
315+
buf.append(fmt)
316+
if fmt == ARG_FORMAT_TOKEN_AMOUNT:
317+
sym = arg['symbol'].encode('ascii')
318+
assert 0 < len(sym) <= 10 and sym.isalnum()
319+
assert 0 <= arg['decimals'] <= 36
320+
buf.append(arg['decimals'])
321+
buf.append(len(sym))
322+
buf.extend(sym)
323+
324+
buf.append(classification)
325+
buf.extend(struct.pack('>I', timestamp))
326+
buf.append(key_id)
327+
328+
return bytes(buf)
329+
330+
331+
def schema_calldata(selector: bytes, args: list) -> bytes:
332+
"""ABI-encode the calldata a v2 schema decodes: selector + one 32-byte head
333+
word per arg. ADDRESS -> left-zero-padded 20-byte address; AMOUNT /
334+
TOKEN_AMOUNT -> big-endian uint256. Used to build a tx whose calldata the
335+
device will decode against a serialize_schema_metadata() blob.
336+
337+
Each arg dict needs 'format' plus a concrete value: 'address' (20 bytes) for
338+
ADDRESS, or 'amount' (int) for AMOUNT/TOKEN_AMOUNT.
339+
"""
340+
data = bytearray(selector)
341+
for arg in args:
342+
fmt = arg['format']
343+
if fmt == ARG_FORMAT_ADDRESS:
344+
addr = arg['address']
345+
assert len(addr) == 20
346+
data.extend(b'\x00' * 12 + addr)
347+
elif fmt in (ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT):
348+
data.extend(int(arg['amount']).to_bytes(32, 'big'))
349+
else:
350+
raise AssertionError('unsupported v2 arg format %r' % fmt)
351+
return bytes(data)
352+
353+
249354
def sign_metadata(payload: bytes, private_key: bytes = None) -> bytes:
250355
"""Sign the canonical binary payload and return the complete signed blob.
251356

scripts/generate-test-report.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,51 @@ def _arg_shown(a):
10281028
'device accepts them; deviate by one byte and it refuses.' % (
10291029
len(CLEARSIGN_FLOWS) if CLEARSIGN_FLOWS else 0),
10301030
[]),
1031+
1032+
# ── v2 static schema (no online signer) ──────────────────────
1033+
# v2 attests only the decode SCHEMA (no tx_hash, no arg values); the
1034+
# DEVICE decodes the argument values from the calldata it signs. This
1035+
# removes the per-tx online signer: the catalog is signed once, offline.
1036+
# Offline format tests run every cycle; the on-device decode test is
1037+
# gated to the release that ships v2 (METADATA_VERSION_SCHEMA).
1038+
('VS1', 'test_msg_ethereum_clear_signing', 'test_layout_has_no_tx_hash',
1039+
'v2 schema blob carries no tx_hash / no values',
1040+
'The v2 (static schema) blob attests only how to decode a curated '
1041+
'(chainId, contract, selector): method + per-arg name/format (+ static '
1042+
'decimals/symbol). It has NO committed tx_hash and NO argument values — '
1043+
'so it can be signed ONCE, offline, and served from a CDN with no hot '
1044+
'key. The device decodes the values itself from the calldata it signs.',
1045+
[]),
1046+
('VS2', 'test_msg_ethereum_clear_signing',
1047+
'test_token_arg_carries_static_decimals_symbol_not_value',
1048+
'v2 token arg = static decimals/symbol, value decoded on-device',
1049+
'A TOKEN_AMOUNT arg encodes the token\'s static decimals + symbol (a '
1050+
'property of the contract), but NOT the amount — the amount is decoded '
1051+
'from the calldata word on-device, then rendered "1.5 USDC".',
1052+
[]),
1053+
('VS3', 'test_msg_ethereum_clear_signing', 'test_frozen_body_snapshot',
1054+
'v2 wire format frozen vs firmware parser',
1055+
'The canonical v2 body\'s length + sha256 are frozen, so the '
1056+
'serializer can never drift from firmware\'s parse_v2_args() undetected '
1057+
'— the same byte-parity discipline the v1 reference vectors use.',
1058+
[]),
1059+
('VS4', 'test_msg_ethereum_clear_signing', 'test_rejects_dynamic_format',
1060+
'v2 scope: fixed-word types only',
1061+
'v2 decodes fixed single ABI words (ADDRESS / AMOUNT / TOKEN_AMOUNT) — '
1062+
'approve/transfer/transferFrom and fixed-arg calls. Dynamic types '
1063+
'(string/bytes/arrays) are rejected by the serializer and fall to the '
1064+
'blind-sign path on-device; a bounded dynamic decoder is future work.',
1065+
[]),
1066+
('VS5', 'test_msg_ethereum_clear_signing',
1067+
'test_v2_transfer_decodes_signs_and_recovers',
1068+
'v2 on-device: decode from calldata, sign, recover',
1069+
'END-TO-END with AdvancedMode OFF: a v2 transfer() schema blob + a real '
1070+
'transfer(to, amount) tx. The device decodes to/amount from the calldata '
1071+
'and clear-signs; the signature recovers to this device\'s signer over '
1072+
'the tx digest — so the who/what/why shown was bound to the exact tx, '
1073+
'with no tx_hash. The offline format tests above pin the wire format '
1074+
'the device decodes.',
1075+
['Clearsign warning', 'v2 decoded transfer to/amount', 'Sign transaction']),
10311076
]),
10321077

10331078
('G', 'Hive', '7.15.0',

tests/test_msg_ethereum_clear_signing.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636

3737
from keepkeylib.signed_metadata import (
3838
serialize_metadata,
39+
serialize_schema_metadata,
40+
schema_calldata,
3941
sign_metadata,
4042
build_test_metadata,
4143
token_amount_value,
@@ -45,6 +47,7 @@
4547
ARG_FORMAT_BYTES,
4648
ARG_FORMAT_STRING,
4749
ARG_FORMAT_TOKEN_AMOUNT,
50+
METADATA_VERSION_SCHEMA,
4851
CLASSIFICATION_VERIFIED,
4952
CLASSIFICATION_OPAQUE,
5053
CLASSIFICATION_MALFORMED,
@@ -696,6 +699,121 @@ def test_catalog_uses_only_hexfree_formats(self):
696699
(flow['key'], arg['name']))
697700

698701

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+
699817
# ═══════════════════════════════════════════════════════════════════════
700818
# Device tests — require KeepKey connected with test firmware
701819
# ═══════════════════════════════════════════════════════════════════════
@@ -1102,6 +1220,61 @@ def test_load_signer_key_id_out_of_range_rejected(self):
11021220
alias=CI_SIGNER_ALIAS)
11031221

11041222

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+
11051278
# ═══════════════════════════════════════════════════════════════════════
11061279
# Dynamically generate one full-confirm device test per CLEARSIGN_FLOWS
11071280
# entry (mirrors keepkey-sdk tests/evm-clearsign): every real-world flow a

0 commit comments

Comments
 (0)