From 67e891214086f7c7c6ebc7f75f9e3e9f27ec96a0 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 14:43:20 -0500 Subject: [PATCH 1/4] feat(7.15): complete embedded-call mirror and release evidence --- keepkeylib/erc7730.py | 12 +- keepkeylib/erc7730_compiler.py | 112 +- scripts/generate-test-report.py | 240 ++++ tests/test_erc7730_catalog.py | 13 + tests/test_erc7730_compiler.py | 80 +- tests/test_msg_ethereum_erc7730_runtime.py | 1166 ++++++++++++++++++++ 6 files changed, 1539 insertions(+), 84 deletions(-) create mode 100644 tests/test_msg_ethereum_erc7730_runtime.py 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/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index e61a31eb..edae0543 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -339,6 +339,7 @@ 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): + # 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) @@ -778,6 +779,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 @@ -994,6 +1001,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 @@ -1005,11 +1014,14 @@ 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, 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, 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 @@ -1023,24 +1035,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 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 == CLASS_UINT, + (13, 18): lambda: cls == CLASS_ADDRESS, } rule = rules.get((kind, role)) return bool(rule and rule()) @@ -1108,10 +1123,31 @@ 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() + short_strings = set() + 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", + b"blockheight"): + date_strings.add(index) + at += 2 + length + literal_table = sections.get(4, b"\0\0") literal_classes = [] decimals_literals = set() @@ -1123,6 +1159,10 @@ def device_refusal(program, capabilities=DEVICE_CAPABILITIES): 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 @@ -1140,34 +1180,6 @@ 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() - 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 @@ -1260,9 +1272,13 @@ def literal_class(index): 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" + 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 if source == 1 and path_arrays[index] is not None: any_array = True @@ -1271,7 +1287,10 @@ def literal_class(index): seen.add(role) if not required <= seen: return "formatter kind %d lacks a required argument" % kind - formatter_arrays.append((value_array, any_array, value_literal)) + if kind == 13 and not calldata: + return "embedded calldata is executed for calldata only" + formatter_arrays.append((value_array, any_array, kind == 13, + value_literal)) displays = sections.get(7, b"\0\0") run_closed = False @@ -1290,10 +1309,6 @@ 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_arrays) and formatter_arrays[a][2]: - 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 opcode == 7: if a not in iterable or iteration is not None or not calldata: return "iteration is not executed here" @@ -1301,7 +1316,14 @@ 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, 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: return "an iterating value outside an iteration" if iteration is not None and value_array != iteration: diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 3661162f..a100dfb7 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,238 @@ 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. 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', + ' 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_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', + '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 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)" 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', + '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_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', + '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 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', + 'A refused inner definition falls back to blind', + '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', + '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']), + ('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.', + []), + ('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.', + []), + ('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 # string, not a char, so this costs nothing. ('TD', 'Structured EIP-712 - The Device Reads The Document', '7.15.0', @@ -3630,6 +3867,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 +3878,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_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 diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 5e7c56e4..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 @@ -435,8 +471,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(): @@ -486,7 +523,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 @@ -517,6 +555,7 @@ def test_compiles_official_uniswap_eip712_fixture_through_firmware(): 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. @@ -552,38 +591,3 @@ def test_signed_enum_keys_are_minimal_twos_complement(): "params": {"$ref": "$.metadata.enums.side"}}]}}}} _firmware_validate(compile_calldata(descriptor, signature, 1, "0x" + "11" * 20), None) - -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") diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py new file mode 100644 index 00000000..acc459eb --- /dev/null +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -0,0 +1,1166 @@ +"""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, cancel_title=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() + 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 + 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 + # 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)) + 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_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( + 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:\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) + 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:\nunit set by signer\n93.784 kg\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:\nlabel set by signer\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, spender="@.to"): + params = {"calleePath": "to"} + if spender: + params["spenderPath"] = spender + 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_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 + # 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: { + "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_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 + # confirmations between lines, so no confirmation ends mid-address. + 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) + # 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)), + signed_large): + 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: the outer spenderPath, or without + # one the outer contract. + 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) + 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)" + 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) + 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 + # 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") + + + 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))) + + + 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 56e35858412a50375f5e33956b228a65084adc3c Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 20:09:54 -0500 Subject: [PATCH 2/4] test(erc7730): bind iterated embedded calls and refuse parallel arrays --- tests/test_msg_ethereum_erc7730_runtime.py | 46 +++++++--------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index acc459eb..c01cb49e 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -908,14 +908,15 @@ def test_inner_calls_read_their_own_containers(self): "Moves:\n2 ETH", "As:\n" + authority, "Token:\n" + usdc]) def test_embedded_calls_inside_an_iteration_are_shown_blind(self): - signature = "multicall(bytes[] calls)" + signature = "multicall((address callee,bytes data)[] calls)" descriptor = {"display": {"formats": {signature: { "intent": "Multicall", "fields": [{ - "path": "calls.[]", "label": "Call", "format": "calldata", - "params": {"calleePath": "@.to"}}]}}}} + "path": "calls.[].data", "label": "Call", "format": "calldata", + "params": {"calleePath": "calls.[].callee"}}]}}}} call = bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + self._word(1) padded = call + bytes(-len(call) % 32) - element = self._word(len(call)) + padded + element = (self._word(OTHER_ADDRESS) + self._word(64) + + 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( @@ -923,7 +924,7 @@ def test_embedded_calls_inside_an_iteration_are_shown_blind(self): 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() + + body = ("Call:\nTo 0x" + OTHER_ADDRESS.hex() + "\nFunction 0xa9059cbb\nData 68 bytes") self.assertEqual(shown, [ ("Blind signature", "The inner call is not clear-signed"), @@ -972,39 +973,20 @@ def test_an_inner_value_the_calldata_does_not_carry_is_never_shown(self): "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. + def test_parallel_arrays_are_refused_before_preload(self): + # A token formatter cannot pair amounts[i] with an independent + # tokens[i] array: the auxiliary token path must read the active array. 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) - 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()), - ]) + with self.assertRaisesRegex( + erc7730_compiler.DeviceCannotExecute, + "a field reads another array than its iteration"): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) def test_a_call_at_depth_two_is_shown_blind(self): # A Safe executing a call on a second Safe: the second Safe's From 7024e37cd2daf793cc865f2aba9a4dad29b8a1f9 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 20:39:29 -0500 Subject: [PATCH 3/4] test(7.15): align EX33 screenshot audit with preload refusal --- 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 a100dfb7..133eac43 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3403,10 +3403,10 @@ def _arg_shown(a): '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']), + 'test_parallel_arrays_are_refused_before_preload', + 'Parallel formatter arrays are refused', + 'A token path in a separate array is refused before preload; no signature or review screen is produced.', + []), ('EX34', 'test_msg_ethereum_erc7730_runtime', 'test_a_call_at_depth_two_is_shown_blind', 'Depth is bounded at one', From fb94c5c2cd6cb5e3b0b3d896a7c58fc0a8ad0a42 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 26 Sep 2026 20:44:02 -0500 Subject: [PATCH 4/4] test(7.15): capture fixed-array screens and correct EX33 audit --- scripts/generate-test-report.py | 7 ++++--- tests/test_msg_ethereum_erc7730_runtime.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 133eac43..f20b1835 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -3319,9 +3319,10 @@ def _arg_shown(a): []), ('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']), + 'Dynamic and fixed arrays show every element, numbered', + 'Dynamic and fixed arrays title each element "Signer field i of N"; an empty dynamic array shows no element and still signs.', + ['Dynamic element 1 of 2', 'Dynamic element 2 of 2', + 'Fixed element 1 of 2', 'Fixed element 2 of 2']), ('EX17', 'test_msg_ethereum_erc7730_runtime', 'test_grouped_tuple_iteration_and_optional_fields', 'Grouped tuple arrays and optional fields', diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py index c01cb49e..e0b48214 100644 --- a/tests/test_msg_ethereum_erc7730_runtime.py +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -492,6 +492,21 @@ def test_iteration_shows_every_element_numbered(self): ("Signer field 1 of 2", "Recipient:\n0x" + OTHER_ADDRESS.hex()), ("Signer field 2 of 2", "Recipient:\n0x" + ADDRESS.hex()), ]) + # A fixed ABI array has no length word. Capture still binds the + # declared count before replaying both element screens. + fixed_signature = "payFixed(address[2] recipients)" + fixed_descriptor = {"display": {"formats": {fixed_signature: { + "intent": "Pay fixed", "fields": [{ + "path": "recipients.[]", "label": "Recipient", + "format": "addressName"}]}}}} + self.assertEqual( + self._titled_fields( + fixed_descriptor, fixed_signature, + self._word(OTHER_ADDRESS) + self._word(ADDRESS)), [ + ("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)"