From be0e84d002a883d412ac8fd5e07fd05039bceb77 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 21 Aug 2026 16:34:14 -0500 Subject: [PATCH] feat(eip712): device-driven field streaming for structured typed data Five messages (1704-1708) that let the device drive an EIP-712 walk instead of being handed the document. The device asks for one type definition, or one leaf VALUE, at a time, and hashes each value in the same pass that displays it. The host owns the document; the device holds only the digest stack for the containers currently open. A 10,000-element array costs the same RAM as a 2-element one, and there is no document-size limit to raise later. This replaces Ethereum712TypesValues, which shipped the whole thing as two 2048-byte JSON blobs and was withdrawn in 7.14.2 because its parser could not guarantee the value on screen was the value being hashed. Here that is structural rather than reviewed: a value is displayed and absorbed from the same buffer in the same call, and each member_path is requested exactly once. That last clause is not incidental. Trezor shipped this same protocol with a hole until 2.12.0 -- nothing bound repeated answers for one path to each other, so a host could answer the domain name one way for the summary screen and another for the hashing pass. Requesting each path once closes it by construction rather than by caching around it. ONE deliberate divergence from Trezor and OneKey: arrays. Both describe an array as a field whose entry_type is another EthereumFieldType -- a self-referential message. Trezor can, because core is Python. OneKey does it on nanopb by compiling that one field as a POINTER (PB_ENABLE_MALLOC) and then flattening the pointer chain into a fixed pool to sever the recursion. KeepKey's nanopb is static-allocation only, and a heap inside a signing device is not a liability worth taking on for one field. So array nesting is FLATTENED onto the wire the way Ledger describes it: data_type is always the LEAF type, and array_levels carries the dimensions in written order, 0 for dynamic: uint256 -> UINT, size=32, array_levels=[] address[] -> ADDRESS, array_levels=[0] Person[3] -> STRUCT, struct_name="Person", array_levels=[3] int16[2][][4] -> INT, size=2, array_levels=[2,0,4] Nothing EIP-712 permits is lost and the encoding is bounded, flat and statically sized. Enum values still match Trezor's so a shared host keeps its mapping; ARRAY is reserved and never sent. Values arrive as raw big-endian bytes of the declared width, not JSON. That deletes the whole decimal-parsing step from the device -- and with it the 64-bit integer ceiling that made the old path refuse an unlimited approval, which is the most common permit there is. Validated with protoc 3.5.1 in kktech/firmware:v8. --- messages-ethereum.options | 9 +++ messages-ethereum.proto | 129 ++++++++++++++++++++++++++++++++++++++ messages.proto | 10 +++ 3 files changed, 148 insertions(+) diff --git a/messages-ethereum.options b/messages-ethereum.options index 8a1b3ff3..fa1767f5 100644 --- a/messages-ethereum.options +++ b/messages-ethereum.options @@ -3,3 +3,12 @@ EthereumMetadataAck.display_summary max_size:32 LoadClearsignSigner.pubkey max_size:33 LoadClearsignSigner.alias max_size:32 LoadClearsignSigner.icon max_size:384 + +EthereumSignTypedData.primary_type max_size:80 +EthereumTypedDataStructRequest.name max_size:80 +EthereumTypedDataStructAck.members max_count:32 +EthereumTypedDataStructAck.EthereumStructMember.name max_size:64 +EthereumTypedDataStructAck.EthereumFieldType.struct_name max_size:80 +EthereumTypedDataStructAck.EthereumFieldType.array_levels max_count:4 +EthereumTypedDataValueRequest.member_path max_count:16 +EthereumTypedDataValueAck.value max_size:1024 diff --git a/messages-ethereum.proto b/messages-ethereum.proto index f04424f5..7a708b41 100644 --- a/messages-ethereum.proto +++ b/messages-ethereum.proto @@ -247,3 +247,132 @@ message Ethereum712TypesValues { required string eip712data = 4; // "domain" or "message" json string (up to 2048) required uint32 eip712typevals = 5; // device calculates hash for 1 = domain sep, 2 = message } + +// ── Structured EIP-712, device-driven field streaming ──────────────── +// +// The device drives. It asks for one type definition, or one leaf VALUE, at a +// time, and hashes each value in the same pass that displays it. The host owns +// the document; the device holds only the digest stack for the containers +// currently open, so a 10,000-element array costs the same RAM as a 2-element +// one and there is no document-size limit to raise later. +// +// This replaces Ethereum712TypesValues, which shipped the whole document in two +// 2048-byte JSON blobs and was withdrawn in 7.14.2: its parser could not +// guarantee that the value shown on screen was the value being hashed. Here +// that property is structural -- a value is displayed and absorbed from the +// same buffer in the same call, and each member_path is requested exactly once. +// +// Shapes follow Trezor's where they can, so a host that can drive a Trezor can +// drive a KeepKey with the same traversal logic. ONE deliberate divergence is +// described at EthereumFieldType. + +/** + * Request: Begin structured EIP-712 signing. The device replies with + * EthereumTypedDataStructRequest and drives from there. + * @start + * @next EthereumTypedDataStructRequest + * @next Failure + */ +message EthereumSignTypedData { + repeated uint32 address_n = 1; // BIP-32 path to derive the key from master node + required string primary_type = 2; // primaryType of the message being signed + optional bool metamask_v4_compat = 3 [default = true]; // array-of-struct hashing follows MetaMask v4 +} + +/** + * Response: The device needs this struct's member list before it can hash. + * @next EthereumTypedDataStructAck + */ +message EthereumTypedDataStructRequest { + required string name = 1; // struct name, "EIP712Domain" for the domain +} + +/** + * Request: The member list for the struct the device just asked about, in + * declaration order. Order is part of the signature: it determines encodeType + * and the order encodeData concatenates members. + * @next EthereumTypedDataStructRequest + * @next EthereumTypedDataValueRequest + * @next Failure + */ +message EthereumTypedDataStructAck { + repeated EthereumStructMember members = 1; + + message EthereumStructMember { + required EthereumFieldType type = 1; + required string name = 2; + } + + /** + * DIVERGENCE FROM TREZOR, and the reason for it. + * + * Trezor and OneKey describe an array as a field whose `entry_type` is + * another EthereumFieldType -- a self-referential message. Trezor can do + * that because core is Python. OneKey does it on nanopb by compiling that + * one field as a POINTER, which needs PB_ENABLE_MALLOC, and then flattens + * the pointer chain into a fixed pool to sever the recursion. + * + * KeepKey's nanopb is static-allocation only, and a heap inside a signing + * device is a liability we are not taking on for one field. So the array + * nesting is FLATTENED onto the wire, the way Ledger describes it: + * data_type is always the LEAF type, and array_levels carries the + * dimensions. + * + * array_levels lists the bracket groups in the order they are written in + * the Solidity type string, left to right -- which is the order encodeType + * must reproduce. 0 means a dynamic dimension, N means a fixed one: + * + * uint256 -> data_type=UINT, size=32, array_levels=[] + * address[] -> data_type=ADDRESS, array_levels=[0] + * Person[3] -> data_type=STRUCT, struct_name="Person", + * array_levels=[3] + * int16[2][][4] -> data_type=INT, size=2, array_levels=[2,0,4] + * + * Nothing EIP-712 permits is lost, and the encoding is bounded, flat and + * statically sized. + */ + message EthereumFieldType { + required EthereumDataType data_type = 1; // the LEAF type; never ARRAY + optional uint32 size = 2; // bytesN: N. intN/uintN: N in BYTES, 1..32. + optional string struct_name = 3; // STRUCT: its name, to request in turn + repeated uint32 array_levels = 4; // see above. Empty for a non-array. + } + + // Values match Trezor's enum so a shared host implementation keeps its + // mapping. ARRAY is reserved and never sent -- see EthereumFieldType. + enum EthereumDataType { + UINT = 1; + INT = 2; + BYTES = 3; + STRING = 4; + BOOL = 5; + ADDRESS = 6; + ARRAY = 7; // reserved, unused + STRUCT = 8; + } +} + +/** + * Response: The device needs one leaf value. member_path addresses it: element + * 0 is 0 for the domain and 1 for the message, and the rest index members and + * array elements from there. A struct is never requested as a value -- the + * device walks into it. An array's LENGTH is requested as its own value, big + * endian uint16, before its elements. + * @next EthereumTypedDataValueAck + */ +message EthereumTypedDataValueRequest { + repeated uint32 member_path = 1; +} + +/** + * Request: The raw big-endian bytes of the requested leaf, already the exact + * declared width. Not JSON: the device does no number parsing, so there is no + * integer width ceiling and no decimal-to-binary step that could disagree with + * what the host meant. + * @next EthereumTypedDataValueRequest + * @next EthereumTypedDataSignature + * @next Failure + */ +message EthereumTypedDataValueAck { + required bytes value = 1; +} diff --git a/messages.proto b/messages.proto index 510ab111..9357211c 100644 --- a/messages.proto +++ b/messages.proto @@ -102,6 +102,16 @@ enum MessageType { MessageType_EthereumMetadataAck = 116 [ (wire_out) = true ]; MessageType_LoadClearsignSigner = 117 [ (wire_in) = true ]; + // Structured EIP-712, device-driven field streaming. Allocated in a fresh + // block rather than after the 108-117 Ethereum run: the low range is where + // upstream Trezor allocates too, and keeping clear of it means adopting more + // of their messages later never collides. + MessageType_EthereumSignTypedData = 1704 [ (wire_in) = true ]; + MessageType_EthereumTypedDataStructRequest = 1705 [ (wire_out) = true ]; + MessageType_EthereumTypedDataStructAck = 1706 [ (wire_in) = true ]; + MessageType_EthereumTypedDataValueRequest = 1707 [ (wire_out) = true ]; + MessageType_EthereumTypedDataValueAck = 1708 [ (wire_in) = true ]; + // BIP-85 MessageType_GetBip85Mnemonic = 120 [ (wire_in) = true ]; MessageType_Bip85Mnemonic = 121 [ (wire_out) = true ];