From 97105c482efbe5e141fbe90131e32e223f4bf0a1 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 04:00:12 -0500 Subject: [PATCH 01/15] feat(erc7730): compile tokenAmount, addressName, containers and constants Phase A of the ERC-7730 formatter plan. The device now executes tokenAmount (value, token, threshold, message, native aliases), addressName, @.from/@.to (calldata only) and signed constants, and type-checks every argument at preload. Mirror that table, including the value classes: amount and threshold are integers, token and addressName values are addresses, raw literals are integers, addresses or strings, and an alias set names at most four addresses. The registry test now asserts 812 signable formats (from 92). The device still refuses addressName and tokenAmount over bytes32/uint256 words that pack an address or an encrypted amount. --- keepkeylib/erc7730_compiler.py | 82 +++++++++++++++++++++++++++++----- tests/test_erc7730_compiler.py | 19 ++++---- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 939298d4..e432195e 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -974,12 +974,38 @@ def depth(node): DEVICE_CAPABILITIES = { "display_opcodes": frozenset((1, 4, 10)), # formatter kind -> (argument role -> permitted sources, required roles) - "formatters": {1: ({1: frozenset((1,))}, frozenset((1,)))}, - "path_sources": frozenset((1,)), - "containers": frozenset(), + "formatters": { + 1: ({1: frozenset((1,))}, frozenset((1,))), # raw + 3: ({1: frozenset((1,)), 2: frozenset((1,)), # tokenAmount + 7: frozenset((2,)), 8: frozenset((3,)), 22: frozenset((2,))}, + frozenset((1, 2))), + 10: ({1: frozenset((1,))}, frozenset((1,))), # addressName + }, + "path_sources": frozenset((1, 2, 3)), + # @.from and @.to, calldata definitions only + "containers": frozenset((1, 2)), "path_step_opcodes": frozenset((1,)), "conditions": False, + "alias_set_max": 4, } +# Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. +CLASS_UINT, CLASS_ADDRESS, CLASS_STRING_REF, CLASS_ALIAS_SET = 1, 3, 8, 9 + + +def _value_allowed(kind, role, cls): + """Mirror of erc7730_cap_value().""" + if not cls: + return False + if kind == 1 and role == 1: + return cls <= CLASS_STRING_REF + if kind == 10 and role == 1: + return cls == CLASS_ADDRESS + if kind == 3: + return {1: CLASS_UINT, 7: CLASS_UINT, 2: CLASS_ADDRESS, + 22: CLASS_ALIAS_SET}.get(role) == cls + return False + + MAX_ARRAY_ELEMENTS = 64 @@ -999,24 +1025,25 @@ def _program_sections(program): def _abi_walk(nodes, steps): - """Mirror of the firmware's preload walk; steps are (opcode, index).""" + """Mirror of the firmware's preload walk; steps are (opcode, index). + Returns (refusal, leaf kind).""" node = 0 for opcode, index in steps: if node >= len(nodes): - return "path leaves the ABI" + return "path leaves the ABI", 0 kind, _, first, count, length = nodes[node] if kind == 8 and opcode == 1 and count and 0 <= index < count: node = first + index elif kind == 9 and opcode in (1, 2): limit = MAX_ARRAY_ELEMENTS if length == ABSENT else length if not -limit <= index < limit: - return "path indexes beyond the array" + return "path indexes beyond the array", 0 node = first else: - return "path does not name an ABI member" + return "path does not name an ABI member", 0 if node >= len(nodes) or nodes[node][0] > 7: - return "path does not end at a value" - return None + return "path does not end at a value", 0 + return None, nodes[node][0] def device_refusal(program, capabilities=DEVICE_CAPABILITIES): @@ -1028,6 +1055,24 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): nodes = [struct.unpack(">BHHHH", abi[2 + 9 * i:11 + 9 * i]) for i in range(u16(abi, 0))] + literal_table = sections.get(4, b"\0\0") + literal_classes = [] + at = 2 + for _ in range(u16(literal_table, 0)): + kind, length = literal_table[at], u16(literal_table, at + 1) + value = literal_table[at + 3:at + 3 + length] + at += 3 + length + members = u16(value, 0) if kind == 9 else 0 + literal_classes.append( + {1: CLASS_UINT, 4: CLASS_STRING_REF, 5: CLASS_ADDRESS}.get(kind) or + (CLASS_ALIAS_SET if kind == 9 and + 0 < members <= capabilities.get("alias_set_max", 0) else 0)) + + def literal_class(index): + return literal_classes[index] if index < len(literal_classes) else 0 + + calldata = program[7] == 1 + path_classes = [] paths = sections.get(3, b"\0\0") at = 2 for _ in range(u16(paths, 0)): @@ -1035,8 +1080,13 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): at += 4 if source not in capabilities["path_sources"]: return "path source %d is not executed" % source - if source == 2 and index not in capabilities["containers"]: + if source == 2 and (index not in capabilities["containers"] or + not calldata): return "container %d is not executed" % index + if source == 3 and index >= 64: + return "path names a literal beyond the table" + path_classes.append(CLASS_ADDRESS if source == 2 else + ("literal", index) if source == 3 else None) steps = [] for _ in range(count): opcode = paths[at] @@ -1052,9 +1102,10 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): flags = paths[at] at += 1 + 4 * bin(flags).count("1") if source == 1: - reason = _abi_walk(nodes, steps) + reason, leaf = _abi_walk(nodes, steps) if reason: return reason + path_classes[-1] = leaf conditions = sections.get(5, b"\0\0") if u16(conditions, 0) and not capabilities["conditions"]: @@ -1071,10 +1122,19 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): seen = set() for _ in range(argc): role, source = formatters[at], formatters[at + 1] + index = u16(formatters, at + 2) at += 4 if source not in roles.get(role, ()): return "formatter kind %d argument role %d is not executed" % ( kind, role) + if source != 3: + cls = (literal_class(index) if source == 2 else + path_classes[index] if index < len(path_classes) else 0) + if isinstance(cls, tuple): + cls = literal_class(cls[1]) + if not _value_allowed(kind, role, cls): + return ("formatter kind %d argument role %d has the wrong " + "type" % (kind, role)) seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index c6219af5..a1be3045 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -168,8 +168,7 @@ def test_compiles_deterministic_canonical_calldata_program(): assert set(sections) == {1, 2, 3, 6, 7, 8, 9} assert hashlib.sha256(first).digest() == hashlib.sha256(second).digest() assert len(first) < 16384 - _firmware_validate(first, - "formatter kind 3 is not executed") + _firmware_validate(first) def test_compiles_official_uniswap_tuple_fixture_through_firmware(): @@ -204,7 +203,7 @@ def test_compiles_official_uniswap_tuple_fixture_through_firmware(): assert fixtures[1]["txHash"] == ( "0xb25281abb3e6bbfe18c746187522c2e915aa02fdb8175082005340e00c1f0b30") _firmware_validate(compiled, - "formatter kind 3 is not executed") + "formatter kind 7 is not executed") def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): @@ -231,7 +230,7 @@ def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): fixture["to"], network_records=[(1, "Ethereum", "ETH", 18)]) assert compiled[38:42].hex() == expected["selector"] _firmware_validate(compiled, - "formatter kind 10 is not executed") + "formatter kind 2 is not executed") def test_compiles_array_iteration_separator_and_optional_visibility(): @@ -388,7 +387,7 @@ def test_compiles_nested_field_group_with_balanced_links(): assert int.from_bytes(instructions[1][6:8], "big") == 4 assert int.from_bytes(instructions[4][2:4], "big") == 1 _firmware_validate(compiled, - "formatter kind 10 is not executed") + "display opcode 5 is not executed") def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): @@ -436,9 +435,11 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): # Formats the device can fully sign with this firmware's capability table. # Each later phase of the ERC-7730 formatter plan raises this number # (docs/security/HANDOFF-ERC7730-715-FORMATTERS.md in keepkey-firmware). -# The plan estimated 94; two 1inch increaseEpoch formats show a raw field read -# from a container path (@.from), which the runtime does not capture. -REGISTRY_SIGNABLE = 92 +# Phase 0 signed 92 (raw fields only). Phase A adds tokenAmount, addressName, +# @.from/@.to and signed constants: 812. It refuses addressName and +# tokenAmount over bytes32/uint256 words that pack an address or an encrypted +# amount, rather than reinterpret bytes the calldata does not say are one. +REGISTRY_SIGNABLE = 812 def test_official_registry_all_calldata_formats_reach_firmware(): @@ -518,4 +519,4 @@ def test_compiles_official_uniswap_eip712_fixture_through_firmware(): # deployment + name/chain/contract domain facts + token + network assert int.from_bytes(binding[:2], "big") == 6 _firmware_validate(compiled, - "formatter kind 3 is not executed") + "formatter kind 5 is not executed") From e105faff62fc9406fa06231350c0db933d6af101 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 04:06:23 -0500 Subject: [PATCH 02/15] feat(erc7730): compile the interpolated intent the device now shows Phase B. The device executes display opcodes 2 and 3 as one run directly after the plain intent, showing each fragment and value as a numbered part. Mirror the opcodes and the run rule, and trim fragment edges: each part is its own screen, and the device escapes edge spaces. The registry test now asserts 954 signable formats. --- keepkeylib/erc7730_compiler.py | 25 ++++++++++++++++++------- tests/test_erc7730_compiler.py | 3 ++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index e432195e..33ff2583 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -476,19 +476,23 @@ def flatten_fields(items, prefix=None): interpolation_tokens = [] interpolation_values = set() if interpolation is not None: + # The device shows each fragment and value as its own numbered + # screen and escapes edge spaces, so trim fragments here; a + # fragment of spaces alone separates nothing and is dropped. + def add_text(text): + text = text.strip() + if text: + strings.add(text) + interpolation_tokens.append(("text", text)) cursor = 0 for match in re.finditer(r"\{([^{}]+)\}", interpolation): if match.start() > cursor: - text = interpolation[cursor:match.start()] - strings.add(text) - interpolation_tokens.append(("text", text)) + add_text(interpolation[cursor:match.start()]) interpolation_tokens.append(("value", match.group(1))) interpolation_values.add(normalized_path(match.group(1))) cursor = match.end() if cursor < len(interpolation): - text = interpolation[cursor:] - strings.add(text) - interpolation_tokens.append(("text", text)) + add_text(interpolation[cursor:]) for record in self.token_records: strings.add(record[2]) for record in self.network_records: @@ -972,7 +976,8 @@ def depth(node): # program outside it at preload, before the first screen. Widen this only # together with the firmware table. DEVICE_CAPABILITIES = { - "display_opcodes": frozenset((1, 4, 10)), + # 2 and 3 (interpolated intent) only as one run directly after the intent + "display_opcodes": frozenset((1, 2, 3, 4, 10)), # formatter kind -> (argument role -> permitted sources, required roles) "formatters": { 1: ({1: frozenset((1,))}, frozenset((1,))), # raw @@ -1140,6 +1145,7 @@ def literal_class(index): return "formatter kind %d lacks a required argument" % kind displays = sections.get(7, b"\0\0") + run_closed = False for pc in range(u16(displays, 0)): opcode, _, a, b, c = struct.unpack( ">BBHHH", displays[2 + 8 * pc:10 + 8 * pc]) @@ -1147,6 +1153,11 @@ def literal_class(index): return "display opcode %d is not executed" % opcode if (opcode == 1) != (pc == 0): return "the intent must be the first display instruction only" + if pc and opcode in (2, 3): + if run_closed: + return "interpolated intent must directly follow the intent" + elif pc: + run_closed = True if opcode == 4 and c != ABSENT and not capabilities["conditions"]: return "display conditions are not executed" return None diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index a1be3045..3587df16 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -439,7 +439,8 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): # @.from/@.to and signed constants: 812. It refuses addressName and # tokenAmount over bytes32/uint256 words that pack an address or an encrypted # amount, rather than reinterpret bytes the calldata does not say are one. -REGISTRY_SIGNABLE = 812 +# Phase B adds the interpolated intent, shown as numbered parts: 954. +REGISTRY_SIGNABLE = 954 def test_official_registry_all_calldata_formats_reach_firmware(): From 4b41809e0060b257b798c36fd95aa8e4c6f73d7b Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 04:20:07 -0500 Subject: [PATCH 03/15] feat(erc7730): compile amount, nftName, date, duration, unit and enum Phase C. Mirror the device's new formatters and their argument classes: a date encoding must be the string "timestamp" or "blockheight", unit decimals a one-byte literal and the prefix a flag, an enum map at most 16 entries, an NFT collection an address, and @.value an unsigned value. The registry test now asserts 1,138 signable formats. --- keepkeylib/erc7730_compiler.py | 106 ++++++++++++++++++++++++--------- tests/test_erc7730_compiler.py | 15 ++--- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 33ff2583..dc61c77c 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -975,40 +975,66 @@ def depth(node): # include/keepkey/firmware/erc7730_capabilities.h. The device refuses every # program outside it at preload, before the first screen. Widen this only # together with the firmware table. +_PATH, _LITERAL, _STRING = frozenset((1,)), frozenset((2,)), frozenset((3,)) DEVICE_CAPABILITIES = { # 2 and 3 (interpolated intent) only as one run directly after the intent "display_opcodes": frozenset((1, 2, 3, 4, 10)), # formatter kind -> (argument role -> permitted sources, required roles) "formatters": { - 1: ({1: frozenset((1,))}, frozenset((1,))), # raw - 3: ({1: frozenset((1,)), 2: frozenset((1,)), # tokenAmount - 7: frozenset((2,)), 8: frozenset((3,)), 22: frozenset((2,))}, - frozenset((1, 2))), - 10: ({1: frozenset((1,))}, frozenset((1,))), # addressName + 1: ({1: _PATH}, frozenset((1,))), # raw + 2: ({1: _PATH}, frozenset((1,))), # amount + 3: ({1: _PATH, 2: _PATH, 7: _LITERAL, 8: _STRING, # tokenAmount + 22: _LITERAL}, frozenset((1, 2))), + 4: ({1: _PATH, 3: _PATH}, frozenset((1, 3))), # nftName + 5: ({1: _PATH, 9: _STRING}, frozenset((1,))), # date + 6: ({1: _PATH}, frozenset((1,))), # duration + 7: ({1: _PATH, 4: _LITERAL, 5: _STRING, 6: _LITERAL}, # unit + frozenset((1, 5))), + 8: ({1: _PATH, 10: _LITERAL}, frozenset((1, 10))), # enum + 10: ({1: _PATH}, frozenset((1,))), # addressName }, "path_sources": frozenset((1, 2, 3)), - # @.from and @.to, calldata definitions only - "containers": frozenset((1, 2)), + # @.from, @.to and @.value, calldata definitions only + "containers": frozenset((1, 2, 3)), "path_step_opcodes": frozenset((1,)), "conditions": False, "alias_set_max": 4, + "enum_max": 16, } # Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. -CLASS_UINT, CLASS_ADDRESS, CLASS_STRING_REF, CLASS_ALIAS_SET = 1, 3, 8, 9 +(CLASS_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, + CLASS_STRING_REF, CLASS_ALIAS_SET, CLASS_UINT_SMALL, CLASS_DATE_ENCODING, + CLASS_ENUM_MAP, CLASS_FLAG) = 1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13 def _value_allowed(kind, role, cls): """Mirror of erc7730_cap_value().""" if not cls: return False - if kind == 1 and role == 1: - return cls <= CLASS_STRING_REF - if kind == 10 and role == 1: - return cls == CLASS_ADDRESS - if kind == 3: - return {1: CLASS_UINT, 7: CLASS_UINT, 2: CLASS_ADDRESS, - 22: CLASS_ALIAS_SET}.get(role) == cls - return False + unsigned = (CLASS_UINT, CLASS_UINT_SMALL) + rules = { + (1, 1): lambda: cls <= CLASS_STRING_REF or cls == CLASS_UINT_SMALL, + (10, 1): lambda: cls == CLASS_ADDRESS, + (2, 1): lambda: cls == CLASS_UINT, + (6, 1): lambda: cls == CLASS_UINT, + (3, 1): lambda: cls == CLASS_UINT, + (3, 7): lambda: cls in unsigned, + (3, 2): lambda: cls == CLASS_ADDRESS, + (3, 8): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), + (3, 22): lambda: cls == CLASS_ALIAS_SET, + (4, 1): lambda: cls == CLASS_UINT, + (4, 3): lambda: cls == CLASS_ADDRESS, + (5, 1): lambda: cls == CLASS_UINT, + (5, 9): lambda: cls == CLASS_DATE_ENCODING, + (7, 1): lambda: cls == CLASS_UINT, + (7, 4): lambda: cls == CLASS_UINT_SMALL, + (7, 5): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), + (7, 6): lambda: cls == CLASS_FLAG, + (8, 1): lambda: cls in (CLASS_UINT, CLASS_INT, CLASS_BOOL), + (8, 10): lambda: cls == CLASS_ENUM_MAP, + } + rule = rules.get((kind, role)) + return bool(rule and rule()) MAX_ARRAY_ELEMENTS = 64 @@ -1067,15 +1093,33 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): kind, length = literal_table[at], u16(literal_table, at + 1) value = literal_table[at + 3:at + 3 + length] at += 3 + length - members = u16(value, 0) if kind == 9 else 0 - literal_classes.append( - {1: CLASS_UINT, 4: CLASS_STRING_REF, 5: CLASS_ADDRESS}.get(kind) or - (CLASS_ALIAS_SET if kind == 9 and - 0 < members <= capabilities.get("alias_set_max", 0) else 0)) + members = u16(value, 0) if kind in (8, 9) else 0 + if kind == 1: + cls = CLASS_UINT_SMALL if length == 1 else CLASS_UINT + elif kind == 8: + cls = (CLASS_ENUM_MAP if 0 < members <= capabilities.get( + "enum_max", 0) else 0) + elif kind == 9: + cls = (CLASS_ALIAS_SET if 0 < members <= capabilities.get( + "alias_set_max", 0) else 0) + else: + cls = {4: CLASS_STRING_REF, 5: CLASS_ADDRESS, + 6: CLASS_FLAG}.get(kind, 0) + literal_classes.append(cls) def literal_class(index): return literal_classes[index] if index < len(literal_classes) else 0 + string_table = sections.get(1, b"\0\0") + date_strings = set() + at = 2 + for index in range(u16(string_table, 0)): + length = u16(string_table, at) + if string_table[at + 2:at + 2 + length] in (b"timestamp", + b"blockheight"): + date_strings.add(index) + at += 2 + length + calldata = program[7] == 1 path_classes = [] paths = sections.get(3, b"\0\0") @@ -1090,8 +1134,9 @@ def literal_class(index): return "container %d is not executed" % index if source == 3 and index >= 64: return "path names a literal beyond the table" - path_classes.append(CLASS_ADDRESS if source == 2 else - ("literal", index) if source == 3 else None) + path_classes.append( + (CLASS_UINT if index == 3 else CLASS_ADDRESS) if source == 2 else + ("literal", index) if source == 3 else None) steps = [] for _ in range(count): opcode = paths[at] @@ -1132,14 +1177,17 @@ def literal_class(index): if source not in roles.get(role, ()): return "formatter kind %d argument role %d is not executed" % ( kind, role) - if source != 3: + if source == 3: + cls = (CLASS_DATE_ENCODING if index in date_strings else + CLASS_STRING) + else: cls = (literal_class(index) if source == 2 else path_classes[index] if index < len(path_classes) else 0) - if isinstance(cls, tuple): - cls = literal_class(cls[1]) - if not _value_allowed(kind, role, cls): - return ("formatter kind %d argument role %d has the wrong " - "type" % (kind, role)) + if isinstance(cls, tuple): + cls = literal_class(cls[1]) + if not _value_allowed(kind, role, cls): + return ("formatter kind %d argument role %d has the wrong " + "type" % (kind, role)) seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 3587df16..3e50efed 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -202,8 +202,7 @@ def test_compiles_official_uniswap_tuple_fixture_through_firmware(): assert calldata[:4] == compiled[38:42] assert fixtures[1]["txHash"] == ( "0xb25281abb3e6bbfe18c746187522c2e915aa02fdb8175082005340e00c1f0b30") - _firmware_validate(compiled, - "formatter kind 7 is not executed") + _firmware_validate(compiled) def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): @@ -229,8 +228,7 @@ def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): fixture["descriptor"], fixture["signature"], fixture["chainId"], fixture["to"], network_records=[(1, "Ethereum", "ETH", 18)]) assert compiled[38:42].hex() == expected["selector"] - _firmware_validate(compiled, - "formatter kind 2 is not executed") + _firmware_validate(compiled) def test_compiles_array_iteration_separator_and_optional_visibility(): @@ -360,8 +358,7 @@ def test_compiles_interpolated_intent_and_metadata_enum(): assert formatters[2] == 8 literals = sections[4] assert int.from_bytes(literals[:2], "big") == 3 - _firmware_validate(compiled, - "formatter kind 8 is not executed") + _firmware_validate(compiled) def test_compiles_nested_field_group_with_balanced_links(): @@ -440,7 +437,8 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): # tokenAmount over bytes32/uint256 words that pack an address or an encrypted # amount, rather than reinterpret bytes the calldata does not say are one. # Phase B adds the interpolated intent, shown as numbered parts: 954. -REGISTRY_SIGNABLE = 954 +# Phase C adds amount, nftName, date, duration, unit, enum and @.value: 1138. +REGISTRY_SIGNABLE = 1138 def test_official_registry_all_calldata_formats_reach_firmware(): @@ -519,5 +517,4 @@ def test_compiles_official_uniswap_eip712_fixture_through_firmware(): binding = sections[8] # deployment + name/chain/contract domain facts + token + network assert int.from_bytes(binding[:2], "big") == 6 - _firmware_validate(compiled, - "formatter kind 5 is not executed") + _firmware_validate(compiled) From 69e2b459867d2eb1390c4cd50ac4c4a8780f8c4c Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 04:37:28 -0500 Subject: [PATCH 04/15] feat(erc7730): compile groups, single-array iteration and optional fields Phase D. Mirror the device: display opcodes 5-8, the "every element" path step reached through tuples only, iteration over one array at a time in calldata definitions, every field inside it reading that array, and only the "optional" condition (always shown). A path ending in its every-element step is also a value when its element is a leaf (address[] recipients). The registry test now asserts 1,294 signable formats. --- keepkeylib/erc7730_compiler.py | 75 ++++++++++++++++++++++++++++------ tests/test_erc7730_compiler.py | 14 +++---- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index dc61c77c..a150e7ee 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -977,8 +977,9 @@ def depth(node): # together with the firmware table. _PATH, _LITERAL, _STRING = frozenset((1,)), frozenset((2,)), frozenset((3,)) DEVICE_CAPABILITIES = { - # 2 and 3 (interpolated intent) only as one run directly after the intent - "display_opcodes": frozenset((1, 2, 3, 4, 10)), + # 2 and 3 (interpolated intent) only as one run directly after the intent; + # 5/6 groups; 7/8 one iteration at a time, calldata only + "display_opcodes": frozenset((1, 2, 3, 4, 5, 6, 7, 8, 10)), # formatter kind -> (argument role -> permitted sources, required roles) "formatters": { 1: ({1: _PATH}, frozenset((1,))), # raw @@ -996,8 +997,11 @@ def depth(node): "path_sources": frozenset((1, 2, 3)), # @.from, @.to and @.value, calldata definitions only "containers": frozenset((1, 2, 3)), - "path_step_opcodes": frozenset((1,)), - "conditions": False, + # 2: every element, bound to the iteration's current element + "path_step_opcodes": frozenset((1, 2)), + # only "optional" (3), which is always shown + "condition_opcodes": frozenset((3,)), + "conditions": True, "alias_set_max": 4, "enum_max": 16, } @@ -1057,24 +1061,38 @@ def _program_sections(program): def _abi_walk(nodes, steps): """Mirror of the firmware's preload walk; steps are (opcode, index). - Returns (refusal, leaf kind).""" + Returns (refusal, leaf kind or 0 for an iteration path, [] array node).""" node = 0 + indexed = False + array = None for opcode, index in steps: if node >= len(nodes): - return "path leaves the ABI", 0 + return "path leaves the ABI", 0, None kind, _, first, count, length = nodes[node] if kind == 8 and opcode == 1 and count and 0 <= index < count: node = first + index elif kind == 9 and opcode in (1, 2): + if opcode == 2: + if indexed: + return "path indexes an array before iterating", 0, None + array = node + else: + indexed = True limit = MAX_ARRAY_ELEMENTS if length == ABSENT else length if not -limit <= index < limit: - return "path indexes beyond the array", 0 + return "path indexes beyond the array", 0, None node = first else: - return "path does not name an ABI member", 0 - if node >= len(nodes) or nodes[node][0] > 7: - return "path does not end at a value", 0 - return None, nodes[node][0] + return "path does not name an ABI member", 0, None + if node >= len(nodes): + return "path does not end at a value", 0, None + if steps and steps[-1][0] == 2: + # An iteration path: a value too when its element is a leaf. + leaf = nodes[node][0] + return None, (leaf if leaf <= 7 else 0), array + if nodes[node][0] > 7: + return "path does not end at a value", 0, None + return None, nodes[node][0], array def device_refusal(program, capabilities=DEVICE_CAPABILITIES): @@ -1122,6 +1140,8 @@ def literal_class(index): calldata = program[7] == 1 path_classes = [] + path_arrays = [] + iterable = set() paths = sections.get(3, b"\0\0") at = 2 for _ in range(u16(paths, 0)): @@ -1137,6 +1157,7 @@ def literal_class(index): path_classes.append( (CLASS_UINT if index == 3 else CLASS_ADDRESS) if source == 2 else ("literal", index) if source == 3 else None) + path_arrays.append(None) steps = [] for _ in range(count): opcode = paths[at] @@ -1152,18 +1173,29 @@ def literal_class(index): flags = paths[at] at += 1 + 4 * bin(flags).count("1") if source == 1: - reason, leaf = _abi_walk(nodes, steps) + if sum(1 for step in steps if step[0] == 2) > 1: + return "path iterates more than once" + reason, leaf, array = _abi_walk(nodes, steps) if reason: return reason path_classes[-1] = leaf + path_arrays[-1] = array + if steps and steps[-1][0] == 2: + iterable.add(len(path_classes) - 1) conditions = sections.get(5, b"\0\0") if u16(conditions, 0) and not capabilities["conditions"]: return "display conditions are not executed" + for i in range(u16(conditions, 0)): + if conditions[2 + 8 * i] not in capabilities.get("condition_opcodes", + ()): + return "condition opcode %d is not executed" % conditions[2 + 8 * i] formatters = sections.get(6, b"\0\0") + formatter_arrays = [] at = 2 for _ in range(u16(formatters, 0)): + value_array, any_array = None, False kind, argc = formatters[at], formatters[at + 2] at += 3 if kind not in capabilities["formatters"]: @@ -1188,12 +1220,18 @@ def literal_class(index): if not _value_allowed(kind, role, cls): return ("formatter kind %d argument role %d has the wrong " "type" % (kind, role)) + if source == 1 and path_arrays[index] is not None: + any_array = True + if role == 1: + value_array = path_arrays[index] seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind + formatter_arrays.append((value_array, any_array)) displays = sections.get(7, b"\0\0") run_closed = False + iteration = None for pc in range(u16(displays, 0)): opcode, _, a, b, c = struct.unpack( ">BBHHH", displays[2 + 8 * pc:10 + 8 * pc]) @@ -1208,6 +1246,19 @@ def literal_class(index): run_closed = True if opcode == 4 and c != ABSENT and not capabilities["conditions"]: return "display conditions are not executed" + if opcode == 7: + if a not in iterable or iteration is not None or not calldata: + return "iteration is not executed here" + iteration = path_arrays[a] + elif opcode == 8: + iteration = None + elif opcode in (3, 4): + value_array, any_array = formatter_arrays[a if opcode == 3 else b] + if any_array and iteration is None: + return "an iterating value outside an iteration" + if (iteration is not None and value_array is not None and + value_array != iteration): + return "a field reads another array than its iteration" return None diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 3e50efed..d6495f1e 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -260,8 +260,7 @@ def test_compiles_array_iteration_separator_and_optional_visibility(): end = display[26:34] assert int.from_bytes(begin[6:8], "big") == 3 assert int.from_bytes(end[2:4], "big") == 1 - _firmware_validate(compiled, - "path step opcode 2 is not executed") + _firmware_validate(compiled) def test_refuses_nested_array_iteration_the_device_cannot_verify(): @@ -325,7 +324,7 @@ def test_compiles_typed_if_not_in_and_must_match_conditions(): literals = sections[4] assert int.from_bytes(literals[:2], "big") == 5 _firmware_validate(compiled, - "display conditions are not executed") + "condition opcode 7 is not executed") def test_compiles_interpolated_intent_and_metadata_enum(): @@ -383,8 +382,7 @@ def test_compiles_nested_field_group_with_balanced_links(): assert [item[0] for item in instructions] == [1, 5, 4, 4, 6, 10] assert int.from_bytes(instructions[1][6:8], "big") == 4 assert int.from_bytes(instructions[4][2:4], "big") == 1 - _firmware_validate(compiled, - "display opcode 5 is not executed") + _firmware_validate(compiled) def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): @@ -419,8 +417,7 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): count = int.from_bytes(display[:2], "big") opcodes = [display[2 + i * 8] for i in range(count)] assert opcodes == [1, 7, 5, 4, 4, 6, 8, 10] - _firmware_validate(compiled, - "path step opcode 2 is not executed") + _firmware_validate(compiled) DEVICE_LIMITS = ( @@ -438,7 +435,8 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): # amount, rather than reinterpret bytes the calldata does not say are one. # Phase B adds the interpolated intent, shown as numbered parts: 954. # Phase C adds amount, nftName, date, duration, unit, enum and @.value: 1138. -REGISTRY_SIGNABLE = 1138 +# Phase D adds groups, single-array iteration and "optional" fields. +REGISTRY_SIGNABLE = 1294 def test_official_registry_all_calldata_formats_reach_firmware(): From c909a200964b4ebd2f8f55a290a9e11b1b6702e1 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 06:32:29 -0500 Subject: [PATCH 05/15] feat(erc7730): compile embedded calldata with its value and authority Phase E1. Emit amountPath and spenderPath as formatter roles 17 and 18 beside the callee (15), and mirror the device: embedded calldata executes for calldata definitions, shown under a blind-sign warning in 7.15. The registry test now asserts 1,326 signable formats. --- keepkeylib/erc7730_compiler.py | 15 +++++++++++++++ tests/test_erc7730_compiler.py | 5 +++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index a150e7ee..1ccc8024 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -777,6 +777,12 @@ def intern_path(steps): if not callee: raise ValueError("embedded calldata requires calleePath") arguments.append((15, 1, intern_path(_resolve_path(root, callee)))) + # 17: the native value the inner call moves; 18: whose + # authority it runs with. + for role, key in ((17, "amountPath"), (18, "spenderPath")): + if params.get(key): + arguments.append((role, 1, intern_path( + _resolve_path(root, params[key])))) formatter_index = len(formatters) formatters.append((kind, arguments)) condition = ABSENT @@ -993,6 +999,8 @@ def depth(node): frozenset((1, 5))), 8: ({1: _PATH, 10: _LITERAL}, frozenset((1, 10))), # enum 10: ({1: _PATH}, frozenset((1,))), # addressName + 13: ({1: _PATH, 15: _PATH, 17: _PATH, 18: _PATH}, # calldata + frozenset((1, 15))), }, "path_sources": frozenset((1, 2, 3)), # @.from, @.to and @.value, calldata definitions only @@ -1006,6 +1014,7 @@ def depth(node): "enum_max": 16, } # Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. +CLASS_BYTES = 6 (CLASS_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, CLASS_STRING_REF, CLASS_ALIAS_SET, CLASS_UINT_SMALL, CLASS_DATE_ENCODING, CLASS_ENUM_MAP, CLASS_FLAG) = 1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13 @@ -1036,6 +1045,10 @@ def _value_allowed(kind, role, cls): (7, 6): lambda: cls == CLASS_FLAG, (8, 1): lambda: cls in (CLASS_UINT, CLASS_INT, CLASS_BOOL), (8, 10): lambda: cls == CLASS_ENUM_MAP, + (13, 1): lambda: cls == CLASS_BYTES, + (13, 15): lambda: cls == CLASS_ADDRESS, + (13, 17): lambda: cls == CLASS_UINT, + (13, 18): lambda: cls == CLASS_ADDRESS, } rule = rules.get((kind, role)) return bool(rule and rule()) @@ -1227,6 +1240,8 @@ def literal_class(index): seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind + if kind == 13 and not calldata: + return "embedded calldata is executed for calldata only" formatter_arrays.append((value_array, any_array)) displays = sections.get(7, b"\0\0") diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index d6495f1e..99a27e70 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -435,8 +435,9 @@ def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): # amount, rather than reinterpret bytes the calldata does not say are one. # Phase B adds the interpolated intent, shown as numbered parts: 954. # Phase C adds amount, nftName, date, duration, unit, enum and @.value: 1138. -# Phase D adds groups, single-array iteration and "optional" fields. -REGISTRY_SIGNABLE = 1294 +# Phase D adds groups, single-array iteration and "optional" fields: 1294. +# Phase E1 adds embedded calldata, shown under a blind-sign warning: 1326. +REGISTRY_SIGNABLE = 1326 def test_official_registry_all_calldata_formats_reach_firmware(): From f8c8d3102fb5495fc5b88d40c928efc23eff9e14 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 06:44:30 -0500 Subject: [PATCH 06/15] feat(erc7730): answer an unknown embedded call's definition with none Phase E2. When the device asks for an embedded call's definition (recursion_depth >= 1) and the catalog holds none, reply with the empty chunk (offset 0, total_length 0, no data) instead of failing: firmware 7.15 then shows the inner call under a blind-sign warning, and 7.16 rejects it. A top-level lookup that fails is still an error. --- keepkeylib/erc7730.py | 12 +++++++++++- tests/test_erc7730_catalog.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/keepkeylib/erc7730.py b/keepkeylib/erc7730.py index 7a9e88db..f2cbe449 100644 --- a/keepkeylib/erc7730.py +++ b/keepkeylib/erc7730.py @@ -166,7 +166,17 @@ def resolve(self, request): return definition def chunk(self, request): - definition = self.resolve(request) + try: + definition = self.resolve(request) + except KeyError: + if (not request.HasField("recursion_depth") or + request.recursion_depth == 0): + raise + # An embedded call's definition that the catalog does not hold: + # the empty chunk means "none". Firmware 7.15 then shows the + # inner call under a blind-sign warning; 7.16 rejects it. + return ethereum.EthereumClearSignDefinitionChunk( + definition_id=bytes(32), offset=0, total_length=0, data=b"") offset = request.offset length = request.length if length == 0 or length > MAX_CHUNK or offset >= len(definition.envelope): diff --git a/tests/test_erc7730_catalog.py b/tests/test_erc7730_catalog.py index 9501fa17..1f791c8f 100644 --- a/tests/test_erc7730_catalog.py +++ b/tests/test_erc7730_catalog.py @@ -62,6 +62,19 @@ def test_catalog_refuses_unknown_ambiguous_and_malformed_requests(): catalog.add(definition(b"different-envelope")) +def test_catalog_answers_an_unknown_embedded_call_with_none(): + # A top-level lookup the catalog cannot satisfy is an error; an embedded + # call's (recursion_depth set) gets the empty "none" chunk, which firmware + # 7.15 shows under a blind-sign warning and 7.16 rejects. + catalog = erc7730.Catalog((definition(),)) + none = catalog.chunk(request(selector_or_type_hash=b"\0" * 4, + recursion_depth=1)) + assert (none.offset, none.total_length, none.data) == (0, 0, b"") + assert len(none.definition_id) == 32 + with pytest.raises(KeyError): + catalog.chunk(request(selector_or_type_hash=b"\0" * 4)) + + class PreloadClient(object): def __init__(self, envelope): self.envelope = envelope From 75585fe72e0518a3df88fb720e940fa64302e234 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 08:27:59 -0500 Subject: [PATCH 07/15] fix(erc7730): mirror the device's audit-remediation preload checks The compiler's device-capability mirror now refuses what the firmware verifier refuses after the 7b audit: - signer text (labels, threshold messages, unit bases, enum labels) over 64 bytes, and unit decimals over 77; - an embedded call's callee given as a literal, and an embedded call used as an intent value part; - tables beyond the device's limits (alias sets, enum maps). Numeric constants are values of every width, as on the device. The string table is parsed before the literals that reference it. The registry lockstep test still asserts 1,326 signable formats; its comment now says what the count means: preload-accepted, not "runs end to end". --- keepkeylib/erc7730_compiler.py | 73 ++++++++++++++++++++++++---------- tests/test_erc7730_compiler.py | 3 +- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 1ccc8024..495a05d0 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -1012,7 +1012,12 @@ def depth(node): "conditions": True, "alias_set_max": 4, "enum_max": 16, + # signer text shown inside a value's screen, and unit decimals + "signer_text_max": 64, + "unit_decimals_max": 77, } +# The verifier's table limits (erc7730_catalog.c). +TABLE_LIMITS = {1: 96, 2: 64, 3: 64, 4: 64, 5: 32, 6: 64, 7: 64} # Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. CLASS_BYTES = 6 (CLASS_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, @@ -1028,26 +1033,27 @@ def _value_allowed(kind, role, cls): rules = { (1, 1): lambda: cls <= CLASS_STRING_REF or cls == CLASS_UINT_SMALL, (10, 1): lambda: cls == CLASS_ADDRESS, - (2, 1): lambda: cls == CLASS_UINT, - (6, 1): lambda: cls == CLASS_UINT, - (3, 1): lambda: cls == CLASS_UINT, + (2, 1): lambda: cls in unsigned, + (6, 1): lambda: cls in unsigned, + (3, 1): lambda: cls in unsigned, (3, 7): lambda: cls in unsigned, (3, 2): lambda: cls == CLASS_ADDRESS, (3, 8): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), (3, 22): lambda: cls == CLASS_ALIAS_SET, - (4, 1): lambda: cls == CLASS_UINT, + (4, 1): lambda: cls in unsigned, (4, 3): lambda: cls == CLASS_ADDRESS, - (5, 1): lambda: cls == CLASS_UINT, + (5, 1): lambda: cls in unsigned, (5, 9): lambda: cls == CLASS_DATE_ENCODING, - (7, 1): lambda: cls == CLASS_UINT, + (7, 1): lambda: cls in unsigned, (7, 4): lambda: cls == CLASS_UINT_SMALL, (7, 5): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), (7, 6): lambda: cls == CLASS_FLAG, - (8, 1): lambda: cls in (CLASS_UINT, CLASS_INT, CLASS_BOOL), + (8, 1): lambda: cls in (CLASS_UINT, CLASS_UINT_SMALL, CLASS_INT, + CLASS_BOOL), (8, 10): lambda: cls == CLASS_ENUM_MAP, (13, 1): lambda: cls == CLASS_BYTES, (13, 15): lambda: cls == CLASS_ADDRESS, - (13, 17): lambda: cls == CLASS_UINT, + (13, 17): lambda: cls in unsigned, (13, 18): lambda: cls == CLASS_ADDRESS, } rule = rules.get((kind, role)) @@ -1117,13 +1123,34 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): nodes = [struct.unpack(">BHHHH", abi[2 + 9 * i:11 + 9 * i]) for i in range(u16(abi, 0))] + string_table = sections.get(1, b"\0\0") + date_strings = set() + short_strings = set() + at = 2 + for index in range(u16(string_table, 0)): + length = u16(string_table, at) + if length <= capabilities.get("signer_text_max", 128): + short_strings.add(index) + if string_table[at + 2:at + 2 + length] in (b"timestamp", + b"blockheight"): + date_strings.add(index) + at += 2 + length + literal_table = sections.get(4, b"\0\0") literal_classes = [] + decimals_literals = set() at = 2 - for _ in range(u16(literal_table, 0)): + for literal_index in range(u16(literal_table, 0)): kind, length = literal_table[at], u16(literal_table, at + 1) value = literal_table[at + 3:at + 3 + length] at += 3 + length + if (kind == 1 and length == 1 and + value[0] <= capabilities.get("unit_decimals_max", 255)): + decimals_literals.add(literal_index) + if kind == 8: + for entry in range(u16(value, 0)): + if u16(value, 4 + 4 * entry) not in short_strings: + return "an enum label is longer than the device shows" members = u16(value, 0) if kind in (8, 9) else 0 if kind == 1: cls = CLASS_UINT_SMALL if length == 1 else CLASS_UINT @@ -1141,15 +1168,9 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): def literal_class(index): return literal_classes[index] if index < len(literal_classes) else 0 - string_table = sections.get(1, b"\0\0") - date_strings = set() - at = 2 - for index in range(u16(string_table, 0)): - length = u16(string_table, at) - if string_table[at + 2:at + 2 + length] in (b"timestamp", - b"blockheight"): - date_strings.add(index) - at += 2 + length + for kind, limit in TABLE_LIMITS.items(): + if kind in sections and u16(sections[kind], 0) > limit: + return "program table %d exceeds the device limit" % kind calldata = program[7] == 1 path_classes = [] @@ -1233,6 +1254,13 @@ def literal_class(index): if not _value_allowed(kind, role, cls): return ("formatter kind %d argument role %d has the wrong " "type" % (kind, role)) + if source == 3 and role in (5, 8) and index not in short_strings: + return "signer text is longer than the device shows" + if kind == 7 and role == 4 and index not in decimals_literals: + return "unit decimals exceed the device limit" + if (kind == 13 and role == 15 and index < len(path_classes) and + isinstance(path_classes[index], tuple)): + return "an embedded call's callee must come from calldata" if source == 1 and path_arrays[index] is not None: any_array = True if role == 1: @@ -1242,7 +1270,7 @@ def literal_class(index): return "formatter kind %d lacks a required argument" % kind if kind == 13 and not calldata: return "embedded calldata is executed for calldata only" - formatter_arrays.append((value_array, any_array)) + formatter_arrays.append((value_array, any_array, kind == 13)) displays = sections.get(7, b"\0\0") run_closed = False @@ -1268,7 +1296,12 @@ def literal_class(index): elif opcode == 8: iteration = None elif opcode in (3, 4): - value_array, any_array = formatter_arrays[a if opcode == 3 else b] + value_array, any_array, embedded = formatter_arrays[ + a if opcode == 3 else b] + if opcode == 3 and embedded: + return "an embedded call cannot be an intent value" + if opcode == 4 and a not in short_strings: + return "a field label is longer than the device shows" if any_array and iteration is None: return "an iterating value outside an iteration" if (iteration is not None and value_array is not None and diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 99a27e70..b75c7cb3 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -487,7 +487,8 @@ def test_official_registry_all_calldata_formats_reach_firmware(): # Every other format is refused by the compiler for a named device limit: # 8 iterate nested arrays, 2 nest their ABI deeper than 8 levels. assert len(unsupported) == 10 - # Passing the parser is not signability: only these run end to end. + # Passing the parser is not signability: only these pass the device's + # preload capability checks. assert signable == REGISTRY_SIGNABLE From a778589854dbeaed99b1b1220d4cb0766792eaee Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 09:22:59 -0500 Subject: [PATCH 08/15] test(erc7730): put the 7.15 runtime tests in the release report The Phase 0-E ERC-7730 runtime wire tests move here from keepkey-firmware's scripts/emulator/test_stack07_regressions.py. Only python-keepkey tests get OLED captures in the release PDF. Firmware keeps its 7a contracts and imports Erc7730Harness from this module. tests/test_msg_ethereum_erc7730_runtime.py (34 tests, full product, 7.15.0+): - every test that signs first signs the same transaction on the ordinary path and requires an identical signature; - the showcased walk runs last, because only its frames are kept; - the ordinary review that follows is not captured: it is unchanged, and the report's frame picker would otherwise prefer its per-transaction data hash over the certified screens; - new: a call at depth two is shown blind, and the device requests no definition deeper than depth one; - test_inner_containers_and_depth_two is renamed test_inner_calls_read_their_own_containers: it has no depth-2 call. generate-test-report.py: - new section EX (7.15.0): what the device executes, where each fact comes from, the 7.15 blind-sign rule for embedded calls (7.16 rejects), and the known fail-closed limits; - rows EX1-EX34 with the OLED screens each test must capture; - four full-sequence flows; - the module is must-run from 7.15.0 on the full product. The existing ER section (7.19.0) is unchanged. --- scripts/generate-test-report.py | 219 +++++ tests/test_msg_ethereum_erc7730_runtime.py | 970 +++++++++++++++++++++ 2 files changed, 1189 insertions(+) create mode 100644 tests/test_msg_ethereum_erc7730_runtime.py diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 3661162f..cdf1ddeb 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -433,6 +433,11 @@ def parse_junit(path): 'test_failed_signature_falls_back_to_the_unverified_review'), ('test_msg_ethereum_clear_signing', 'test_binding_happy_path_signs_and_recovers'), ('test_msg_ethereum_clear_signing', 'test_clearsign_uniswap_v2_eth_to_token'), + # ERC-7730 runtime: the order of the screens is the claim. + ('test_msg_ethereum_erc7730_runtime', 'test_interpolated_intent_parts_show_the_same_values_as_fields'), + ('test_msg_ethereum_erc7730_runtime', 'test_embedded_call_is_shown_under_a_blind_sign_warning'), + ('test_msg_ethereum_erc7730_runtime', 'test_inner_call_is_clear_signed_with_its_own_definition'), + ('test_msg_ethereum_erc7730_runtime', 'test_an_inner_definition_the_device_refuses_falls_back_to_blind'), # The newest/highest-stakes tx shapes get the full ordered walkthrough too. ('test_msg_ethereum_clear_signing', 'test_clearsign_eip7702_setcode_authorization'), ('test_msg_ethereum_clear_signing', 'test_clearsign_erc4337_entrypoint_v0_7_handleops'), @@ -3197,6 +3202,217 @@ def _arg_shown(a): 'selector or type-hash identity fields.', []), ]), + ('EX', 'ERC-7730 Runtime - What 7.15 Shows For A Certified Definition', '7.15.0', + 'A host may send a signed ERC-7730 definition before an Ethereum transaction. The device ' + 'verifies it while it streams and then shows the transaction through it: the contract action, ' + 'the signer\'s labels beside values the device decodes from the signed calldata, and inner ' + 'calls reviewed with their own definition. The definition only adds screens. The ' + 'ordinary review and signature follow unchanged: every test here that signs first signs ' + 'the same transaction on the ordinary path and requires an identical signature.\n' + 'In 7.15 this runs only with AdvancedMode on, and the definition\'s signer is shown as a runtime ' + 'signer, "NOT verified by KeepKey". It is annotation, not KeepKey-verified clear signing.', + ['WHAT THE DEVICE EXECUTES (one capability table, shared by the preload verifier, the', + 'runtime and python-keepkey\'s compiler; anything else is refused at preload, before', + 'any screen):', + '- Formatters: raw, amount, tokenAmount, nftName, date, duration, unit, enum,', + ' addressName, embedded calldata. Containers @.from/@.to/@.value; signed constants.', + '- Display: intent; interpolated intent as numbered parts; fields; groups; one array', + ' iterated at a time. Condition "optional" only, and it always shows.', + '- Refused: slices, packed words, never/ifIn/ifNotIn/mustMatch/ifEmpty, nested', + ' iteration, ABIs deeper than 8. The compiler omits visible:never fields.', + '- Official registry (pinned 9f37816a): 1,326 of 1,450 calldata formats pass the', + ' device\'s preload checks (asserted in lockstep by test_erc7730_compiler).', + '', + 'WHERE EACH FACT COMES FROM:', + '- Values: decoded on device from the signed calldata, replayed and hash-checked on', + ' every pass. Token tickers and decimals: the firmware table only. Signer text', + ' (labels, messages, units, enum labels) is escaped and shown beside the raw value.', + '', + 'EMBEDDED CALLS (7.15 rule: what cannot be clear-signed is shown under a', + '"Blind signature" warning; 7.16 will reject it):', + '- The inner definition is requested by chain, callee and selector read from the', + ' signed calldata, and bound before any inner screen. One level deep.', + '- Blind in 7.15: no inner definition; a refused one; one showing @.value with no', + ' amountPath; depth 2; calls inside an array; inner bytes without a selector.', + '', + 'KNOWN LIMITS (fail closed, no signature): parallel arrays pair by index and a', + 'shorter one aborts mid-review; a fixed index into a short dynamic array aborts; inner', + 'bytes not canonical for the inner ABI abort after the outer screens. The board pager', + 'may wrap a checksummed address across two OLED pages; every page must be confirmed.', + 'Open for 7.16: Safe DELEGATECALL is shown only as the operation field.'], + [ + ('EX1', 'test_msg_ethereum_erc7730_runtime', + 'test_program_outside_capability_table_is_refused_at_preload', + 'Unexecutable programs are refused at preload', + "A definition using a formatter, condition or path step outside the runtime's capability table is refused while it streams, before any screen. A review can never start and then fail part-way.", + []), + ('EX2', 'test_msg_ethereum_erc7730_runtime', + 'test_path_outside_abi_is_refused_at_preload', + 'Paths outside the ABI are refused at preload', + "Every value path is walked against the function's ABI at preload: a tuple index out of range, a step into a scalar or a path ending on an array is refused before any screen.", + []), + ('EX3', 'test_msg_ethereum_erc7730_runtime', + 'test_raw_field_screens_show_exact_text', + 'Raw fields show the exact decoded value', + "Each field is its own required confirmation under a device-owned title; the label is the signer's, the value is decoded from the signed calldata.", + ['Signer field with decoded value']), + ('EX4', 'test_msg_ethereum_erc7730_runtime', + 'test_token_amount_uses_the_firmware_token_table', + 'Token amounts use the firmware token table', + "Ticker and decimals come only from the firmware's own table for the chain, rendered exactly as the ordinary Ethereum review renders them.", + ['Amount with firmware ticker']), + ('EX5', 'test_msg_ethereum_erc7730_runtime', + 'test_token_named_by_calldata_wins_over_the_hosts_claim', + 'The token is read from calldata, not the host', + "The transaction goes to one contract; the calldata names another token. The device shows the calldata's token, and an unknown token as the exact integer plus its address.", + ['Unknown token, exact integer and address']), + ('EX6', 'test_msg_ethereum_erc7730_runtime', + 'test_signer_label_cannot_name_an_unknown_token', + 'A signer label cannot name a token', + 'A label saying "USDC amount" over an unknown token still shows the raw integer, "unknown token" and the address.', + ['Signer label beside unknown token']), + ('EX7', 'test_msg_ethereum_erc7730_runtime', + 'test_threshold_message_is_shown_beside_the_exact_amount', + 'Threshold messages sit beside the amount', + 'At or above the threshold the signer\'s message is shown, marked "Signer:", above the exact amount, never instead of it; below it no message appears.', + ['Signer message above the exact amount']), + ('EX8', 'test_msg_ethereum_erc7730_runtime', + 'test_native_alias_shows_the_chains_native_asset', + "Native-currency aliases name only the chain's asset", + "An address in the signer's alias set is shown as the chain's native asset; any other address stays an unknown token. The firmware token table wins over an alias.", + ['Native asset amount']), + ('EX9', 'test_msg_ethereum_erc7730_runtime', + 'test_address_name_marks_only_the_signing_account', + '"(this wallet)" marks only the signing account', + 'The signing address is derived on device; an address one byte away is shown without the mark. Addresses are EIP-55 and never truncated.', + ['Address marked (this wallet)']), + ('EX10', 'test_msg_ethereum_erc7730_runtime', + 'test_containers_and_signed_constants', + 'Transaction containers and signed constants', + '@.to is read from the transaction being signed; a constant comes from the signed definition.', + ['Container and constant fields']), + ('EX11', 'test_msg_ethereum_erc7730_runtime', + 'test_interpolated_intent_parts_show_the_same_values_as_fields', + 'Interpolated intent as numbered parts', + 'The intent sentence is shown as numbered parts after the plain intent: "Intent text i of n" for the signer\'s fragments, "Intent value i of n" for values the device formats, identical to the matching field.', + ['Intent text part', 'Intent value part', 'Matching field']), + ('EX12', 'test_msg_ethereum_erc7730_runtime', + 'test_phase_c_formatters_show_exact_text', + 'Date, duration, unit and enum formatters', + 'Each value is formatted on device and always shown with its raw integer; a unit\'s base is marked "unit set by signer".', + ['Unit value with raw integer']), + ('EX13', 'test_msg_ethereum_erc7730_runtime', + 'test_enum_labels_are_the_signers_claim_beside_the_value', + 'Enum labels never replace the value', + 'A mapped value shows "label (value)"; an unmapped one shows "value (unmapped)".', + ['Unmapped enum value']), + ('EX14', 'test_msg_ethereum_erc7730_runtime', + 'test_nft_shows_the_collection_address', + 'NFTs show the token ID and collection address', + 'No collection name is claimed: the token ID and the full collection address.', + ['Token ID and collection']), + ('EX15', 'test_msg_ethereum_erc7730_runtime', + 'test_malformed_calldata_is_refused_before_a_constant_field', + 'Malformed calldata is refused before any field', + 'The up-front validation pass rejects calldata that does not match the ABI before the first field, even one showing a constant.', + []), + ('EX16', 'test_msg_ethereum_erc7730_runtime', + 'test_iteration_shows_every_element_numbered', + 'Arrays show every element, numbered', + 'Each element\'s fields are titled "Signer field i of N"; an empty array shows no element and still signs.', + ['Element 1 of 2', 'Element 2 of 2']), + ('EX17', 'test_msg_ethereum_erc7730_runtime', + 'test_grouped_tuple_iteration_and_optional_fields', + 'Grouped tuple arrays and optional fields', + 'Groups only group; an "optional" field is always shown. Nothing given to the device is hidden.', + ['Tuple element fields', 'Optional field shown']), + ('EX18', 'test_msg_ethereum_erc7730_runtime', + 'test_embedded_call_is_shown_under_a_blind_sign_warning', + '7.15: embedded calls without a definition are blind', + 'A "Blind signature" screen, then the inner call\'s callee, selector, length, value and authority, all read from the signed calldata. 7.16 rejects instead.', + ['Blind signature warning', 'Inner call summary']), + ('EX19', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_call_is_clear_signed_with_its_own_definition', + 'Inner calls are clear-signed with their own definition', + 'The device requests the inner definition by chain, callee and selector read from the signed calldata, binds it before any inner screen, runs it under "Inner" titles, then resumes the outer definition.', + ['Call summary', 'Inner action', 'Inner fields', 'Outer field after the inner call']), + ('EX20', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_call_without_a_definition_is_blind_in_715', + 'No inner definition means a blind-sign warning', + 'When the host has no definition for the inner call, the 7.15 blind path follows; it is never a silent blind sign.', + ['Blind signature warning']), + ('EX21', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_definition_for_another_call_is_refused', + 'An inner definition for another selector is refused', + 'A hostile host substitutes the inner definition; the binding check refuses it before any inner screen.', + []), + ('EX22', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_bytes_changed_in_an_inner_pass_are_refused', + 'Changed inner bytes are refused', + 'Every inner pass replays the whole outer calldata against the reviewed digest; a change on a later pass is refused before any inner field.', + []), + ('EX23', 'test_msg_ethereum_erc7730_runtime', + 'test_numeric_constants_are_values', + 'Numeric constants are values', + 'A signed numeric constant is formatted like a decoded value of the same type.', + ['Constant formatted as an amount']), + ('EX24', 'test_msg_ethereum_erc7730_runtime', + 'test_control_characters_in_a_value_are_escaped', + 'Control characters are escaped', + 'A newline or tab inside a value is shown as \\x0a / \\x09, so no value can fake a line break.', + ['Escaped value']), + ('EX25', 'test_msg_ethereum_erc7730_runtime', + 'test_a_raw_value_too_long_to_capture_is_shown_blind', + 'Over-long raw values are shown blind with their length', + 'A value longer than the device can hold gets a "Blind signature" screen and "Not shown: N bytes".', + ['Blind signature warning', 'Length of the unshown value']), + ('EX26', 'test_msg_ethereum_erc7730_runtime', + 'test_multiline_values_split_between_lines', + 'Long values split between lines', + 'A value too long for one confirmation is split across numbered confirmations at line boundaries, so an amount or address line stays whole.', + ['Numbered part', 'Next part']), + ('EX27', 'test_msg_ethereum_erc7730_runtime', + 'test_an_inner_definition_the_device_refuses_falls_back_to_blind', + 'A refused inner definition falls back to blind', + 'An inner definition the device cannot execute, or signed by an unknown key, is dropped and the call is shown blind; the outer review then continues.', + ['Blind signature warning', 'Outer review continues']), + ('EX28', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_definition_for_another_callee_or_chain_is_refused', + 'Inner definitions are bound to callee and chain', + 'Substituting a definition for another contract or another chain is refused before any inner screen.', + []), + ('EX29', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_calls_read_their_own_containers', + 'Inner calls read their own context', + 'Inside the inner call @.value is the value it moves, @.from whose authority it runs with and @.to the callee.', + ['Inner value, authority and callee']), + ('EX30', 'test_msg_ethereum_erc7730_runtime', + 'test_embedded_calls_inside_an_iteration_are_shown_blind', + 'Calls inside an array are shown blind', + 'Each element of a multicall gets its own blind-sign warning and summary (multicall clear-signing is future work).', + ['Blind warning per element']), + ('EX31', 'test_msg_ethereum_erc7730_runtime', + 'test_a_fixed_index_into_a_short_array_fails_closed', + 'A fixed index past a short array fails closed', + "Preload cannot know a dynamic array's length; reaching path.[0] of an empty array aborts without a signature.", + []), + ('EX32', 'test_msg_ethereum_erc7730_runtime', + 'test_an_inner_value_the_calldata_does_not_carry_is_never_shown', + 'An inner value the calldata does not carry is never shown', + 'Without amountPath the calldata does not say what the inner call moves; an inner definition that shows @.value is refused and the call shown blind.', + ['Blind signature warning']), + ('EX33', 'test_msg_ethereum_erc7730_runtime', + 'test_parallel_arrays_pair_by_index_and_a_short_one_fails_closed', + 'Parallel arrays pair by index', + 'amounts[i] is shown with tokens[i]; a shorter second array aborts without a signature.', + ['Paired element']), + ('EX34', 'test_msg_ethereum_erc7730_runtime', + 'test_a_call_at_depth_two_is_shown_blind', + 'Depth is bounded at one', + 'A call inside an inner call is shown blind; the device never requests a depth-2 definition.', + ['Blind warning inside the inner call']), + ]), + # Two-character id because all 26 letters were taken. The catalog keys on a # string, not a char, so this costs nothing. ('TD', 'Structured EIP-712 - The Device Reads The Document', '7.15.0', @@ -3630,6 +3846,8 @@ def screenshot_test_list(fw_version): # loading regressed, all four would skip and the report would certify a # feature it never exercised. 'test_msg_solana_lut_attestation': '7.15.0', + # Block 7b: every ERC-7730 runtime row in section EX is part of 7.15. + 'test_msg_ethereum_erc7730_runtime': '7.15.0', } # A module can be mandatory for the regular product while being intentionally @@ -3639,6 +3857,7 @@ def screenshot_test_list(fw_version): FULL_FEATURE_ONLY_MUST_RUN_MODULES = { 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_msg_solana_lut_attestation', + 'test_msg_ethereum_erc7730_runtime', } diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py new file mode 100644 index 00000000..1e141254 --- /dev/null +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -0,0 +1,970 @@ +"""ERC-7730 runtime (7.15): what the device shows for a certified definition. + +Each test signs the same transaction on the ordinary path and then with a +certified ERC-7730 definition, reads every confirmation's exact title and +body over DebugLink, and requires the same signature both times: the +definition adds screens, never changes what is signed. + +The definition is signed by a throwaway catalog key loaded as a runtime +signer ("NOT verified by KeepKey"); AdvancedMode is required. Uses only the +public test mnemonic. Also imported by keepkey-firmware's +scripts/emulator/test_stack07_regressions.py for its harness. +""" + +import copy +import hashlib + +import common +from keepkeylib import erc7730, erc7730_compiler, eip712_stream +from keepkeylib import messages_ethereum_pb2 as eth +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as types +from keepkeylib.signed_metadata import TEST_PRIVATE_KEY, test_signer_compressed_pubkey as signer_pubkey + + +PATH = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] +ADDRESS = bytes.fromhex("11" * 20) +OTHER_ADDRESS = bytes.fromhex("33" * 20) +# An unrelated throwaway key that is never loaded into any signer slot. +UNKNOWN_SIGNER_KEY = bytes.fromhex("42" * 32) +# The ordinary review that follows every certified one. Its screens are not +# captured for the report: they are unchanged, which the identical signature +# already proves, and the report's frame picker would otherwise prefer the +# per-transaction data hash over the screens the definition adds. +ORDINARY_REVIEW = ("Send", "Transaction", "Ethereum Data Hash") + + +def assert_failure(test, result, code, message): + test.assertIsInstance(result, proto.Failure) + test.assertEqual((result.code, result.message), (code, message)) + + +class Erc7730Harness(object): + """Preload, sign and walk helpers. Not a TestCase: importing it collects + no tests.""" + + def _envelope(self, program, signer_key=TEST_PRIVATE_KEY, signer_pub=None, + chain=1): + cert = bytearray(139) + cert[0] = 1 + cert[2:6] = chain.to_bytes(4, "big") + cert[10:21] = b"Host alias\0" + cert[42:75] = signer_pub if signer_pub is not None else signer_pubkey() + return erc7730.sign_envelope(program, cert, signer_key) + + def _definition(self, program, envelope): + return erc7730.Definition( + envelope, program[7], 1, ADDRESS, + program[38:42] if program[7] == 1 else program[38:70]) + + def _load_signer(self): + self.client.load_clearsign_signer( + key_id=3, pubkey=signer_pubkey(), alias="Audit signer") + + def _preload(self, program): + self._load_signer() + envelope = self._envelope(program) + erc7730.preload(self.client, self._definition(program, envelope)) + self._drop_setup_screenshots() + return envelope + + def _raw_preload(self, envelope): + """Stream an envelope; return the first non-Ack response. + + Every refusal contract below is a refusal BEFORE any ButtonRequest, + so a ButtonRequest here is returned (and fails the caller's Failure + assertion) rather than acknowledged. + """ + definition_id = hashlib.sha256(envelope).digest() + offset = 0 + while offset < len(envelope): + data = envelope[offset:offset + erc7730.MAX_CHUNK] + response = self.client.call_raw(eth.EthereumClearSignDefinition( + definition_id=definition_id, offset=offset, + total_length=len(envelope), data=data)) + if not isinstance(response, eth.EthereumClearSignDefinitionAck): + return response + offset += len(data) + return response + + def _first_pages(self): + """(title, body) of each confirmation's first page. A body that pages + continues under ButtonRequest_Other with the same text; any other + repeat is a second confirmation and is kept, so a double display + stays visible.""" + return [screen for screen, code in zip(self.screens, self.button_codes) + if code != types.ButtonRequest_Other] + + def _walk(self, start, envelope=b"", doc=None, change_pass=None, cancel_button=None, + arguments=None, catalog=None, altered=None): + response = self.client.call_raw(start) + self.definition_requests = 0 + self.button_codes = [] + self.screens = [] + buttons = 0 + calldata_passes = 0 + typed_passes = 0 + for _ in range(1000): + if isinstance(response, proto.ButtonRequest): + buttons += 1 + self.button_codes.append(response.code) + self.screens.append(self.client.debug.read_confirm_text()) + if self.screens[-1][0] not in ORDINARY_REVIEW: + self.client.capture_oled() + self.client.debug.press_yes() if buttons != cancel_button else self.client.debug.press_no() + response = self.client.call_raw(proto.ButtonAck()) + elif isinstance(response, eth.EthereumClearSignDefinitionRequest) and catalog: + self.definition_requests += 1 + response = self.client.call_raw(catalog.chunk(response)) + elif isinstance(response, eth.EthereumClearSignDefinitionRequest): + self.definition_requests += 1 + offset = response.offset + response = self.client.call_raw(eth.EthereumClearSignDefinitionChunk( + definition_id=hashlib.sha256(envelope).digest(), offset=offset, + total_length=len(envelope), data=envelope[offset:offset + response.length])) + elif isinstance(response, eth.EthereumTypedDataStructRequest): + response = self.client.call_raw(eip712_stream.build_struct_ack( + eip712_stream.struct_members(doc, response.name))) + elif isinstance(response, eth.EthereumTypedDataValueRequest): + path = list(response.member_path) + if path == [1, 0]: + typed_passes += 1 + current = copy.deepcopy(doc) + if change_pass == typed_passes: + current["message"]["first"] += 1 + resolved = eip712_stream.resolve_member_path(current, path) + value = (eip712_stream.encode_array_length(resolved[1]) + if resolved[0] == "length" else + eip712_stream.encode_value(resolved[1], resolved[2])) + response = self.client.call_raw(eth.EthereumTypedDataValueAck(value=value)) + elif isinstance(response, eth.EthereumTxRequest) and response.HasField("data_length"): + calldata_passes += 1 + data = arguments + if altered and altered[0] == calldata_passes: + data = altered[1] + if data is None: + data = (43 if change_pass == calldata_passes else 42).to_bytes(32, "big") + data += (7).to_bytes(32, "big") + self.assertEqual(response.data_length, len(data)) + response = self.client.call_raw(eth.EthereumTxAck(data_chunk=data)) + else: + return response, buttons, calldata_passes, typed_passes + self.fail("protocol did not terminate") + + +class TestMsgEthereumErc7730Runtime(Erc7730Harness, common.KeepKeyTest): + def setUp(self): + super().setUp() + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + # Phase 0 of the ERC-7730 formatter plan: the preload verifier and the + # runtime share one capability table, so a program the runtime cannot + # finish is refused before the first screen instead of after the user has + # approved the signer, intent and earlier fields. + def _audit_start(self, program, data_length=68): + return eth.EthereumSignTx(address_n=PATH, nonce=b"", gas_price=b"\x01", + gas_limit=b"\xff\xff", to=ADDRESS, value=b"", chain_id=1, + data_length=data_length, data_initial_chunk=program[38:42]) + + def test_program_outside_capability_table_is_refused_at_preload(self): + signature = "audit(uint256 first,uint256 second)" + # Conditions that could hide or veto a field are never executed. + fields = { + "ifNotIn": {"path": "first", "label": "First value", + "format": "raw", "visible": {"ifNotIn": [0]}}, + "mustMatch": {"path": "first", "label": "First value", + "format": "raw", "visible": {"mustMatch": [42]}}, + } + self._load_signer() + for name, field in sorted(fields.items()): + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + field, + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + with self.assertRaises(erc7730_compiler.DeviceCannotExecute): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS, executable_only=False) + result = self._raw_preload(self._envelope(program)) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + # Nothing is left preloaded: the transaction takes the ordinary, + # uncertified path and never asks for a definition. + result, _, passes, _ = self._walk(self._audit_start(program)) + self.assertIsInstance(result, eth.EthereumTxRequest, msg=name) + self.assertTrue(result.HasField("signature_r"), msg=name) + self.assertEqual((name, passes, self.definition_requests), + (name, 1, 0)) + + def test_path_outside_abi_is_refused_at_preload(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + program = bytearray(erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS)) + # The single value path is (source 1, one step, index 1). Point it at + # a third argument the function does not have. + step = program.index(bytes([1, 1, 0xff, 0xff, 1, 0, 0, 0, 1])) + program[step + 8] = 2 + self._load_signer() + result = self._raw_preload(self._envelope(bytes(program))) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + + def test_raw_field_screens_show_exact_text(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "first", "label": "First value", "format": "raw"}, + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, self._word(42) + self._word(7)) + self.assertEqual(self.calldata_passes, 4) + certified = [screen for screen in self.screens if screen[0] in ( + "Runtime signer", "Unverified data", "Contract action", + "Signer field")] + self.assertEqual(certified[1:], [ + ("Unverified data", "NOT verified by KeepKey"), + ("Contract action", "Audit action"), + ("Signer field", "First value:\n42"), + ("Signer field", "Second value:\n7"), + ]) + self.assertEqual(certified[0][0], "Runtime signer") + self.assertTrue(certified[0][1].startswith("Audit signer ("), + certified[0][1]) + + # Phase A: tokenAmount, addressName, @.from/@.to and signed constants. + # Asset facts come only from the firmware token table; an address is always + # shown in full; the signer's message sits beside the value. + USDC = bytes.fromhex("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + # Not in the firmware token table on chain 1 (0xeeee..ee is: there the + # table's own entry wins over any signer alias). + NATIVE = bytes.fromhex("44" * 20) + + def _certified(self, program, arguments, preload=None, catalog=None): + """Sign once on the ordinary path, then with the definition, and + require the same signature: the annotations are additive only.""" + start = self._audit_start(program, 4 + len(arguments)) + baseline, _, _, _ = self._walk(start, arguments=arguments) + self.assertIsInstance(baseline, eth.EthereumTxRequest) + self.assertTrue(baseline.HasField("signature_r")) + envelope = self._preload(program) if preload is None else preload() + result, _, passes, _ = self._walk(start, envelope, arguments=arguments, + catalog=catalog) + self.calldata_passes = passes + self.assertIsInstance(result, eth.EthereumTxRequest) + self.assertEqual((result.signature_r, result.signature_s), + (baseline.signature_r, baseline.signature_s)) + return result + + def _field_screens(self, descriptor, signature, arguments): + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [body for title, body in self._first_pages() + if title == "Signer field"] + + @staticmethod + def _word(value): + if isinstance(value, bytes): + return bytes(12) + value + return value.to_bytes(32, "big") + + def _token_screen(self, token, amount, params=None): + signature = "send(address token,uint256 amount)" + fields = [{"path": "amount", "label": "Amount", "format": "tokenAmount", + "params": dict({"tokenPath": "token"}, **(params or {}))}] + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": fields}}}} + return self._field_screens(descriptor, signature, + self._word(token) + self._word(amount)) + + def test_token_amount_uses_the_firmware_token_table(self): + self.assertEqual(self._token_screen(self.USDC, 1500000), + ["Amount:\n1.5 USDC"]) + + def test_token_named_by_calldata_wins_over_the_hosts_claim(self): + # The transaction goes to ADDRESS and a host might call it USDC; the + # calldata names another token, which the firmware table does not know. + self.assertEqual(self._token_screen(OTHER_ADDRESS, 42), [ + "Amount:\n42\nunknown token\n0x" + OTHER_ADDRESS.hex()]) + + def test_signer_label_cannot_name_an_unknown_token(self): + signature = "send(uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": [{ + "path": "amount", "label": "USDC amount", + "format": "tokenAmount", + "params": {"token": "0x" + OTHER_ADDRESS.hex()}}]}}}} + self.assertEqual( + self._field_screens(descriptor, signature, self._word(42)), + ["USDC amount:\n42\nunknown token\n0x" + OTHER_ADDRESS.hex()]) + + def test_threshold_message_is_shown_beside_the_exact_amount(self): + params = {"threshold": 1000000, "message": "Large amount"} + self.assertEqual(self._token_screen(self.USDC, 999999, params), + ["Amount:\n0.999999 USDC"]) + self.assertEqual(self._token_screen(self.USDC, 1500000, params), + ["Amount:\nSigner: Large amount\n1.5 USDC"]) + + def test_native_alias_shows_the_chains_native_asset(self): + params = {"nativeCurrencyAddress": ["0x" + self.NATIVE.hex()]} + # An address outside the alias set is not the native asset. + self.assertEqual( + self._token_screen(OTHER_ADDRESS, 1500000000000000000, params), + ["Amount:\n1500000000000000000\nunknown token\n0x" + + OTHER_ADDRESS.hex()]) + self.assertEqual( + self._token_screen(self.NATIVE, 1500000000000000000, params), + ["Amount:\n1.5 ETH"]) + + def test_address_name_marks_only_the_signing_account(self): + signer = self.client.ethereum_get_address(PATH) + if not isinstance(signer, bytes): + signer = bytes.fromhex(signer[2:]) + signature = "pay(address recipient)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [{ + "path": "recipient", "label": "Recipient", + "format": "addressName"}]}}}} + from keepkeylib.signed_metadata import keccak256 + + def checksummed(address): + digest = keccak256(address.hex().encode("ascii")).hex() + return "0x" + "".join( + c.upper() if c.isalpha() and int(digest[i], 16) >= 8 else c + for i, c in enumerate(address.hex())) + + near = bytes(signer[:19]) + bytes([signer[19] ^ 1]) + self.assertEqual( + self._field_screens(descriptor, signature, self._word(near)), + ["Recipient:\n" + checksummed(near)]) + self.assertEqual( + self._field_screens(descriptor, signature, self._word(signer)), + ["Recipient:\n" + checksummed(signer) + "\n(this wallet)"]) + + def test_containers_and_signed_constants(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "@.to", "label": "Contract", "format": "addressName"}, + {"value": "Audit protocol", "label": "Protocol"}]}}}} + self.assertEqual( + self._field_screens(descriptor, signature, + self._word(42) + self._word(7)), + ["Contract:\n0x" + ADDRESS.hex(), "Protocol:\nAudit protocol"]) + + # Phase B: the interpolated intent is shown as numbered parts after the + # plain intent. Each value part is formatted exactly as its field is. + def test_interpolated_intent_parts_show_the_same_values_as_fields(self): + signature = "send(address token,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send tokens", + "interpolatedIntent": "Send {amount} now", + "fields": [{"path": "amount", "label": "Amount", + "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, self._word(self.USDC) + self._word(1500000)) + shown = [screen for screen in self._first_pages() + if screen[0] in ("Contract action", "Intent text 1 of 3", + "Intent value 2 of 3", "Intent text 3 of 3", + "Signer field")] + self.assertEqual(shown, [ + ("Contract action", "Send tokens"), + ("Intent text 1 of 3", "Send"), + ("Intent value 2 of 3", "1.5 USDC"), + ("Intent text 3 of 3", "now"), + ("Signer field", "Amount:\n1.5 USDC"), + ]) + + # Phase C: amount, date, duration, unit, enum and nftName. Signer-supplied + # units and enum labels appear beside the raw value, never instead. + def _one_field(self, field, arguments, signature="act(uint256 a,address b)", + metadata=None): + descriptor = {"display": {"formats": {signature: { + "intent": "Act", "fields": [field]}}}} + if metadata: + descriptor["metadata"] = metadata + return self._field_screens(descriptor, signature, arguments) + + def test_phase_c_formatters_show_exact_text(self): + pad = self._word(OTHER_ADDRESS) + cases = [ + ({"path": "a", "label": "Value", "format": "amount"}, + 1500000000000000000, "Value:\n1.5 ETH"), + ({"path": "a", "label": "When", "format": "date", + "params": {"encoding": "timestamp"}}, + 1700000000, "When:\n2023-11-14 22:13:20 UTC\n(1700000000)"), + ({"path": "a", "label": "At", "format": "date", + "params": {"encoding": "blockheight"}}, + 19000000, "At:\nBlock 19000000"), + ({"path": "a", "label": "Lock", "format": "duration"}, + 93784, "Lock:\n1d 2h 3m 4s\n(93784 s)"), + ({"path": "a", "label": "Weight", "format": "unit", + "params": {"base": "kg", "decimals": 3}}, + 93784, "Weight:\n93.784 kg\nunit set by signer\nraw 93784"), + ] + for field, value, expected in cases: + self.assertEqual( + (field["format"], + self._one_field(field, self._word(value) + pad)), + (field["format"], [expected])) + + def test_enum_labels_are_the_signers_claim_beside_the_value(self): + field = {"path": "a", "label": "Side", "format": "enum", + "params": {"$ref": "$.metadata.enums.side"}} + metadata = {"enums": {"side": {"0": "Buy", "1": "Sell"}}} + pad = self._word(OTHER_ADDRESS) + self.assertEqual(self._one_field(field, self._word(1) + pad, + metadata=metadata), + ["Side:\nSell (1)"]) + self.assertEqual(self._one_field(field, self._word(5) + pad, + metadata=metadata), + ["Side:\n5 (unmapped)"]) + + def test_nft_shows_the_collection_address(self): + field = {"path": "a", "label": "Item", "format": "nftName", + "params": {"collectionPath": "b"}} + self.assertEqual( + self._one_field(field, self._word(42) + self._word(self.USDC)), + ["Item:\nToken ID 42\nCollection\n" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]) + + def test_malformed_calldata_is_refused_before_a_constant_field(self): + # The first field shows a signed constant and captures nothing, so + # only the up-front validation pass stands between malformed calldata + # and the first screen. + signature = "pay(address recipient)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [ + {"value": "Audit protocol", "label": "Protocol"}, + {"path": "recipient", "label": "Recipient", + "format": "addressName"}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + envelope = self._preload(program) + dirty = b"\x01" + bytes(11) + OTHER_ADDRESS # non-canonical address + result, buttons, passes, _ = self._walk( + self._audit_start(program, 36), envelope, arguments=dirty) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual((buttons, passes), (0, 1)) + + # Phase D: groups, "optional" fields and one iteration at a time. Every + # element gets its own numbered screens; nothing is ever hidden. + def _titled_fields(self, descriptor, signature, arguments): + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [screen for screen in self._first_pages() + if screen[0].startswith("Signer field")] + + def test_iteration_shows_every_element_numbered(self): + signature = "pay(address[] recipients)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [{ + "path": "recipients.[]", "label": "Recipient", + "format": "addressName"}]}}}} + # An empty array shows no element and still signs. + self.assertEqual( + self._titled_fields(descriptor, signature, + self._word(32) + self._word(0)), []) + arguments = (self._word(32) + self._word(2) + + self._word(OTHER_ADDRESS) + self._word(ADDRESS)) + self.assertEqual( + self._titled_fields(descriptor, signature, arguments), [ + ("Signer field 1 of 2", "Recipient:\n0x" + OTHER_ADDRESS.hex()), + ("Signer field 2 of 2", "Recipient:\n0x" + ADDRESS.hex()), + ]) + + def test_grouped_tuple_iteration_and_optional_fields(self): + signature = "batch((address to,uint256 amount)[] items,uint256 fee)" + descriptor = {"display": {"formats": {signature: { + "intent": "Batch", "fields": [ + {"path": "items.[]", "label": "Transfer", "fields": [ + {"path": "to", "label": "To", "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "raw"}]}, + {"path": "fee", "label": "Fee", "format": "raw", + "visible": "optional"}]}}}} + arguments = (self._word(64) + self._word(9) + self._word(2) + + self._word(OTHER_ADDRESS) + self._word(5) + + self._word(ADDRESS) + self._word(6)) + self.assertEqual( + self._titled_fields(descriptor, signature, arguments), [ + ("Signer field 1 of 2", "To:\n0x" + OTHER_ADDRESS.hex()), + ("Signer field 1 of 2", "Amount:\n5"), + ("Signer field 2 of 2", "To:\n0x" + ADDRESS.hex()), + ("Signer field 2 of 2", "Amount:\n6"), + ("Signer field", "Fee:\n9"), + ]) + + # Phase E1 (7.15): an embedded call the device cannot clear-sign is shown + # under a blind-sign warning: its callee, selector, length, value and + # authority, all read from the signed calldata. 7.16 rejects it instead. + def _exec_screens(self, inner, value=1500000000000000000): + signature = ("execTransaction(address to,uint256 value,bytes data," + "uint8 operation)") + descriptor = {"display": {"formats": {signature: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": {"calleePath": "to", "amountPath": "value", + "spenderPath": "@.to"}}]}}}} + padded = inner + bytes(-len(inner) % 32) + arguments = (self._word(OTHER_ADDRESS) + self._word(value) + + self._word(128) + self._word(0) + + self._word(len(inner)) + padded) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [screen for screen in self._first_pages() + if screen[0] in ("Blind signature", "Signer field")] + + def test_embedded_call_is_shown_under_a_blind_sign_warning(self): + inner = bytes.fromhex("a9059cbb") + bytes(296) # 300 bytes: no capture + self.assertEqual(self._exec_screens(inner), [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0x" + OTHER_ADDRESS.hex() + + "\nFunction 0xa9059cbb\nData 300 bytes\nValue 1.5 ETH\nAs 0x" + + ADDRESS.hex()), + ]) + self.assertEqual(self._exec_screens(b"", value=0), [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0x" + OTHER_ADDRESS.hex() + + "\nNo data\nValue 0 Wei\nAs 0x" + ADDRESS.hex()), + ]) + + # Phase E2: the inner call is clear-signed with its own definition, bound + # to the callee and selector in the signed calldata. Every inner pass + # replays the whole outer calldata against the reviewed digest. + EXEC = ("execTransaction(address to,uint256 value,bytes data," + "uint8 operation)") + TRANSFER = "transfer(address to,uint256 amount)" + + def _exec_setup(self, amount_path=True): + params = {"calleePath": "to", "spenderPath": "@.to"} + if amount_path: + params["amountPath"] = "value" + outer_descriptor = {"display": {"formats": {self.EXEC: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": params}, + {"path": "operation", "label": "Operation", "format": "raw"}]}}}} + inner_descriptor = {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient", "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "@.to"}}]}}}} + outer = erc7730_compiler.compile_calldata( + outer_descriptor, self.EXEC, 1, ADDRESS) + inner = erc7730_compiler.compile_calldata( + inner_descriptor, self.TRANSFER, 1, self.USDC) + self._load_signer() + outer_env = self._envelope(outer) + inner_def = erc7730.Definition(self._envelope(inner), 1, 1, self.USDC, + inner[38:42]) + outer_def = self._definition(outer, outer_env) + self._exec_outer = (outer, outer_def) + call = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + arguments = (self._word(self.USDC) + self._word(0) + + self._word(128) + self._word(0) + + self._word(len(call)) + call + + bytes(-len(call) % 32)) + start = self._audit_start(outer, 4 + len(arguments)) + return start, arguments, outer_def, inner_def + + def _exec_certified(self, arguments, catalog): + outer, outer_def = self._exec_outer + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + return self._certified(outer, arguments, preload=preload, + catalog=catalog) + + def _relevant(self): + titles = ("Runtime signer", "Inner signer", "Contract action", + "Inner action", "Signer field", "Inner field", + "Blind signature") + return [screen for screen in self._first_pages() if screen[0] in titles] + + def test_inner_call_is_clear_signed_with_its_own_definition(self): + start, arguments, outer_def, inner_def = self._exec_setup() + self._exec_certified(arguments, erc7730.Catalog((outer_def, inner_def))) + shown = [s for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual(shown, [ + ("Contract action", "sign multisig operation"), + ("Signer field", + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes\nValue 0 Wei\nAs 0x" + + ADDRESS.hex()), + ("Inner action", "Transfer"), + ("Inner field", "Recipient:\n0x" + OTHER_ADDRESS.hex()), + ("Inner field", "Amount:\n1.5 USDC"), + ("Signer field", "Operation:\n0"), + ]) + self.assertIn("Inner signer", [s[0] for s in self.screens]) + + def test_inner_call_without_a_definition_is_blind_in_715(self): + start, arguments, outer_def, _ = self._exec_setup() + self._exec_certified(arguments, erc7730.Catalog((outer_def,))) + shown = [s for s in self._relevant() + if s[0] != "Runtime signer"] + self.assertEqual(shown[1:3], [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes\nValue 0 Wei\nAs 0x" + + ADDRESS.hex()), + ]) + + def test_inner_definition_for_another_call_is_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + # A host that answers the inner request with a definition for another + # selector: the device binds it to the signed selector and refuses. + approve = erc7730_compiler.compile_calldata( + {"display": {"formats": {"approve(address spender,uint256 amount)": { + "intent": "Approve", "fields": []}}}}, + "approve(address spender,uint256 amount)", 1, self.USDC) + wrong = erc7730.Definition(self._envelope(approve), 1, 1, self.USDC, + approve[38:42]) + + class Substituting(erc7730.Catalog): + def chunk(self, request): + if request.HasField("recursion_depth"): + request = copy.deepcopy(request) + request.selector_or_type_hash = wrong.selector_or_type_hash + return erc7730.Catalog.chunk(self, request) + + erc7730.preload(self.client, outer_def) + result, _, _, _ = self._walk( + start, arguments=arguments, + catalog=Substituting((outer_def, inner_def, wrong))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 inner definition does not match") + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_inner_bytes_changed_in_an_inner_pass_are_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + # Pass 1 validates and fixes the digest; passes 2-4 capture the outer + # fields up to the inner call. Pass 5 is the inner call's own + # validation pass: change one byte of its amount there. + altered = bytearray(arguments) + altered[-40] ^= 1 + erc7730.preload(self.client, outer_def) + result, buttons, passes, _ = self._walk( + start, arguments=arguments, altered=(5, bytes(altered)), + catalog=erc7730.Catalog((outer_def, inner_def))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual(passes, 5) + self.assertNotIn("Inner field", [s[0] for s in self.screens]) + + # ---- Audit remediation: each of these used to fail mid-review, take a + # fact from the wrong source, or was untested. ---- + + def test_numeric_constants_are_values(self): + pad = self._word(OTHER_ADDRESS) + for constant, expected in ((5, "Fee:\n5 Wei"), (1000, "Fee:\n1000 Wei")): + self.assertEqual( + self._one_field({"value": constant, "label": "Fee", + "format": "amount"}, + self._word(1) + pad), + [expected]) + + def test_control_characters_in_a_value_are_escaped(self): + signature = "note(string text)" + descriptor = {"display": {"formats": {signature: { + "intent": "Note", "fields": [ + {"path": "text", "label": "Text", "format": "raw"}]}}}} + text = b"a\nb\tc" + arguments = (self._word(32) + self._word(len(text)) + text + + bytes(-len(text) % 32)) + self.assertEqual(self._field_screens(descriptor, signature, arguments), + ["Text:\na\\x0ab\\x09c"]) + + def test_a_raw_value_too_long_to_capture_is_shown_blind(self): + signature = "blob(bytes data)" + descriptor = {"display": {"formats": {signature: { + "intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}} + data = bytes(range(200)) + arguments = (self._word(32) + self._word(len(data)) + data + + bytes(-len(data) % 32)) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + self.assertEqual( + [s for s in self._first_pages() + if s[0] in ("Blind signature", "Signer field")], + [("Blind signature", "The value is too long to show"), + ("Signer field", "Data:\nNot shown: 200 bytes")]) + + def test_multiline_values_split_between_lines(self): + # A label of 64 non-ASCII bytes escapes to 256 characters, leaving 93 + # per screen: the unknown-token body splits, but between lines, so + # the token address is never cut. + label = "é" * 32 + signature = "send(address token,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": [{ + "path": "amount", "label": label, "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}} + arguments = self._word(OTHER_ADDRESS) + self._word(10 ** 60) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + parts = [body for title, body in self._first_pages() + if title.startswith("Signer field")] + self.assertGreater(len(parts), 1) + address = "0x" + OTHER_ADDRESS.hex() + self.assertEqual(sum(1 for body in parts if body.endswith(address)), 1) + for body in parts: + self.assertFalse(body.endswith(address[:10]), body) + + def _exec_inner_catalog(self, inner_program, chain=1, signer=None): + env = (self._envelope(inner_program, chain=chain) if signer is None + else self._envelope(inner_program, *signer, chain=chain)) + return erc7730.Definition(env, 1, chain, inner_program[18:38], + inner_program[38:42]) + + def test_an_inner_definition_the_device_refuses_falls_back_to_blind(self): + start, arguments, outer_def, _ = self._exec_setup() + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + from ecdsa import SigningKey, SECP256k1 + unknown = SigningKey.from_string( + UNKNOWN_SIGNER_KEY, curve=SECP256k1).get_verifying_key().to_string( + "compressed") + clear = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 1, self.USDC) + for inner in (self._exec_inner_catalog(refused), + self._exec_inner_catalog( + clear, signer=(UNKNOWN_SIGNER_KEY, unknown))): + self._exec_certified(arguments, + erc7730.Catalog((outer_def, inner))) + shown = self._relevant() + self.assertIn(("Blind signature", + "The inner call is not clear-signed"), shown) + self.assertNotIn("Inner action", [s[0] for s in shown]) + self.assertEqual(shown[-1], ("Signer field", "Operation:\n0")) + + def test_inner_definition_for_another_callee_or_chain_is_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + other_callee = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 1, OTHER_ADDRESS) + other_chain = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 56, self.USDC) + for wrong in (self._exec_inner_catalog(other_callee), + self._exec_inner_catalog(other_chain, chain=56)): + class Substituting(erc7730.Catalog): + def chunk(self, request): + if request.HasField("recursion_depth") and request.recursion_depth: + replaced = copy.deepcopy(request) + replaced.chain_id = wrong.chain_id + replaced.contract_address = wrong.contract_address + return erc7730.Catalog.chunk(self, replaced) + return erc7730.Catalog.chunk(self, request) + erc7730.preload(self.client, outer_def) + result, _, _, _ = self._walk( + start, arguments=arguments, + catalog=Substituting((outer_def, inner_def, wrong))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 inner definition does not match") + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_inner_calls_read_their_own_containers(self): + # Inside the inner call @.value is the value it moves and @.from + # whose authority it runs with; a call inside it is shown blind. + start, arguments, outer_def, _ = self._exec_setup() + inner = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "@.value", "label": "Moves", "format": "amount"}, + {"path": "@.from", "label": "As", "format": "addressName"}, + {"path": "@.to", "label": "Token", "format": "addressName"}, + ]}}}}, + self.TRANSFER, 1, self.USDC) + # Give the outer call a value: the inner call moves 2 ETH. + moving = bytearray(arguments) + moving[32:64] = self._word(2 * 10 ** 18) + self._exec_certified(bytes(moving), + erc7730.Catalog((outer_def, + self._exec_inner_catalog(inner)))) + inner_fields = [s[1] for s in self._relevant() if s[0] == "Inner field"] + self.assertEqual(inner_fields, [ + "Moves:\n2 ETH", + "As:\n0x" + ADDRESS.hex(), + "Token:\n0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + ]) + + def test_embedded_calls_inside_an_iteration_are_shown_blind(self): + signature = "multicall(bytes[] calls)" + descriptor = {"display": {"formats": {signature: { + "intent": "Multicall", "fields": [{ + "path": "calls.[]", "label": "Call", "format": "calldata", + "params": {"calleePath": "@.to"}}]}}}} + call = bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + self._word(1) + padded = call + bytes(-len(call) % 32) + element = self._word(len(call)) + padded + arguments = (self._word(32) + self._word(2) + self._word(64) + + self._word(64 + len(element)) + element + element) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + shown = [s for s in self._first_pages() + if s[0] == "Blind signature" or s[0].startswith("Signer field")] + body = ("Call:\nTo 0x" + ADDRESS.hex() + + "\nFunction 0xa9059cbb\nData 68 bytes") + self.assertEqual(shown, [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field 1 of 2", body), + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field 2 of 2", body), + ]) + + def test_a_fixed_index_into_a_short_array_fails_closed(self): + # Data-dependent: preload cannot know the array's length. The device + # refuses without a signature when it reaches the field. + signature = "swap(address[] path,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Swap", "fields": [{ + "path": "amount", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "path.[0]"}}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + arguments = self._word(64) + self._word(5) + self._word(0) + envelope = self._preload(program) + result, _, _, _ = self._walk( + self._audit_start(program, 4 + len(arguments)), envelope, + arguments=arguments) + self.assertIsInstance(result, proto.Failure) + self.assertEqual(result.message, + "ERC-7730 calldata does not match definition") + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_an_inner_value_the_calldata_does_not_carry_is_never_shown(self): + # Without amountPath the calldata does not say what the inner call + # moves: an inner definition that shows @.value is shown blind, while + # one that does not is still clear-signed. + _, arguments, outer_def, inner_def = self._exec_setup(amount_path=False) + self._exec_certified(arguments, erc7730.Catalog((outer_def, inner_def))) + self.assertIn("Inner field", [s[0] for s in self._relevant()]) + _, arguments, outer_def, _ = self._exec_setup(amount_path=False) + shows_value = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "@.value", "label": "Moves", "format": "amount"}]}}}}, + self.TRANSFER, 1, self.USDC) + self._exec_certified(arguments, erc7730.Catalog( + (outer_def, self._exec_inner_catalog(shows_value)))) + shown = self._relevant() + self.assertIn(("Blind signature", + "The inner call is not clear-signed"), shown) + self.assertNotIn("Inner field", [s[0] for s in shown]) + + def test_parallel_arrays_pair_by_index_and_a_short_one_fails_closed(self): + # ERC-7730 pairs a field's arrays by index: amounts[i] with tokens[i]. + # When the second array is shorter the device stops without signing. + signature = "batch(address[] tokens,uint256[] amounts)" + descriptor = {"display": {"formats": {signature: { + "intent": "Batch", "fields": [{ + "path": "amounts.[]", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "tokens.[]"}}]}}}} + + def arguments(tokens): + return (self._word(64) + self._word(96 + 32 * len(tokens)) + + self._word(len(tokens)) + + b"".join(self._word(t) for t in tokens) + + self._word(2) + self._word(7) + self._word(8)) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + short = arguments([OTHER_ADDRESS]) + envelope = self._preload(program) + result, _, _, _ = self._walk( + self._audit_start(program, 4 + len(short)), envelope, + arguments=short) + self.assertIsInstance(result, proto.Failure) + self.assertEqual(result.message, + "ERC-7730 calldata does not match definition") + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + both = self._titled_fields(descriptor, signature, + arguments([OTHER_ADDRESS, ADDRESS])) + self.assertEqual([t for t, _ in both], + ["Signer field 1 of 2", "Signer field 2 of 2"]) + self.assertTrue(both[0][1].endswith(OTHER_ADDRESS.hex()), both[0][1]) + self.assertTrue(both[1][1].endswith(ADDRESS.hex()), both[1][1]) + + def test_a_call_at_depth_two_is_shown_blind(self): + # A Safe executing a call on a second Safe: the second Safe's + # definition is clear-signed one level deep, and the transfer it + # carries is shown blind. The device never fetches a depth-2 + # definition. + from keepkeylib.signed_metadata import keccak256 + _, _, outer_def, _ = self._exec_setup() + second_safe = bytes.fromhex("55" * 20) + middle = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.EXEC: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": {"calleePath": "to", "amountPath": "value", + "spenderPath": "@.to"}}, + {"path": "operation", "label": "Operation", + "format": "raw"}]}}}}, + self.EXEC, 1, second_safe) + + def execute(to, data): + return (self._word(to) + self._word(0) + self._word(128) + + self._word(0) + self._word(len(data)) + data + + bytes(-len(data) % 32)) + selector = keccak256( + b"execTransaction(address,uint256,bytes,uint8)")[:4] + transfer = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + arguments = execute(second_safe, + selector + execute(self.USDC, transfer)) + depths = [] + + class Recording(erc7730.Catalog): + def chunk(self, request): + depths.append(request.recursion_depth) + return erc7730.Catalog.chunk(self, request) + self._exec_certified(arguments, Recording( + (outer_def, self._exec_inner_catalog(middle)))) + self.assertEqual(max(depths), 1) + shown = [s for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual([s[0] for s in shown], [ + "Contract action", "Signer field", "Inner action", + "Blind signature", "Inner field", "Inner field", "Signer field"]) + self.assertEqual(shown[3][1], "The inner call is not clear-signed") + self.assertTrue(shown[4][1].startswith( + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes"), shown[4][1]) + self.assertEqual(shown[5][1], "Operation:\n0") + self.assertEqual(shown[6][1], "Operation:\n0") From 26d1842bff4b5d999b952c937c03ab8a65839ce7 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 09:49:20 -0500 Subject: [PATCH 09/15] fix(erc7730): mirror the re-audit's preload refusals; tighten runtime tests Mirror (device_refusal), in step with the firmware verifier: - an enum's value must come from the signed data, never a constant; - an interpolated-intent value part may not show a signer constant; - the bindings table (section 8) is bounded at 64 like the others. The registry still has 1,326 signable formats. Runtime tests: - _certified now also requires that the device asked for the definition, so a walk that silently took the ordinary path cannot pass; - a Wanchain transaction (tx_type) is refused on the certified path before any screen (EX35); - the containers test distinguishes the outer spenderPath from the default authority. Report: EX26 no longer claims an address stays on one OLED page. --- keepkeylib/erc7730_compiler.py | 19 ++++++-- scripts/generate-test-report.py | 7 ++- tests/test_msg_ethereum_erc7730_runtime.py | 55 +++++++++++++++------- 3 files changed, 59 insertions(+), 22 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 495a05d0..3f349766 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -1017,7 +1017,7 @@ def depth(node): "unit_decimals_max": 77, } # The verifier's table limits (erc7730_catalog.c). -TABLE_LIMITS = {1: 96, 2: 64, 3: 64, 4: 64, 5: 32, 6: 64, 7: 64} +TABLE_LIMITS = {1: 96, 2: 64, 3: 64, 4: 64, 5: 32, 6: 64, 7: 64, 8: 64} # Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. CLASS_BYTES = 6 (CLASS_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, @@ -1229,7 +1229,7 @@ def literal_class(index): formatter_arrays = [] at = 2 for _ in range(u16(formatters, 0)): - value_array, any_array = None, False + value_array, any_array, value_literal = None, False, False kind, argc = formatters[at], formatters[at + 2] at += 3 if kind not in capabilities["formatters"]: @@ -1261,6 +1261,12 @@ def literal_class(index): if (kind == 13 and role == 15 and index < len(path_classes) and isinstance(path_classes[index], tuple)): return "an embedded call's callee must come from calldata" + if (kind == 8 and role == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)): + return "an enum's value must come from the signed data" + if (role == 1 and source == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)): + value_literal = True if source == 1 and path_arrays[index] is not None: any_array = True if role == 1: @@ -1270,7 +1276,8 @@ def literal_class(index): return "formatter kind %d lacks a required argument" % kind if kind == 13 and not calldata: return "embedded calldata is executed for calldata only" - formatter_arrays.append((value_array, any_array, kind == 13)) + formatter_arrays.append((value_array, any_array, kind == 13, + value_literal)) displays = sections.get(7, b"\0\0") run_closed = False @@ -1296,10 +1303,12 @@ def literal_class(index): elif opcode == 8: iteration = None elif opcode in (3, 4): - value_array, any_array, embedded = formatter_arrays[ - a if opcode == 3 else b] + value_array, any_array, embedded, value_literal = ( + formatter_arrays[a if opcode == 3 else b]) if opcode == 3 and embedded: return "an embedded call cannot be an intent value" + if opcode == 3 and value_literal: + return "a signer constant cannot be an intent value" if opcode == 4 and a not in short_strings: return "a field label is longer than the device shows" if any_array and iteration is None: diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index cdf1ddeb..06c73fa1 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3369,7 +3369,7 @@ def _arg_shown(a): ('EX26', 'test_msg_ethereum_erc7730_runtime', 'test_multiline_values_split_between_lines', 'Long values split between lines', - 'A value too long for one confirmation is split across numbered confirmations at line boundaries, so an amount or address line stays whole.', + 'A value too long for one confirmation is split into numbered confirmations at line boundaries. Within one confirmation the board pager still wraps by pixel width, so a long line can continue on the next OLED page.', ['Numbered part', 'Next part']), ('EX27', 'test_msg_ethereum_erc7730_runtime', 'test_an_inner_definition_the_device_refuses_falls_back_to_blind', @@ -3411,6 +3411,11 @@ def _arg_shown(a): 'Depth is bounded at one', 'A call inside an inner call is shown blind; the device never requests a depth-2 definition.', ['Blind warning inside the inner call']), + ('EX35', 'test_msg_ethereum_erc7730_runtime', + 'test_a_wanchain_transaction_is_never_certified', + 'A Wanchain transaction is never certified', + 'A transaction carrying tx_type (Wanchain, valued in WAN) is refused on the certified path before any screen; the review would otherwise name its value ETH.', + []), ]), # Two-character id because all 26 letters were taken. The catalog keys on a diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index 1e141254..3396ead4 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -261,6 +261,8 @@ def _certified(self, program, arguments, preload=None, catalog=None): result, _, passes, _ = self._walk(start, envelope, arguments=arguments, catalog=catalog) self.calldata_passes = passes + # The certified path ran: the device asked for its definition. + self.assertGreater(self.definition_requests, 0) self.assertIsInstance(result, eth.EthereumTxRequest) self.assertEqual((result.signature_r, result.signature_s), (baseline.signature_r, baseline.signature_s)) @@ -553,8 +555,10 @@ def test_embedded_call_is_shown_under_a_blind_sign_warning(self): "uint8 operation)") TRANSFER = "transfer(address to,uint256 amount)" - def _exec_setup(self, amount_path=True): - params = {"calleePath": "to", "spenderPath": "@.to"} + def _exec_setup(self, amount_path=True, spender="@.to"): + params = {"calleePath": "to"} + if spender: + params["spenderPath"] = spender if amount_path: params["amountPath"] = "value" outer_descriptor = {"display": {"formats": {self.EXEC: { @@ -687,6 +691,24 @@ def test_numeric_constants_are_values(self): self._word(1) + pad), [expected]) + def test_a_wanchain_transaction_is_never_certified(self): + # tx_type marks a Wanchain transaction, whose value is WAN; the + # certified review would name it ETH. Refused before any screen. + signature = "audit(uint256 first,uint256 second)" + program = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "first", "label": "First", + "format": "amount"}]}}}}, + signature, 1, ADDRESS) + self._preload(program) + start = self._audit_start(program) + start.tx_type = 1 + result, buttons, _, _ = self._walk(start) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 definition does not match transaction") + self.assertEqual(buttons, 0) + def test_control_characters_in_a_value_are_escaped(self): signature = "note(string text)" descriptor = {"display": {"formats": {signature: { @@ -800,8 +822,8 @@ def chunk(self, request): def test_inner_calls_read_their_own_containers(self): # Inside the inner call @.value is the value it moves and @.from - # whose authority it runs with; a call inside it is shown blind. - start, arguments, outer_def, _ = self._exec_setup() + # whose authority it runs with: the outer spenderPath, or without + # one the outer contract. inner = erc7730_compiler.compile_calldata( {"display": {"formats": {self.TRANSFER: { "intent": "Transfer", "fields": [ @@ -810,18 +832,19 @@ def test_inner_calls_read_their_own_containers(self): {"path": "@.to", "label": "Token", "format": "addressName"}, ]}}}}, self.TRANSFER, 1, self.USDC) - # Give the outer call a value: the inner call moves 2 ETH. - moving = bytearray(arguments) - moving[32:64] = self._word(2 * 10 ** 18) - self._exec_certified(bytes(moving), - erc7730.Catalog((outer_def, - self._exec_inner_catalog(inner)))) - inner_fields = [s[1] for s in self._relevant() if s[0] == "Inner field"] - self.assertEqual(inner_fields, [ - "Moves:\n2 ETH", - "As:\n0x" + ADDRESS.hex(), - "Token:\n0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - ]) + usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + for spender, authority in ((None, "0x" + ADDRESS.hex()), + ("to", usdc)): + _, arguments, outer_def, _ = self._exec_setup(spender=spender) + # Give the outer call a value: the inner call moves 2 ETH. + moving = bytearray(arguments) + moving[32:64] = self._word(2 * 10 ** 18) + self._exec_certified(bytes(moving), erc7730.Catalog( + (outer_def, self._exec_inner_catalog(inner)))) + inner_fields = [s[1] for s in self._relevant() + if s[0] == "Inner field"] + self.assertEqual(inner_fields, [ + "Moves:\n2 ETH", "As:\n" + authority, "Token:\n" + usdc]) def test_embedded_calls_inside_an_iteration_are_shown_blind(self): signature = "multicall(bytes[] calls)" From b3b8792974061b00f5d98102ac8b4d3c79f2b1cc Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 09:55:32 -0500 Subject: [PATCH 10/15] test(erc7730): enum labels are marked as the signer's The device now shows a mapped enum value as "label (value)" followed by "label set by signer", like a unit's base. The runtime test and report row EX13 expect it. --- scripts/generate-test-report.py | 2 +- tests/test_msg_ethereum_erc7730_runtime.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 06c73fa1..c7a65caa 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3304,7 +3304,7 @@ def _arg_shown(a): ('EX13', 'test_msg_ethereum_erc7730_runtime', 'test_enum_labels_are_the_signers_claim_beside_the_value', 'Enum labels never replace the value', - 'A mapped value shows "label (value)"; an unmapped one shows "value (unmapped)".', + 'A mapped value shows "label (value)", marked "label set by signer"; an unmapped one shows "value (unmapped)".', ['Unmapped enum value']), ('EX14', 'test_msg_ethereum_erc7730_runtime', 'test_nft_shows_the_collection_address', diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index 3396ead4..0365b075 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -430,7 +430,7 @@ def test_enum_labels_are_the_signers_claim_beside_the_value(self): pad = self._word(OTHER_ADDRESS) self.assertEqual(self._one_field(field, self._word(1) + pad, metadata=metadata), - ["Side:\nSell (1)"]) + ["Side:\nSell (1)\nlabel set by signer"]) self.assertEqual(self._one_field(field, self._word(5) + pad, metadata=metadata), ["Side:\n5 (unmapped)"]) From 5c13b1b8149ee5223522e68d55db682f5174d452 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 10:11:38 -0500 Subject: [PATCH 11/15] test(erc7730): decline every kind of certified screen; pair both halves Re-audit round 3: - No calldata test declined an ERC-7730 screen. The new test declines each of these and requires ActionCancelled and no final sign screen: a blind-sign warning (a value too long to capture, no inner definition, a refused inner definition), the second part of a split value, and an inner field. It is report row EX36. It fails when the long-value warning ignores a decline. - The parallel-arrays test now checks each element's amount as well as its token. _walk can decline by confirmation title (cancel_title). --- scripts/generate-test-report.py | 5 ++ tests/test_msg_ethereum_erc7730_runtime.py | 83 +++++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index c7a65caa..b76db2a2 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3416,6 +3416,11 @@ def _arg_shown(a): 'A Wanchain transaction is never certified', 'A transaction carrying tx_type (Wanchain, valued in WAN) is refused on the certified path before any screen; the review would otherwise name its value ETH.', []), + ('EX36', 'test_msg_ethereum_erc7730_runtime', + 'test_declining_any_certified_screen_returns_no_signature', + 'Declining any certified screen returns no signature', + 'Declining a blind-sign warning (long value, no inner definition, refused inner definition), a later part of a split value, or an inner field aborts with no signature.', + []), ]), # Two-character id because all 26 letters were taken. The catalog keys on a diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index 0365b075..3fd22a11 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -96,7 +96,7 @@ def _first_pages(self): if code != types.ButtonRequest_Other] def _walk(self, start, envelope=b"", doc=None, change_pass=None, cancel_button=None, - arguments=None, catalog=None, altered=None): + arguments=None, catalog=None, altered=None, cancel_title=None): response = self.client.call_raw(start) self.definition_requests = 0 self.button_codes = [] @@ -111,7 +111,9 @@ def _walk(self, start, envelope=b"", doc=None, change_pass=None, cancel_button=N self.screens.append(self.client.debug.read_confirm_text()) if self.screens[-1][0] not in ORDINARY_REVIEW: self.client.capture_oled() - self.client.debug.press_yes() if buttons != cancel_button else self.client.debug.press_no() + decline = (buttons == cancel_button or + self.screens[-1][0] == cancel_title) + self.client.debug.press_no() if decline else self.client.debug.press_yes() response = self.client.call_raw(proto.ButtonAck()) elif isinstance(response, eth.EthereumClearSignDefinitionRequest) and catalog: self.definition_requests += 1 @@ -739,8 +741,8 @@ def test_a_raw_value_too_long_to_capture_is_shown_blind(self): def test_multiline_values_split_between_lines(self): # A label of 64 non-ASCII bytes escapes to 256 characters, leaving 93 - # per screen: the unknown-token body splits, but between lines, so - # the token address is never cut. + # per confirmation: the unknown-token body splits into numbered + # confirmations between lines, so no confirmation ends mid-address. label = "é" * 32 signature = "send(address token,uint256 amount)" descriptor = {"display": {"formats": {signature: { @@ -936,12 +938,14 @@ def arguments(tokens): self.assertEqual(result.message, "ERC-7730 calldata does not match definition") self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) - both = self._titled_fields(descriptor, signature, - arguments([OTHER_ADDRESS, ADDRESS])) - self.assertEqual([t for t, _ in both], - ["Signer field 1 of 2", "Signer field 2 of 2"]) - self.assertTrue(both[0][1].endswith(OTHER_ADDRESS.hex()), both[0][1]) - self.assertTrue(both[1][1].endswith(ADDRESS.hex()), both[1][1]) + self.assertEqual( + self._titled_fields(descriptor, signature, + arguments([OTHER_ADDRESS, ADDRESS])), [ + ("Signer field 1 of 2", "Amount:\n7\nunknown token\n0x" + + OTHER_ADDRESS.hex()), + ("Signer field 2 of 2", "Amount:\n8\nunknown token\n0x" + + ADDRESS.hex()), + ]) def test_a_call_at_depth_two_is_shown_blind(self): # A Safe executing a call on a second Safe: the second Safe's @@ -991,3 +995,62 @@ def chunk(self, request): "\nFunction 0xa9059cbb\nData 68 bytes"), shown[4][1]) self.assertEqual(shown[5][1], "Operation:\n0") self.assertEqual(shown[6][1], "Operation:\n0") + + + def _declined(self, program, arguments, title, preload=None, + catalog=None): + envelope = self._preload(program) if preload is None else preload() + result, _, _, _ = self._walk( + self._audit_start(program, 4 + len(arguments)), envelope, + arguments=arguments, catalog=catalog, cancel_title=title) + assert_failure(self, result, types.Failure_ActionCancelled, + "Signing cancelled by user") + self.assertEqual(self.screens[-1][0], title) + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_declining_any_certified_screen_returns_no_signature(self): + # Every certified screen is a consent: the blind-sign warnings, a + # later part of a split value and an inner field each abort when + # declined, with no signature. + signature = "blob(bytes data)" + blob = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: {"intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}}, + signature, 1, ADDRESS) + data = bytes(range(200)) + self._declined(blob, self._word(32) + self._word(len(data)) + data + + bytes(-len(data) % 32), "Blind signature") + + signature = "send(address token,uint256 amount)" + split = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: {"intent": "Send", "fields": [{ + "path": "amount", "label": "\u00e9" * 32, + "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}}, + signature, 1, ADDRESS) + self._declined(split, self._word(OTHER_ADDRESS) + self._word(10 ** 60), + "Signer field (2/2)") + + _, arguments, outer_def, inner_def = self._exec_setup() + outer = self._exec_outer[0] + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + # No inner definition: the E1 warning. + self._declined(outer, arguments, "Blind signature", preload=preload, + catalog=erc7730.Catalog((outer_def,))) + # A refused inner definition: the warning on the blind re-run. + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + self._declined(outer, arguments, "Blind signature", preload=preload, + catalog=erc7730.Catalog( + (outer_def, self._exec_inner_catalog(refused)))) + # A clear-signed inner call: its field. + self._declined(outer, arguments, "Inner field", preload=preload, + catalog=erc7730.Catalog((outer_def, inner_def))) From a8bbee7babd363db24f8e40bef13b3922a0f3f4c Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 11:11:01 -0500 Subject: [PATCH 12/15] fix(erc7730): refuse formatted constants, close lockstep gaps; wider tests Re-audit round 4 and its completeness critic: - Only a raw field may show a signer constant. A constant formatted as an amount, date, unit, enum, NFT or address looks decoded, so the mirror refuses it, as it refuses a constant embedded value or authority (callee already). The value classes return to what they were before D1. The official registry uses no such constant: 1,326 formats remain signable. - Lockstep gaps that predate this block, found by the re-audit: - fixed ABI arrays over 64 elements are refused; - program strings must be printable text; - signed enum keys use minimal two's complement (-128 is 0x80), so the device no longer refuses them. Each is tested through the mirror and the firmware validator together, and fails when reverted. Runtime tests: - test_only_a_raw_field_shows_a_signer_constant replaces test_numeric_constants_are_values; - a long typed-data value points to the walk, with no blind warning (EX37); - a refused inner definition streamed over several chunks; - two embedded calls where the first is refused and the second is still clear-signed (EX38); - unit and enum marks now come before the value. --- keepkeylib/erc7730_compiler.py | 45 ++++--- scripts/generate-test-report.py | 27 +++-- tests/test_erc7730_compiler.py | 37 ++++++ tests/test_msg_ethereum_erc7730_runtime.py | 131 +++++++++++++++++++-- 4 files changed, 205 insertions(+), 35 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 3f349766..4ff4a88b 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -339,7 +339,9 @@ def _condition_literal(node, value): if node.kind == 1 and isinstance(value, int) and not isinstance(value, bool): return 1, _unsigned_literal(value) if node.kind == 2 and isinstance(value, int) and not isinstance(value, bool): - width = max(1, (value.bit_length() + 8) // 8) + # Minimal two's complement, as the device requires: -128 is 0x80. + width = max(1, ((value if value >= 0 else ~value).bit_length() + 8) + // 8) return 2, value.to_bytes(width, "big", signed=True) if node.kind == 3 and isinstance(value, str): return 5, _hex_address(value) @@ -1033,27 +1035,26 @@ def _value_allowed(kind, role, cls): rules = { (1, 1): lambda: cls <= CLASS_STRING_REF or cls == CLASS_UINT_SMALL, (10, 1): lambda: cls == CLASS_ADDRESS, - (2, 1): lambda: cls in unsigned, - (6, 1): lambda: cls in unsigned, - (3, 1): lambda: cls in unsigned, + (2, 1): lambda: cls == CLASS_UINT, + (6, 1): lambda: cls == CLASS_UINT, + (3, 1): lambda: cls == CLASS_UINT, (3, 7): lambda: cls in unsigned, (3, 2): lambda: cls == CLASS_ADDRESS, (3, 8): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), (3, 22): lambda: cls == CLASS_ALIAS_SET, - (4, 1): lambda: cls in unsigned, + (4, 1): lambda: cls == CLASS_UINT, (4, 3): lambda: cls == CLASS_ADDRESS, - (5, 1): lambda: cls in unsigned, + (5, 1): lambda: cls == CLASS_UINT, (5, 9): lambda: cls == CLASS_DATE_ENCODING, - (7, 1): lambda: cls in unsigned, + (7, 1): lambda: cls == CLASS_UINT, (7, 4): lambda: cls == CLASS_UINT_SMALL, (7, 5): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), (7, 6): lambda: cls == CLASS_FLAG, - (8, 1): lambda: cls in (CLASS_UINT, CLASS_UINT_SMALL, CLASS_INT, - CLASS_BOOL), + (8, 1): lambda: cls in (CLASS_UINT, CLASS_INT, CLASS_BOOL), (8, 10): lambda: cls == CLASS_ENUM_MAP, (13, 1): lambda: cls == CLASS_BYTES, (13, 15): lambda: cls == CLASS_ADDRESS, - (13, 17): lambda: cls in unsigned, + (13, 17): lambda: cls == CLASS_UINT, (13, 18): lambda: cls == CLASS_ADDRESS, } rule = rules.get((kind, role)) @@ -1122,6 +1123,10 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): abi = sections.get(2, b"\0\0") nodes = [struct.unpack(">BHHHH", abi[2 + 9 * i:11 + 9 * i]) for i in range(u16(abi, 0))] + # A fixed array holds 1-64 elements (0xffff marks a dynamic one). + for kind, _, _, _, length in nodes: + if kind == 9 and (length == 0 or (length > 64 and length != 0xffff)): + return "an ABI array exceeds the device limit" string_table = sections.get(1, b"\0\0") date_strings = set() @@ -1129,6 +1134,13 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): at = 2 for index in range(u16(string_table, 0)): length = u16(string_table, at) + # Printable ASCII or well-formed UTF-8, never a control character. + try: + text = string_table[at + 2:at + 2 + length].decode("utf-8") + except UnicodeDecodeError: + return "a program string is not printable text" + if any(ch < " " or ch == "\x7f" for ch in text): + return "a program string is not printable text" if length <= capabilities.get("signer_text_max", 128): short_strings.add(index) if string_table[at + 2:at + 2 + length] in (b"timestamp", @@ -1258,12 +1270,13 @@ def literal_class(index): return "signer text is longer than the device shows" if kind == 7 and role == 4 and index not in decimals_literals: return "unit decimals exceed the device limit" - if (kind == 13 and role == 15 and index < len(path_classes) and - isinstance(path_classes[index], tuple)): - return "an embedded call's callee must come from calldata" - if (kind == 8 and role == 1 and index < len(path_classes) and - isinstance(path_classes[index], tuple)): - return "an enum's value must come from the signed data" + constant = (source == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)) + if constant and kind == 13 and role in (15, 17, 18): + return ("an embedded call's callee, value and authority must " + "come from calldata") + if constant and role == 1 and kind != 1: + return "only a raw field may show a signer constant" if (role == 1 and source == 1 and index < len(path_classes) and isinstance(path_classes[index], tuple)): value_literal = True diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index b76db2a2..09cf1e08 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3215,7 +3215,8 @@ def _arg_shown(a): 'runtime and python-keepkey\'s compiler; anything else is refused at preload, before', 'any screen):', '- Formatters: raw, amount, tokenAmount, nftName, date, duration, unit, enum,', - ' addressName, embedded calldata. Containers @.from/@.to/@.value; signed constants.', + ' addressName, embedded calldata. Containers @.from/@.to/@.value. A signed', + ' constant is shown only as a raw field, as the signer\'s own.', '- Display: intent; interpolated intent as numbered parts; fields; groups; one array', ' iterated at a time. Condition "optional" only, and it always shows.', '- Refused: slices, packed words, never/ifIn/ifNotIn/mustMatch/ifEmpty, nested', @@ -3299,12 +3300,12 @@ def _arg_shown(a): ('EX12', 'test_msg_ethereum_erc7730_runtime', 'test_phase_c_formatters_show_exact_text', 'Date, duration, unit and enum formatters', - 'Each value is formatted on device and always shown with its raw integer; a unit\'s base is marked "unit set by signer".', + 'Each value is formatted on device and always shown with its raw integer; a unit\'s base is preceded by "unit set by signer", so the mark is never on a later page than the value.', ['Unit value with raw integer']), ('EX13', 'test_msg_ethereum_erc7730_runtime', 'test_enum_labels_are_the_signers_claim_beside_the_value', 'Enum labels never replace the value', - 'A mapped value shows "label (value)", marked "label set by signer"; an unmapped one shows "value (unmapped)".', + 'A mapped value shows "label (value)" after "label set by signer"; an unmapped one shows "value (unmapped)".', ['Unmapped enum value']), ('EX14', 'test_msg_ethereum_erc7730_runtime', 'test_nft_shows_the_collection_address', @@ -3352,10 +3353,10 @@ def _arg_shown(a): 'Every inner pass replays the whole outer calldata against the reviewed digest; a change on a later pass is refused before any inner field.', []), ('EX23', 'test_msg_ethereum_erc7730_runtime', - 'test_numeric_constants_are_values', - 'Numeric constants are values', - 'A signed numeric constant is formatted like a decoded value of the same type.', - ['Constant formatted as an amount']), + 'test_only_a_raw_field_shows_a_signer_constant', + 'Only a raw field shows a signer constant', + 'A signer constant formatted as an amount (or any formatter but raw) would look like a decoded value, so preload refuses it; as a raw field it is shown as the signer\'s own field.', + ['Constant as a raw field']), ('EX24', 'test_msg_ethereum_erc7730_runtime', 'test_control_characters_in_a_value_are_escaped', 'Control characters are escaped', @@ -3374,7 +3375,7 @@ def _arg_shown(a): ('EX27', 'test_msg_ethereum_erc7730_runtime', 'test_an_inner_definition_the_device_refuses_falls_back_to_blind', 'A refused inner definition falls back to blind', - 'An inner definition the device cannot execute, or signed by an unknown key, is dropped and the call is shown blind; the outer review then continues.', + 'An inner definition the device cannot execute, or signed by an unknown key (also one refused on a later chunk of a multi-chunk stream), is dropped and the call is shown blind; the outer review then continues.', ['Blind signature warning', 'Outer review continues']), ('EX28', 'test_msg_ethereum_erc7730_runtime', 'test_inner_definition_for_another_callee_or_chain_is_refused', @@ -3421,6 +3422,16 @@ def _arg_shown(a): 'Declining any certified screen returns no signature', 'Declining a blind-sign warning (long value, no inner definition, refused inner definition), a later part of a split value, or an inner field aborts with no signature.', []), + ('EX37', 'test_msg_ethereum_erc7730_runtime', + 'test_a_long_typed_data_value_points_to_the_walk_not_blind', + 'A long typed-data value is not a blind sign', + 'The typed-data walk shows every leaf in full; a raw field too long to capture says "Shown above in full: N bytes" with no blind-sign warning, and the signature equals the ordinary one.', + ['Shown above in full']), + ('EX38', 'test_msg_ethereum_erc7730_runtime', + 'test_a_refused_inner_call_does_not_blind_the_next_one', + 'A refused inner call does not blind the next one', + 'With two embedded calls, the first inner definition is refused and shown blind; the second is still clear-signed with its own definition.', + ['Blind first call', 'Second call clear-signed']), ]), # Two-character id because all 26 letters were taken. The catalog keys on a diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index b75c7cb3..6fc2a5b8 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -518,3 +518,40 @@ def test_compiles_official_uniswap_eip712_fixture_through_firmware(): # deployment + name/chain/contract domain facts + token + network assert int.from_bytes(binding[:2], "big") == 6 _firmware_validate(compiled) + + +def test_mirror_applies_the_devices_abi_and_text_limits(): + # Each shape the device refuses at preload is refused by the mirror too, + # and a neighbour inside the limit is accepted by both. + address = "0x" + "11" * 20 + for length, refusal in ((64, None), + (65, "an ABI array exceeds the device limit")): + signature = "f(uint256[%d] a,uint256 b)" % length + descriptor = {"display": {"formats": {signature: { + "intent": "F", "fields": [ + {"path": "b", "label": "B", "format": "raw"}]}}}} + _firmware_validate(_unchecked(compile_calldata, descriptor, signature, + 1, address), refusal) + for label, refusal in (("Line one", None), + ("Line\none", "a program string is not printable text"), + ("Tab\there", "a program string is not printable text"), + ("Del\x7f", "a program string is not printable text")): + descriptor = {"display": {"formats": {"f(uint256 a)": { + "intent": "F", "fields": [ + {"path": "a", "label": label, "format": "raw"}]}}}} + _firmware_validate(_unchecked(compile_calldata, descriptor, + "f(uint256 a)", 1, address), refusal) + + +def test_signed_enum_keys_are_minimal_twos_complement(): + # -128 fits one byte (0x80); the device refuses a longer encoding. + for signature, key in (("f(int8 side)", -128), ("f(int16 side)", -32768), + ("f(int16 side)", -129), ("f(int8 side)", 127)): + descriptor = { + "metadata": {"enums": {"side": {str(key): "Edge", "1": "Long"}}}, + "display": {"formats": {signature: { + "intent": "F", "fields": [{ + "path": "side", "label": "Side", "format": "enum", + "params": {"$ref": "$.metadata.enums.side"}}]}}}} + _firmware_validate(compile_calldata(descriptor, signature, 1, + "0x" + "11" * 20), None) diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index 3fd22a11..1ff9a3e1 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -417,7 +417,7 @@ def test_phase_c_formatters_show_exact_text(self): 93784, "Lock:\n1d 2h 3m 4s\n(93784 s)"), ({"path": "a", "label": "Weight", "format": "unit", "params": {"base": "kg", "decimals": 3}}, - 93784, "Weight:\n93.784 kg\nunit set by signer\nraw 93784"), + 93784, "Weight:\nunit set by signer\n93.784 kg\nraw 93784"), ] for field, value, expected in cases: self.assertEqual( @@ -432,7 +432,7 @@ def test_enum_labels_are_the_signers_claim_beside_the_value(self): pad = self._word(OTHER_ADDRESS) self.assertEqual(self._one_field(field, self._word(1) + pad, metadata=metadata), - ["Side:\nSell (1)\nlabel set by signer"]) + ["Side:\nlabel set by signer\nSell (1)"]) self.assertEqual(self._one_field(field, self._word(5) + pad, metadata=metadata), ["Side:\n5 (unmapped)"]) @@ -684,14 +684,28 @@ def test_inner_bytes_changed_in_an_inner_pass_are_refused(self): # ---- Audit remediation: each of these used to fail mid-review, take a # fact from the wrong source, or was untested. ---- - def test_numeric_constants_are_values(self): - pad = self._word(OTHER_ADDRESS) - for constant, expected in ((5, "Fee:\n5 Wei"), (1000, "Fee:\n1000 Wei")): - self.assertEqual( - self._one_field({"value": constant, "label": "Fee", - "format": "amount"}, - self._word(1) + pad), - [expected]) + def test_only_a_raw_field_shows_a_signer_constant(self): + # Formatters show values the device decodes. A signer constant + # formatted as an amount would look decoded, so preload refuses it; + # as a raw field it is the signer's own field, and shows. + signature = "act(uint256 a,address b)" + for constant in (5, 1000): + descriptor = {"display": {"formats": {signature: { + "intent": "Act", "fields": [{ + "value": constant, "label": "Fee", "format": "amount"}]}}}} + with self.assertRaises(erc7730_compiler.DeviceCannotExecute): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS, executable_only=False) + self._load_signer() + assert_failure(self, self._raw_preload(self._envelope(program)), + types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + self.assertEqual( + self._one_field({"value": 1000, "label": "Fee"}, + self._word(1) + self._word(OTHER_ADDRESS)), + ["Fee:\n1000"]) def test_a_wanchain_transaction_is_never_certified(self): # tx_type marks a Wanchain transaction, whose value is WAN; the @@ -739,6 +753,38 @@ def test_a_raw_value_too_long_to_capture_is_shown_blind(self): [("Blind signature", "The value is too long to show"), ("Signer field", "Data:\nNot shown: 200 bytes")]) + def test_a_long_typed_data_value_points_to_the_walk_not_blind(self): + # Typed data: the walk shows every leaf in full, so a raw field too + # long to capture says where it was shown, with no blind warning. + data = bytes(range(200)) + doc = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}], + "Blob": [{"name": "data", "type": "bytes"}]}, + "primaryType": "Blob", + "domain": {"name": "Audit app", "chainId": 1, + "verifyingContract": "0x" + ADDRESS.hex()}, + "message": {"data": "0x" + data.hex()}} + program = erc7730_compiler.compile_eip712( + {"display": {"formats": {"Blob(bytes data)": { + "intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}}, + doc) + start = eth.EthereumSignTypedData(address_n=PATH, primary_type="Blob", + metamask_v4_compat=True) + baseline, _, _, _ = self._walk(start, doc=doc) + self.assertIsInstance(baseline, eth.EthereumTypedDataSignature) + envelope = self._preload(program) + result, _, _, _ = self._walk(start, envelope, doc) + self.assertIsInstance(result, eth.EthereumTypedDataSignature) + self.assertEqual(result.signature, baseline.signature) + self.assertNotIn("Blind signature", [s[0] for s in self.screens]) + self.assertIn(("Signer field", "Data:\nShown above in full: 200 bytes"), + self._first_pages()) + def test_multiline_values_split_between_lines(self): # A label of 64 non-ASCII bytes escapes to 256 characters, leaving 93 # per confirmation: the unknown-token body splits into numbered @@ -783,9 +829,21 @@ def test_an_inner_definition_the_device_refuses_falls_back_to_blind(self): {"display": {"formats": {self.TRANSFER: { "intent": "Transfer", "fields": []}}}}, self.TRANSFER, 1, self.USDC) + # Large enough to stream in several chunks, so the refusal comes on a + # later chunk and the fetch position must be reset for the outer. + large = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient %02d %s" % (i, "r" * 44), + "format": "raw"} for i in range(24)]}}}}, + self.TRANSFER, 1, self.USDC) + signed_large = self._exec_inner_catalog( + large, signer=(UNKNOWN_SIGNER_KEY, unknown)) + self.assertGreater(len(signed_large.envelope), 2 * erc7730.MAX_CHUNK) for inner in (self._exec_inner_catalog(refused), self._exec_inner_catalog( - clear, signer=(UNKNOWN_SIGNER_KEY, unknown))): + clear, signer=(UNKNOWN_SIGNER_KEY, unknown)), + signed_large): self._exec_certified(arguments, erc7730.Catalog((outer_def, inner))) shown = self._relevant() @@ -1054,3 +1112,54 @@ def preload(): # A clear-signed inner call: its field. self._declined(outer, arguments, "Inner field", preload=preload, catalog=erc7730.Catalog((outer_def, inner_def))) + + + def test_a_refused_inner_call_does_not_blind_the_next_one(self): + # Two embedded calls: the first inner definition is refused and shown + # blind; the second is still clear-signed with its own definition. + signature = "execTwo(address a,bytes da,address b,bytes db)" + other_token = bytes.fromhex("55" * 20) + outer = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: { + "intent": "Run two calls", "fields": [ + {"path": "da", "label": "First", "format": "calldata", + "params": {"calleePath": "a"}}, + {"path": "db", "label": "Second", "format": "calldata", + "params": {"calleePath": "b"}}]}}}}, + signature, 1, ADDRESS) + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + second = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient", + "format": "addressName"}]}}}}, + self.TRANSFER, 1, other_token) + call = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + padded = self._word(len(call)) + call + bytes(-len(call) % 32) + arguments = (self._word(self.USDC) + self._word(128) + + self._word(other_token) + self._word(128 + len(padded)) + + padded + padded) + self._load_signer() + outer_def = self._definition(outer, self._envelope(outer)) + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + self._certified(outer, arguments, preload=preload, + catalog=erc7730.Catalog(( + outer_def, self._exec_inner_catalog(refused), + self._exec_inner_catalog(second)))) + titles = [s[0] for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual(titles, [ + "Contract action", "Blind signature", "Signer field", + "Signer field", "Inner action", "Inner field"]) + self.assertEqual(self._relevant()[-1], + ("Inner field", "Recipient:\n0x" + OTHER_ADDRESS.hex())) From 4f114707f4401002646febc12c370b766ebd059d Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 17:33:24 -0500 Subject: [PATCH 13/15] fix(7.15): reject scalar formatter values in iterations --- keepkeylib/erc7730_compiler.py | 3 +-- tests/test_erc7730_compiler.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 4ff4a88b..edae0543 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -1326,8 +1326,7 @@ def literal_class(index): return "a field label is longer than the device shows" if any_array and iteration is None: return "an iterating value outside an iteration" - if (iteration is not None and value_array is not None and - value_array != iteration): + if iteration is not None and value_array != iteration: return "a field reads another array than its iteration" return None diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 6fc2a5b8..e65fe827 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -263,6 +263,42 @@ def test_compiles_array_iteration_separator_and_optional_visibility(): _firmware_validate(compiled) +def test_refuses_scalar_value_repeated_inside_iteration(): + descriptor = {"display": {"formats": { + "batch(address[] recipients,address fallback)": { + "intent": "Batch transfer", + "fields": [ + {"path": "recipients.[]", "label": "Recipient", + "format": "addressName", "separator": "Next recipient"}, + {"path": "fallback", "label": "Fallback", + "format": "addressName"}, + ], + } + }}} + program = bytearray(_unchecked( + compile_calldata, descriptor, + "batch(address[] recipients,address fallback)", 1, + "0x1111111111111111111111111111111111111111")) + _firmware_validate(bytes(program)) + # Replace the iterated formatter's path with the scalar formatter's path. + # The display still contains an iteration, so the device must refuse it. + offset = HEADER_SIZE + while offset < len(program): + kind = program[offset] + length = struct.unpack_from(">I", program, offset + 1)[0] + if kind == 6: + start = offset + 5 + assert struct.unpack_from(">H", program, start)[0] == 2 + assert program[start + 7:start + 9] != program[start + 14:start + 16] + program[start + 7:start + 9] = program[start + 14:start + 16] + break + offset += 5 + length + else: + pytest.fail("formatter section missing") + _firmware_validate(bytes(program), + "a field reads another array than its iteration") + + def test_refuses_nested_array_iteration_the_device_cannot_verify(): # The device's catalog verifier accepts one "[]" step per path, so a # program iterating a nested array could never be loaded. The compiler From e68dcd667db2c8a532fe9f58d6c76c88d48fa40e Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 12:53:44 -0500 Subject: [PATCH 14/15] test(7.15): disclose signer native aliases in runtime review --- tests/test_msg_ethereum_erc7730_runtime.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index 1ff9a3e1..acc459eb 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -320,7 +320,7 @@ def test_threshold_message_is_shown_beside_the_exact_amount(self): self.assertEqual(self._token_screen(self.USDC, 1500000, params), ["Amount:\nSigner: Large amount\n1.5 USDC"]) - def test_native_alias_shows_the_chains_native_asset(self): + def test_native_alias_discloses_signer_mapping_and_token_address(self): params = {"nativeCurrencyAddress": ["0x" + self.NATIVE.hex()]} # An address outside the alias set is not the native asset. self.assertEqual( @@ -329,7 +329,8 @@ def test_native_alias_shows_the_chains_native_asset(self): OTHER_ADDRESS.hex()]) self.assertEqual( self._token_screen(self.NATIVE, 1500000000000000000, params), - ["Amount:\n1.5 ETH"]) + ["Amount:\nSigner native alias:\n0x" + self.NATIVE.hex() + + "\n1.5 ETH"]) def test_address_name_marks_only_the_signing_account(self): signer = self.client.ethereum_get_address(PATH) From df376eec6ea7d705f7a70bdd31210f8cf929f97e Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 13:34:06 -0500 Subject: [PATCH 15/15] docs(7.15): keep EX8 capture mapping with alias disclosure test --- scripts/generate-test-report.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 09cf1e08..a100dfb7 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3278,10 +3278,10 @@ def _arg_shown(a): 'At or above the threshold the signer\'s message is shown, marked "Signer:", above the exact amount, never instead of it; below it no message appears.', ['Signer message above the exact amount']), ('EX8', 'test_msg_ethereum_erc7730_runtime', - 'test_native_alias_shows_the_chains_native_asset', - "Native-currency aliases name only the chain's asset", - "An address in the signer's alias set is shown as the chain's native asset; any other address stays an unknown token. The firmware token table wins over an alias.", - ['Native asset amount']), + 'test_native_alias_discloses_signer_mapping_and_token_address', + 'Native-currency aliases disclose the signer mapping and token address', + "An address in the signer's alias set is marked as a signer native alias and shown in full before the native-unit amount; any other address stays an unknown token. The firmware token table wins over an alias.", + ['Signer alias, token address and native-unit amount']), ('EX9', 'test_msg_ethereum_erc7730_runtime', 'test_address_name_marks_only_the_signing_account', '"(this wallet)" marks only the signing account',