diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 939298d4..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) @@ -476,19 +477,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: @@ -971,15 +976,72 @@ 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 = { - "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,)))}, - "path_sources": frozenset((1,)), - "containers": frozenset(), + "formatters": { + 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, @.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, + "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, + 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 + 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 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 in unsigned, + (4, 3): lambda: cls == CLASS_ADDRESS, + (5, 1): lambda: cls in unsigned, + (5, 9): lambda: cls == CLASS_DATE_ENCODING, + (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_UINT_SMALL, + 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 @@ -999,24 +1061,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): @@ -1027,7 +1090,72 @@ 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 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 + 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() + 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 = [] paths = sections.get(3, b"\0\0") at = 2 for _ in range(u16(paths, 0)): @@ -1035,8 +1163,14 @@ 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_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] @@ -1052,17 +1186,20 @@ 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"]: 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"]: @@ -1071,15 +1208,39 @@ 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 = (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 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 for pc in range(u16(displays, 0)): opcode, _, a, b, c = struct.unpack( ">BBHHH", displays[2 + 8 * pc:10 + 8 * pc]) @@ -1087,8 +1248,17 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): 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" + 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 c6219af5..e48a80fc 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(): @@ -203,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 3 is not executed") + _firmware_validate(compiled) def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): @@ -230,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 10 is not executed") + _firmware_validate(compiled) def test_compiles_array_iteration_separator_and_optional_visibility(): @@ -361,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(): @@ -388,7 +384,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 +432,13 @@ 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. +# 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 def test_official_registry_all_calldata_formats_reach_firmware(): @@ -517,5 +517,40 @@ 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 3 is not executed") + _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)