From 97105c482efbe5e141fbe90131e32e223f4bf0a1 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 25 Sep 2026 04:00:12 -0500 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 f813519c7c1163783b0e89b5cbd70536831f5e49 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 14:28:27 -0500 Subject: [PATCH 4/4] fix(7.15): mirror phase A-C preload and text limits --- keepkeylib/erc7730_compiler.py | 69 +++++++++++++++++++++++++++++----- tests/test_erc7730_compiler.py | 36 ++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index dc61c77c..c6bad2a6 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -339,7 +339,8 @@ 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) + 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) @@ -1000,7 +1001,10 @@ def depth(node): "conditions": False, "alias_set_max": 4, "enum_max": 16, + "signer_text_max": 64, + "unit_decimals_max": 77, } +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_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, CLASS_STRING_REF, CLASS_ALIAS_SET, CLASS_UINT_SMALL, CLASS_DATE_ENCODING, @@ -1015,22 +1019,23 @@ 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, } rule = rules.get((kind, role)) @@ -1085,14 +1090,21 @@ 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))] + 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" 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) members = u16(value, 0) if kind in (8, 9) else 0 if kind == 1: cls = CLASS_UINT_SMALL if length == 1 else CLASS_UINT @@ -1112,13 +1124,35 @@ def literal_class(index): 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) + raw = string_table[at + 2:at + 2 + length] + try: + shown = raw.decode("utf-8") + except UnicodeDecodeError: + return "a program string is not printable text" + if any(ch < " " or ch == "\x7f" for ch in shown): + 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", b"blockheight"): date_strings.add(index) at += 2 + length + 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] + 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" + at += 3 + 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 = [] @@ -1162,8 +1196,10 @@ def literal_class(index): return "display conditions are not executed" formatters = sections.get(6, b"\0\0") + formatter_constants = [] at = 2 for _ in range(u16(formatters, 0)): + value_literal = False kind, argc = formatters[at], formatters[at + 2] at += 3 if kind not in capabilities["formatters"]: @@ -1188,9 +1224,20 @@ 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" + constant = (source == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)) + if constant and role == 1: + if kind != 1: + return "only a raw field may show a signer constant" + value_literal = True seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind + formatter_constants.append(value_literal) displays = sections.get(7, b"\0\0") run_closed = False @@ -1208,6 +1255,10 @@ 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 == 3 and a < len(formatter_constants) and formatter_constants[a]: + 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" return None diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 3e50efed..e48a80fc 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -518,3 +518,39 @@ 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)