feat(ethereum): clear signing support for nested array and byte32
What changed, and why it matters
This commit adds support in Trezor's Ethereum 'clear signing' feature for two new data shapes: nested arrays (arrays inside arrays) and fixed 32-byte values (bytes32). It also renames an internal helper function from `_request_definitions` to `request_definitions`. The changes are framed as a normal feature addition with no changelog entry. There is no direct evidence in the commit that this fixes a security vulnerability, but any change to transaction parsing logic can affect how the device interprets maliciously crafted data.
Treat as a routine feature commit but include it in any review of Ethereum clear-signing robustness. Verify that the new nested-array offset arithmetic cannot be abused to bypass bounds checks, and confirm that bytes32 values are displayed to the user in a way that prevents confusion with addresses or hashes. No immediate security action is indicated by the diff alone.
Security signals we found
Parsing logic change for externally supplied Ethereum transaction/call data
New fixed-size bytes32 parser added with explicit length check
Nested array support added with relative-offset resolution and bounds checks
Out-of-bounds test cases added for nested arrays
No changelog entry despite user-facing parser change
Evidence from the diff
The patch extends the Ethereum ABI parser used for ERC-7730 clear signing. It adds ABI_BYTES32 to the protobuf enum and wires a new parse_bytes32 parser that returns the first 32 bytes of a 32-byte word. It also enables one level of nested arrays (Array(Array(...))) by handling element.array inside ABIValue.from_field() and adding a recursive _parse_body path in the Array class. Offsets are validated against len(raw_data) and raise OutOfBounds on overflow. The function _request_definitions is renamed to request_definitions and three call sites are updated. Tests cover bytes32 parsing, nested array parsing, and out-of-bounds cases.
Changed components
core/src/apps/ethereum/clear_signing.pycommon/protob/messages-definitions.protocore/src/trezor/enums/EthereumABIType.pycore/src/trezor/enums/__init__.pypython/src/trezorlib/messages.pyrust/trezor-client/src/protos/generated/messages_definitions.rscore/tests/test_apps.ethereum.clear_signing.pyInspect captured patch +144 / −23
diff --git a/common/protob/messages-definitions.proto b/common/protob/messages-definitions.proto
index f310c0c7..09c501f2 100644
--- a/common/protob/messages-definitions.proto
+++ b/common/protob/messages-definitions.proto
@@ -92,6 +92,7 @@ enum EthereumABIType {
ABI_UINT16 = 14;
ABI_UINT8 = 15;
ABI_BOOL = 16;
+ ABI_BYTES32 = 17;
ABI_BYTES = 20;
ABI_STRING = 21;
}
diff --git a/core/src/apps/ethereum/clear_signing.py b/core/src/apps/ethereum/clear_signing.py
index 456bb5f2..02e08549 100644
--- a/core/src/apps/ethereum/clear_signing.py
+++ b/core/src/apps/ethereum/clear_signing.py
@@ -138,6 +138,12 @@ parse_uint16 = _make_uint_parser(16)
parse_uint8 = _make_uint_parser(8)
+def parse_bytes32(raw_data: memoryview) -> Value:
+ if len(raw_data) < 32:
+ raise OutOfBounds
+ return bytes(raw_data[:32])
+
+
def parse_bool(raw_data: memoryview) -> Value:
if len(raw_data) < 32:
raise OutOfBounds
@@ -214,6 +220,8 @@ def _get_parser(t: int, is_dynamic: bool) -> Parser:
return parse_uint8
elif t == T.ABI_BOOL:
return parse_bool
+ elif t == T.ABI_BYTES32:
+ return parse_bytes32
raise InvalidFormatDefinition
@@ -332,7 +340,7 @@ class TokenAmountFormatter(FieldFormatter):
token = defs.get_token(token_address)
if token is UNKNOWN_TOKEN:
if msg.supports_definition_request:
- received_definitions, _ = await _request_definitions(
+ received_definitions, _ = await request_definitions(
msg.chain_id, token_address, func_sig=None
)
if received_definitions is not None:
@@ -436,7 +444,18 @@ class ABIValue:
is_dynamic=False, # Tuples inside Arrays are always parsed as static!
)
)
- raise InvalidFormatDefinition # Array of arrays not supported
+ elif element.array is not None:
+ inner = element.array
+ if inner.atomic is not None:
+ return Array(
+ Array(Atomic(_get_parser(inner.atomic, is_dynamic=False)))
+ )
+ elif inner.dynamic is not None:
+ return Array(
+ Array(Dynamic(_get_parser(inner.dynamic, is_dynamic=True)))
+ )
+ raise InvalidFormatDefinition # deeper nesting not supported
+ raise InvalidFormatDefinition
raise InvalidFormatDefinition
@@ -538,33 +557,39 @@ class Array(ABIValue):
if offset + 32 > len(raw_data):
raise OutOfBounds
array_pointer = int.from_bytes(raw_data[offset : offset + 32], "big")
- if array_pointer + 32 > len(raw_data):
+ return self._parse_body(raw_data, array_pointer), 32
+
+ def _parse_body(self, raw_data: memoryview, array_start: int) -> ListValue:
+ if array_start + 32 > len(raw_data):
raise OutOfBounds
- array_length = int.from_bytes(
- raw_data[array_pointer : array_pointer + 32], "big"
- )
- array_heads_end = array_pointer + 32 + (array_length * 32)
+ array_length = int.from_bytes(raw_data[array_start : array_start + 32], "big")
+ array_heads_end = array_start + 32 + (array_length * 32)
if array_heads_end > len(raw_data):
raise OutOfBounds
value = []
for i in range(array_length):
- p = array_pointer + 32 + (i * 32)
+ p = array_start + 32 + (i * 32)
if p + 32 > len(raw_data):
raise OutOfBounds
if isinstance(self.element_definition, Atomic):
# atomic types are encoded in place
data, _ = self.element_definition.parse(raw_data, p)
+ elif isinstance(self.element_definition, Array):
+ # inner arrays: element head is a relative offset to the inner array body
+ element_pointer = int.from_bytes(raw_data[p : p + 32], "big")
+ inner_array_start = array_start + 32 + element_pointer
+ data = self.element_definition._parse_body(raw_data, inner_array_start)
else:
element_pointer = int.from_bytes(raw_data[p : p + 32], "big")
- element_absolute_pointer = array_pointer + 32 + element_pointer
+ element_absolute_pointer = array_start + 32 + element_pointer
data, _ = self.element_definition.parse(
raw_data, element_absolute_pointer
)
value.append(data)
- return value, 32 # arrays just consume the pointer
+ return value
# https://eips.ethereum.org/EIPS/eip-7730#evm-transaction-container
@@ -775,7 +800,7 @@ class DisplayFormat:
)
-async def _request_definitions(
+async def request_definitions(
chain_id: int, token_address: bytes, func_sig: bytes | None
) -> tuple[Definitions | None, DisplayFormat | None]:
from trezor.messages import EthereumDefinitionAck, EthereumDefinitionRequest
@@ -844,7 +869,7 @@ async def try_confirm(
if display_format is None:
# ... finally request the display format via another call!
if msg.supports_definition_request:
- _, f = await _request_definitions(msg.chain_id, address_bytes, func_sig)
+ _, f = await request_definitions(msg.chain_id, address_bytes, func_sig)
if f:
if f.func_sig == func_sig and f.matches_context(
msg.chain_id, address_bytes
diff --git a/core/src/trezor/enums/EthereumABIType.py b/core/src/trezor/enums/EthereumABIType.py
index b319b2f5..4f995cb6 100644
--- a/core/src/trezor/enums/EthereumABIType.py
+++ b/core/src/trezor/enums/EthereumABIType.py
@@ -19,5 +19,6 @@ ABI_UINT24 = 13
ABI_UINT16 = 14
ABI_UINT8 = 15
ABI_BOOL = 16
+ABI_BYTES32 = 17
ABI_BYTES = 20
ABI_STRING = 21
diff --git a/core/src/trezor/enums/__init__.py b/core/src/trezor/enums/__init__.py
index 590741a8..b9b0880b 100644
--- a/core/src/trezor/enums/__init__.py
+++ b/core/src/trezor/enums/__init__.py
@@ -309,6 +309,7 @@ if TYPE_CHECKING:
ABI_UINT16 = 14
ABI_UINT8 = 15
ABI_BOOL = 16
+ ABI_BYTES32 = 17
ABI_BYTES = 20
ABI_STRING = 21
diff --git a/core/tests/test_apps.ethereum.clear_signing.py b/core/tests/test_apps.ethereum.clear_signing.py
index 44a667a0..d7de03f3 100644
--- a/core/tests/test_apps.ethereum.clear_signing.py
+++ b/core/tests/test_apps.ethereum.clear_signing.py
@@ -16,6 +16,7 @@ if not utils.BITCOIN_ONLY:
ValueOverflow,
parse_address,
parse_bool,
+ parse_bytes32,
parse_string,
parse_uint24,
parse_uint160,
@@ -224,7 +225,9 @@ class TestEthereumClearSigning(unittest.TestCase):
array_length = len([addr1, addr2])
- array_pointer = len(FIVE_RANDOM_BYTES) + 32 # payload start + pointer size
+ array_pointer = (
+ len(FIVE_RANDOM_BYTES) + 32
+ ) # absolute position of body in raw_data
payload = (
to_bytes(array_pointer)
+ to_bytes(array_length)
@@ -310,6 +313,88 @@ class TestEthereumClearSigning(unittest.TestCase):
self.assertEqual(parsed, [(addr1, text1), (addr2, text2)])
self.assertEqual(consumed, 32)
+ def test_bytes32_parsing(self):
+ atomic_bytes32 = Atomic(parse_bytes32)
+
+ b32 = bytes(range(32))
+ data = memoryview(FIVE_RANDOM_BYTES + b32 + SEVEN_RANDOM_BYTES)
+ parsed, consumed = atomic_bytes32.parse(data, len(FIVE_RANDOM_BYTES))
+ self.assertEqual(parsed, b32)
+ self.assertEqual(consumed, 32)
+
+ with self.assertRaises(OutOfBounds):
+ atomic_bytes32.parse(memoryview(b"\x00" * 20), 0)
+
+ def test_array_of_arrays_of_bytes32(self):
+ # bytes32[][] = [[b0, b1], [b2]]
+ nested_array_parser = Array(Array(Atomic(parse_bytes32)))
+
+ b0 = bytes(range(0, 32))
+ b1 = bytes(range(32, 64))
+ b2 = bytes(range(64, 96))
+
+ # ABI layout (offsets are absolute positions within raw_data):
+ # [7]: pointer slot → outer body at 7+32 = 39
+ # [39]: outer length = 2
+ # [71]: rel. offset to inner[0] from heads base (=71): 135-71 = 64
+ # [103]: rel. offset to inner[1] from heads base (=71): 231-71 = 160
+ # [135]: inner[0] length = 2
+ # [167]: inner[0][0] = b0
+ # [199]: inner[0][1] = b1
+ # [231]: inner[1] length = 1
+ # [263]: inner[1][0] = b2
+ payload = (
+ to_bytes(
+ len(SEVEN_RANDOM_BYTES) + 32
+ ) # pointer: absolute raw_data pos of outer body
+ + to_bytes(2) # outer length
+ + to_bytes(64) # rel. offset → inner[0]
+ + to_bytes(160) # rel. offset → inner[1]
+ + to_bytes(2) # inner[0] length
+ + b0
+ + b1
+ + to_bytes(1) # inner[1] length
+ + b2
+ )
+ data = memoryview(SEVEN_RANDOM_BYTES + payload + FIVE_RANDOM_BYTES)
+
+ parsed, consumed = nested_array_parser.parse(data, len(SEVEN_RANDOM_BYTES))
+ self.assertEqual(parsed, [[b0, b1], [b2]])
+ self.assertEqual(consumed, 32)
+
+ def test_array_of_arrays_out_of_bounds(self):
+ nested_array_parser = Array(Array(Atomic(parse_bytes32)))
+
+ # outer array pointer points beyond the data
+ payload = to_bytes(9999)
+ with self.assertRaises(OutOfBounds):
+ nested_array_parser.parse(memoryview(payload), 0)
+
+ # inner array length overruns the data — test both sides of the border
+
+ # border valid: inner[0] length = 0 → empty inner array, parses as [[]]
+ # pointer = len(SEVEN_RANDOM_BYTES) + 32 = absolute raw_data pos of outer body
+ payload_ok = (
+ to_bytes(len(SEVEN_RANDOM_BYTES) + 32) # pointer to outer body
+ + to_bytes(1) # outer length = 1
+ + to_bytes(32) # rel. offset to inner[0] from heads base
+ + to_bytes(0) # inner[0] length = 0 → empty
+ )
+ data = memoryview(SEVEN_RANDOM_BYTES + payload_ok + FIVE_RANDOM_BYTES)
+ parsed, consumed = nested_array_parser.parse(data, len(SEVEN_RANDOM_BYTES))
+ self.assertEqual(parsed, [[]])
+ self.assertEqual(consumed, 32)
+
+ # border invalid: inner[0] length = 1 but no element data follows
+ payload_fail = (
+ to_bytes(32) # pointer to outer body (no prefix, absolute pos = 32)
+ + to_bytes(1) # outer length = 1
+ + to_bytes(32) # rel. offset to inner[0] from heads base
+ + to_bytes(1) # inner[0] length = 1 — one element claimed but no data
+ )
+ with self.assertRaises(OutOfBounds):
+ nested_array_parser.parse(memoryview(payload_fail), 0)
+
if __name__ == "__main__":
unittest.main()
diff --git a/python/src/trezorlib/messages.py b/python/src/trezorlib/messages.py
index 7b8a1ef7..a56eeae8 100644
--- a/python/src/trezorlib/messages.py
+++ b/python/src/trezorlib/messages.py
@@ -350,6 +350,7 @@ class EthereumABIType(IntEnum):
ABI_UINT16 = 14
ABI_UINT8 = 15
ABI_BOOL = 16
+ ABI_BYTES32 = 17
ABI_BYTES = 20
ABI_STRING = 21
diff --git a/rust/trezor-client/src/protos/generated/messages_definitions.rs b/rust/trezor-client/src/protos/generated/messages_definitions.rs
index 5478f856..ef46255a 100644
--- a/rust/trezor-client/src/protos/generated/messages_definitions.rs
+++ b/rust/trezor-client/src/protos/generated/messages_definitions.rs
@@ -2426,6 +2426,8 @@ pub enum EthereumABIType {
ABI_UINT8 = 15,
// @@protoc_insertion_point(enum_value:hw.trezor.messages.definitions.EthereumABIType.ABI_BOOL)
ABI_BOOL = 16,
+ // @@protoc_insertion_point(enum_value:hw.trezor.messages.definitions.EthereumABIType.ABI_BYTES32)
+ ABI_BYTES32 = 17,
// @@protoc_insertion_point(enum_value:hw.trezor.messages.definitions.EthereumABIType.ABI_BYTES)
ABI_BYTES = 20,
// @@protoc_insertion_point(enum_value:hw.trezor.messages.definitions.EthereumABIType.ABI_STRING)
@@ -2458,6 +2460,7 @@ impl ::protobuf::Enum for EthereumABIType {
14 => ::std::option::Option::Some(EthereumABIType::ABI_UINT16),
15 => ::std::option::Option::Some(EthereumABIType::ABI_UINT8),
16 => ::std::option::Option::Some(EthereumABIType::ABI_BOOL),
+ 17 => ::std::option::Option::Some(EthereumABIType::ABI_BYTES32),
20 => ::std::option::Option::Some(EthereumABIType::ABI_BYTES),
21 => ::std::option::Option::Some(EthereumABIType::ABI_STRING),
_ => ::std::option::Option::None
@@ -2483,6 +2486,7 @@ impl ::protobuf::Enum for EthereumABIType {
"ABI_UINT16" => ::std::option::Option::Some(EthereumABIType::ABI_UINT16),
"ABI_UINT8" => ::std::option::Option::Some(EthereumABIType::ABI_UINT8),
"ABI_BOOL" => ::std::option::Option::Some(EthereumABIType::ABI_BOOL),
+ "ABI_BYTES32" => ::std::option::Option::Some(EthereumABIType::ABI_BYTES32),
"ABI_BYTES" => ::std::option::Option::Some(EthereumABIType::ABI_BYTES),
"ABI_STRING" => ::std::option::Option::Some(EthereumABIType::ABI_STRING),
_ => ::std::option::Option::None
@@ -2507,6 +2511,7 @@ impl ::protobuf::Enum for EthereumABIType {
EthereumABIType::ABI_UINT16,
EthereumABIType::ABI_UINT8,
EthereumABIType::ABI_BOOL,
+ EthereumABIType::ABI_BYTES32,
EthereumABIType::ABI_BYTES,
EthereumABIType::ABI_STRING,
];
@@ -2537,8 +2542,9 @@ impl ::protobuf::EnumFull for EthereumABIType {
EthereumABIType::ABI_UINT16 => 14,
EthereumABIType::ABI_UINT8 => 15,
EthereumABIType::ABI_BOOL => 16,
- EthereumABIType::ABI_BYTES => 17,
- EthereumABIType::ABI_STRING => 18,
+ EthereumABIType::ABI_BYTES32 => 17,
+ EthereumABIType::ABI_BYTES => 18,
+ EthereumABIType::ABI_STRING => 19,
};
Self::enum_descriptor().value_by_index(index)
}
@@ -2741,7 +2747,7 @@ static file_descriptor_proto_data: &'static [u8] = b"\
\x20\x03(\x0b28.hw.trezor.messages.definitions.EthereumERC7730FieldInfoR\
\x10fieldDefinitions*i\n\x0eDefinitionType\x12\x14\n\x10ETHEREUM_NETWORK\
\x10\0\x12\x12\n\x0eETHEREUM_TOKEN\x10\x01\x12\x10\n\x0cSOLANA_TOKEN\x10\
- \x02\x12\x1b\n\x17ETHEREUM_DISPLAY_FORMAT\x10\x03*\xc4\x02\n\x0fEthereum\
+ \x02\x12\x1b\n\x17ETHEREUM_DISPLAY_FORMAT\x10\x03*\xd5\x02\n\x0fEthereum\
ABIType\x12\x0f\n\x0bABI_ADDRESS\x10\0\x12\x0f\n\x0bABI_UINT256\x10\x01\
\x12\x0f\n\x0bABI_UINT248\x10\x02\x12\x0f\n\x0bABI_UINT160\x10\x03\x12\
\x0f\n\x0bABI_UINT128\x10\x04\x12\x0f\n\x0bABI_UINT120\x10\x05\x12\x0f\n\
@@ -2749,13 +2755,14 @@ static file_descriptor_proto_data: &'static [u8] = b"\
T72\x10\x08\x12\x0e\n\nABI_UINT64\x10\t\x12\x0e\n\nABI_UINT48\x10\n\x12\
\x0e\n\nABI_UINT40\x10\x0b\x12\x0e\n\nABI_UINT32\x10\x0c\x12\x0e\n\nABI_\
UINT24\x10\r\x12\x0e\n\nABI_UINT16\x10\x0e\x12\r\n\tABI_UINT8\x10\x0f\
- \x12\x0c\n\x08ABI_BOOL\x10\x10\x12\r\n\tABI_BYTES\x10\x14\x12\x0e\n\nABI\
- _STRING\x10\x15*\x85\x01\n!EthereumERC7730FieldFormatterType\x12\x1a\n\
- \x16FORMATTER_ADDRESS_NAME\x10\0\x12\x14\n\x10FORMATTER_AMOUNT\x10\x01\
- \x12\x1a\n\x16FORMATTER_TOKEN_AMOUNT\x10\x02\x12\x12\n\x0eFORMATTER_UNIT\
- \x10\x03*;\n\x1cEthereumERC7730ContainerPath\x12\x08\n\x04FROM\x10\x01\
- \x12\t\n\x05VALUE\x10\x02\x12\x06\n\x02TO\x10\x03B?\n#com.satoshilabs.tr\
- ezor.lib.protobufB\x18TrezorMessageDefinitions\
+ \x12\x0c\n\x08ABI_BOOL\x10\x10\x12\x0f\n\x0bABI_BYTES32\x10\x11\x12\r\n\
+ \tABI_BYTES\x10\x14\x12\x0e\n\nABI_STRING\x10\x15*\x85\x01\n!EthereumERC\
+ 7730FieldFormatterType\x12\x1a\n\x16FORMATTER_ADDRESS_NAME\x10\0\x12\x14\
+ \n\x10FORMATTER_AMOUNT\x10\x01\x12\x1a\n\x16FORMATTER_TOKEN_AMOUNT\x10\
+ \x02\x12\x12\n\x0eFORMATTER_UNIT\x10\x03*;\n\x1cEthereumERC7730Container\
+ Path\x12\x08\n\x04FROM\x10\x01\x12\t\n\x05VALUE\x10\x02\x12\x06\n\x02TO\
+ \x10\x03B?\n#com.satoshilabs.trezor.lib.protobufB\x18TrezorMessageDefini\
+ tions\
";
/// `FileDescriptorProto` object which was a source for this generated file
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.