diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h new file mode 100644 index 000000000..179c4c2d5 --- /dev/null +++ b/include/keepkey/firmware/signed_metadata.h @@ -0,0 +1,231 @@ +#ifndef KEEPKEY_FIRMWARE_SIGNED_METADATA_H +#define KEEPKEY_FIRMWARE_SIGNED_METADATA_H + +#include +#include +#include + +typedef struct _EthereumSignTx EthereumSignTx; + +#define METADATA_MAX_ARGS 8 +#define METADATA_MAX_METHOD_LEN 64 +#define METADATA_MAX_ARG_NAME_LEN 32 +/* Sized for TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol(<=10) + + * amount(<=32). Other formats remain capped at 32 by their own guards. */ +#define METADATA_MAX_ARG_VALUE_LEN 44 +#define METADATA_MAX_TOKEN_SYMBOL_LEN 10 +#define METADATA_MAX_KEYS 4 +#define METADATA_ALIAS_MAX_LEN 31 +/* Identity icon cap (1bpp mono RLE). Must equal the device-protocol + * LoadClearsignSigner.icon max_size and storage.h CLEARSIGN_ICON_MAX + * (static-asserted in signed_metadata.c). */ +#define METADATA_ICON_MAX 384 +/* hex(first 4 bytes of sha256(pubkey)) + NUL */ +#define METADATA_FINGERPRINT_LEN 9 + +typedef enum { + METADATA_OPAQUE = 0, + METADATA_VERIFIED = 1, + METADATA_MALFORMED = 2, +} MetadataClassification; + +/* + * Blob format versions (the first payload byte). + * + * LEGACY (v1): per-transaction. The blob carries a committed tx_hash and the + * pre-decoded argument VALUES; the host is trusted for the decode and the + * device only binds it to the signed digest (signed_metadata_enforce). This is + * the format that requires an online, per-tx signer holding the attestation + * key. + * + * SCHEMA (v2): static. The blob carries NO tx_hash and NO values — only how to + * decode the call: (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]). The DEVICE decodes the argument values + * from the exact calldata it is about to sign, so the display is bound to the + * signature by construction. No tx_hash, no per-tx signing: the catalog is + * signed ONCE, offline, and can be served from a host CDN (no hot key). + */ +#define METADATA_VERSION_LEGACY 0x01 +#define METADATA_VERSION_SCHEMA 0x02 + +/* + * Argument display formats. The goal of clear-signing is that the device + * answers WHO the user is dealing with (validated contract address, protocol + * name), WHAT the transaction does (method + human-readable typed args: + * recipient, "Amount: 1,000 USDC"), and WHY the decode can be trusted + * (signer attestation bound to the exact tx hash). RAW/BYTES hex dumps are + * the fallback, not the product. + */ +typedef enum { + ARG_FORMAT_RAW = 0, /* hex dump (first 16 bytes) */ + ARG_FORMAT_ADDRESS = 1, /* 20 bytes -> full EIP-55 address, never truncated */ + ARG_FORMAT_AMOUNT = 2, /* big-endian uint256 -> raw integer, "wei" */ + ARG_FORMAT_BYTES = 3, /* hex dump (first 16 bytes) */ + /* Attested printable label, e.g. protocol: "Uniswap V2". Same character + * rules as the signer alias minus length (printable subset, no '%'). */ + ARG_FORMAT_STRING = 4, + /* decimals(1) + symbol_len(1) + symbol(<=10, [A-Za-z0-9]) + amount(1..32 + * big-endian). Rendered as a decimal-scaled amount with the symbol, e.g. + * "1000 USDC"; all-0xFF 32-byte amounts render "UNLIMITED ". */ + ARG_FORMAT_TOKEN_AMOUNT = 5, +} ArgFormat; + +typedef struct { + char name[METADATA_MAX_ARG_NAME_LEN + 1]; + ArgFormat format; + uint8_t value[METADATA_MAX_ARG_VALUE_LEN]; + uint16_t value_len; +} MetadataArg; + +typedef struct { + uint8_t version; + uint32_t chain_id; + uint8_t contract_address[20]; + uint8_t selector[4]; + uint8_t tx_hash[32]; + char method_name[METADATA_MAX_METHOD_LEN + 1]; + uint8_t num_args; + MetadataArg args[METADATA_MAX_ARGS]; + MetadataClassification classification; + uint32_t timestamp; + uint8_t key_id; + uint8_t signature[64]; + uint8_t recovery; +} SignedMetadata; + +bool signed_metadata_available(void); + +/* True when the stored v2 (schema) metadata was decoded from the current tx's + * calldata by the most recent signed_metadata_matches_tx() call. Reset at the + * top of every matches_tx() so it reflects only that call (never a stale prior + * match). The v2 enforce path requires it; exported for unit testing. */ +bool signed_metadata_schema_decoded(void); + +/* True when the matched schema is v2 AND the transaction moves native value. + * A v2 schema cannot express a value binding, so the caller MUST still show + * the amount/recipient screen; only the raw-calldata screen may be replaced + * by the decoded display. */ +bool signed_metadata_schema_moves_value(void); + +void signed_metadata_clear(void); + +/* + * Runtime-loaded clearsign signers (phase 1: the ONLY verification path). + * + * A signer is a compressed secp256k1 pubkey + display alias loaded into a + * key slot at the host's request, gated by a mandatory on-device confirm + * (see fsm_msgLoadClearsignSigner). Loaded signers live in RAM only and are + * gone on reboot. Metadata verified by a loaded signer always shows a + * warning screen naming the alias before any clearsign page — only the + * built-in (phase 2) keys sign warning-free. + */ + +/* Pure validation: slot in range and not occupied by a built-in key, pubkey a + * valid compressed secp256k1 point, alias non-empty printable ASCII within + * METADATA_ALIAS_MAX_LEN. No state, no I/O. */ +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias); + +/* Store a signer into a slot. Caller (the FSM handler) MUST have passed + * signed_metadata_signer_valid() and obtained on-device user confirmation + * first — this function is the post-consent write, nothing more. + * + * icon (optional, icon_len<=384, 1bpp mono RLE) is kept as the session icon for + * the slot; icon_len==0 => text-only identity. RC18 rejects persist=true before + * changing the session slot because public storage lacks authenticated + * integrity. */ +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist); + +/* Resolve a slot's alias / icon from the RAM session copy. alias returns NULL + * and icon returns false when the slot has no signer / no icon (text-only). + * Used by the per-tx confirm. */ +const char* signed_metadata_signer_alias(uint8_t key_id); +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out); + +/* The LoadClearsignSigner consent screen: leads with the identity's logo (if + * any) + alias + fingerprint. Returns true iff the user confirmed. The FSM + * handler calls this before storing the signer. */ +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len); + +/* Drop all runtime-loaded signers (and any metadata they verified). */ +void signed_metadata_clear_signers(void); + +/* out = hex of the first 4 bytes of sha256(pubkey[33]), NUL-terminated. + * Shown at load-confirm and on the per-tx warning screen so the user can + * correlate the two. */ +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]); + +/* True when the currently stored metadata was verified by a runtime-loaded + * signer (=> its confirm flow is warning-first, never "Insight Verified"). */ +bool signed_metadata_from_loaded_signer(void); +/* True when key_id currently resolves to a runtime-loaded signer. This lets + * non-EVM callers preserve their normal Advanced-mode review after showing an + * additive schema decode. */ +bool signed_metadata_signer_is_runtime(uint8_t key_id); +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id); + +/* Generic attestation check reusing the (chain-agnostic) clear-sign signer + * keyring: returns true iff a signer is loaded/pinned for `key_id` AND the + * 64-byte compact ECDSA signature `sig` verifies over sha256(data). Used by + * non-EVM paths (e.g. Solana signed token definitions) that want to trust + * host-supplied data only when a loaded signer attests to it. */ +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len); + +/* Fingerprint (hex of sha256(pubkey)[0:4]) of the signer loaded/pinned in + * `key_id`, written NUL-terminated to `out`. Returns false if no signer is + * present. Lets non-EVM callers disambiguate signers (aliases are not unique) + * the same way the EVM per-tx warning does. */ +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]); +/* Display gate: does this metadata plausibly describe `msg`? Binds contract + * address, selector and chain id so the wrong method is never shown. The + * authoritative full-tx binding is enforced later by signed_metadata_enforce(). + */ +bool signed_metadata_matches_tx(const EthereumSignTx* msg); +bool signed_metadata_confirm(void); + +/* True once a verified confirm has suppressed the raw-data confirmation, i.e. + * the signature is now gated on the metadata matching the final tx hash. */ +bool signed_metadata_relied(void); + +/* Authoritative binding, called after the real Ethereum sighash is finalized + * (in send_signature, the only point it exists). Returns true if signing may + * proceed: either no metadata was relied upon, or the relied-upon metadata's + * committed tx_hash equals `hash`. Fail-closed on any mismatch. */ +bool signed_metadata_enforce(const uint8_t hash[32]); + +/* Pure enforcement decision, exported for unit testing. Given the module flags + * and the metadata's committed tx hash, decides whether signing may proceed for + * the just-finalized `hash`. signed_metadata_enforce() is a thin wrapper that + * feeds the module state into this function. No state, no I/O. */ +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash); + +/* Pure enforcement decision for v2 (static schema) blobs, exported for unit + * testing. v2 has no committed tx_hash; the binding is structural (args decoded + * from the signed calldata), so signing proceeds when the relied-upon metadata + * is available, VERIFIED, and was actually decoded (`decoded`) — no digest + * comparison. `decoded` must be the recorded result of decode_v2_args() for + * this signing operation, not inferred from call order. + * signed_metadata_enforce() dispatches here when the stored blob's version is + * METADATA_VERSION_SCHEMA. */ +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification); + +const SignedMetadata* signed_metadata_get(void); + +#endif diff --git a/include/keepkey/firmware/tiny-json.h b/include/keepkey/firmware/tiny-json.h index 7ba75ec38..d4a76a019 100644 --- a/include/keepkey/firmware/tiny-json.h +++ b/include/keepkey/firmware/tiny-json.h @@ -35,6 +35,10 @@ #include #include +#ifdef __cplusplus +extern "C" { +#endif + #define json_containerOf(ptr, type, member) \ ((type*)((char*)ptr - offsetof(type, member))) @@ -66,7 +70,6 @@ typedef struct json_s { jsonType_t type; } json_t; -extern int errno; /** Parse a string to get a json. * @param str String pointer with a JSON object. It will be modified. * @param mem Array of json properties to allocate. diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index d9f0d7a19..dcf21b9d4 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -31,6 +31,7 @@ set(sources reset.c ripple.c ripple_base58.c + signed_metadata.c signing.c signtx_tendermint.c solana.c diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c new file mode 100644 index 000000000..25d064fe0 --- /dev/null +++ b/lib/firmware/signed_metadata.c @@ -0,0 +1,1036 @@ +#include "keepkey/firmware/signed_metadata.h" + +#include "keepkey/board/confirm_sm.h" +#include "keepkey/board/draw.h" // draw_bitmap_mono_rle_valid +#include "keepkey/board/layout.h" // RUNTIME_ICON + layout_set_runtime_icon +#include "keepkey/board/variant.h" // Image / AnimationFrame +#include "keepkey/board/util.h" +#include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/address.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +static bool metadata_available = false; +static bool relied_on_metadata = false; +static bool metadata_signer_loaded = false; +/* v2 only: set true once decode_v2_args() has decoded this metadata's args from + * the tx calldata. The v2 enforce path REQUIRES it — v2 has no committed + * tx_hash, so this is the explicit proof (not an implicit call-order + * assumption) that the displayed values came from the calldata being signed. */ +/* Set during matching: this tx carries native value, so the amount screen + * must NOT be suppressed even though the schema matched. */ +static bool metadata_schema_moves_value = false; +static bool metadata_schema_decoded = false; +static SignedMetadata stored_metadata; + +/* Phase 1 ships with NO built-in verification keys: every clearsign signer is + * loaded at runtime via LoadClearsignSigner. Phase 2 restores the production + * key. */ + +/* Runtime-loaded signers. RAM only — cleared on reboot by construction. RC18 + * deliberately rejects persistent trust anchors: the public storage section + * has no authenticated integrity against physical flash modification. */ +static uint8_t loaded_pubkeys[METADATA_MAX_KEYS][33]; +static char loaded_aliases[METADATA_MAX_KEYS][METADATA_ALIAS_MAX_LEN + 1]; +/* Per-slot session icon (1bpp mono RLE). icon_len==0 => text-only identity. */ +#if !ZCASH_PRIVACY +static uint8_t loaded_icons[METADATA_MAX_KEYS][METADATA_ICON_MAX]; +static uint8_t loaded_icon_w[METADATA_MAX_KEYS]; +static uint8_t loaded_icon_h[METADATA_MAX_KEYS]; +static uint16_t loaded_icon_len[METADATA_MAX_KEYS]; +#endif + +static bool read_u8(const uint8_t** cursor, const uint8_t* end, uint8_t* out) { + if ((size_t)(end - *cursor) < 1) { + return false; + } + + *out = **cursor; + *cursor += 1; + return true; +} + +static bool read_be_u16(const uint8_t** cursor, const uint8_t* end, + uint16_t* out) { + if ((size_t)(end - *cursor) < 2) { + return false; + } + + *out = ((uint16_t)(*cursor)[0] << 8) | (*cursor)[1]; + *cursor += 2; + return true; +} + +static bool read_be_u32(const uint8_t** cursor, const uint8_t* end, + uint32_t* out) { + if ((size_t)(end - *cursor) < 4) { + return false; + } + + *out = ((uint32_t)(*cursor)[0] << 24) | ((uint32_t)(*cursor)[1] << 16) | + ((uint32_t)(*cursor)[2] << 8) | (*cursor)[3]; + *cursor += 4; + return true; +} + +static bool read_bytes(const uint8_t** cursor, const uint8_t* end, uint8_t* out, + size_t size) { + if ((size_t)(end - *cursor) < size) { + return false; + } + + memcpy(out, *cursor, size); + *cursor += size; + return true; +} + +/* method_name and arg names render through confirm() bodies exactly like + * STRING values and signer aliases do — hold them to the same allowlist + * (printable ASCII, '%' excluded) so no metadata-carried text can embed + * control bytes or format specifiers. Only a trusted signer could author + * such a blob, but the charset rule should not depend on who signs. */ +static bool display_text_ok(const uint8_t* text, size_t len) { + for (size_t i = 0; i < len; i++) { + if (text[i] < 0x20 || text[i] > 0x7e || text[i] == '%') { + return false; + } + } + return true; +} + +static bool read_string(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint16_t value_len = 0; + if (!read_be_u16(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +static bool read_arg_name(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint8_t value_len = 0; + if (!read_u8(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +/* Per-format value validation, fail-closed at parse time. STRING and + * TOKEN_AMOUNT carry display semantics, so their byte layout is enforced + * before anything is stored; legacy formats keep their original 32-byte cap + * (METADATA_MAX_ARG_VALUE_LEN grew only to fit TOKEN_AMOUNT). */ +static bool arg_value_ok(uint8_t format, const uint8_t* value, uint16_t len) { + switch (format) { + case ARG_FORMAT_STRING: { + /* Attested printable label ("protocol: Uniswap V2"). Rendered through + * confirm() bodies: printable ASCII only, '%' excluded. */ + if (len == 0 || len > 32) { + return false; + } + for (uint16_t i = 0; i < len; i++) { + if (value[i] < 0x20 || value[i] > 0x7e || value[i] == '%') { + return false; + } + } + return true; + } + case ARG_FORMAT_TOKEN_AMOUNT: { + /* decimals(1) + symbol_len(1) + symbol + amount(1..32 BE) */ + if (len < 4) { + return false; + } + uint8_t decimals = value[0]; + uint8_t symlen = value[1]; + if (decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (uint16_t)(2 + symlen) >= len || len - 2 - symlen > 32) { + return false; + } + for (uint8_t i = 0; i < symlen; i++) { + char c = (char)value[2 + i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + } + return true; + } + default: + return len <= 32; + } +} + +/* chain_id(4) + contract(20) + selector(4) — shared by both blob versions. */ +static bool parse_common_head(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + return read_be_u32(cursor, end, &out->chain_id) && + read_bytes(cursor, end, out->contract_address, + sizeof(out->contract_address)) && + read_bytes(cursor, end, out->selector, sizeof(out->selector)); +} + +/* classification(1) + timestamp(4) + key_id(1) + sig(64) + recovery(1), then + * the cursor must land exactly on `end` — identical for v1 and v2. */ +static bool parse_trailer(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + uint8_t classification = 0; + if (!read_u8(cursor, end, &classification) || classification > 2 || + !read_be_u32(cursor, end, &out->timestamp) || + !read_u8(cursor, end, &out->key_id) || + !read_bytes(cursor, end, out->signature, sizeof(out->signature)) || + !read_u8(cursor, end, &out->recovery) || *cursor != end) { + return false; + } + out->classification = (MetadataClassification)classification; + return true; +} + +/* v1 args: name + format + explicit (host-decoded) value. */ +static bool parse_v1_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + uint16_t value_len = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format) || format > ARG_FORMAT_TOKEN_AMOUNT || + !read_be_u16(cursor, end, &value_len) || + value_len > METADATA_MAX_ARG_VALUE_LEN || + !read_bytes(cursor, end, arg->value, value_len) || + !arg_value_ok(format, arg->value, value_len)) { + return false; + } + arg->format = (ArgFormat)format; + arg->value_len = value_len; + } + return true; +} + +/* v2 args: name + display format only (NO value — decoded from calldata later). + * TOKEN_AMOUNT additionally carries its static decimals + symbol, pre-stored as + * the value prefix [decimals, symlen, symbol...] so decode_v2_args() only has + * to append the 32-byte amount word. v2 supports the fixed single-word ABI + * types ADDRESS / AMOUNT / TOKEN_AMOUNT; anything else is out of scope -> blind + * sign. */ +static bool parse_v2_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format)) { + return false; + } + switch (format) { + case ARG_FORMAT_ADDRESS: + case ARG_FORMAT_AMOUNT: + /* BYTES covers an opaque fixed word — an order/request id, say — which + * a router genuinely cannot render as an address or an amount. It still + * consumes exactly one 32-byte ABI word, so structural completeness is + * unaffected; only the rendering differs (hex, first 16 bytes). */ + case ARG_FORMAT_BYTES: + arg->value_len = 0; /* filled from the tx calldata at decode time */ + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + uint8_t decimals = 0, symlen = 0; + if (!read_u8(cursor, end, &decimals) || + !read_u8(cursor, end, &symlen) || decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (size_t)(end - *cursor) < symlen) { + return false; + } + arg->value[0] = decimals; + arg->value[1] = symlen; + for (uint8_t j = 0; j < symlen; j++) { + char c = (char)(*cursor)[j]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + arg->value[2 + j] = (uint8_t)c; + } + *cursor += symlen; + arg->value_len = (uint16_t)(2 + symlen); + break; + } + default: + return false; + } + arg->format = (ArgFormat)format; + } + return true; +} + +static bool parse_metadata_binary(const uint8_t* payload, size_t payload_len, + SignedMetadata* out) { + const uint8_t* cursor = payload; + const uint8_t* end = payload + payload_len; + memset(out, 0, sizeof(*out)); + + if (!read_u8(&cursor, end, &out->version)) { + return false; + } + + if (out->version == METADATA_VERSION_LEGACY) { + /* Min: version(1)+chain_id(4)+contract(20)+selector(4)+tx_hash(32)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 136 */ + if (payload_len < 136 || !parse_common_head(&cursor, end, out) || + !read_bytes(&cursor, end, out->tx_hash, sizeof(out->tx_hash)) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v1_args(&cursor, end, out)) { + return false; + } + } else if (out->version == METADATA_VERSION_SCHEMA) { + /* Min (0 args): version(1)+chain_id(4)+contract(20)+selector(4)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 104 (no tx_hash) */ + if (payload_len < 104 || !parse_common_head(&cursor, end, out) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v2_args(&cursor, end, out)) { + return false; + } + } else { + return false; + } + + return parse_trailer(&cursor, end, out); +} + +/* + * v2 decode: fill each schema arg's value from the transaction calldata. + * + * All v2 args are fixed single 32-byte ABI head words, laid out sequentially + * from offset 4 (right after the selector). We require the ENTIRE calldata to + * be exactly selector + num_args words, wholly present in the initial chunk — + * so the device decodes, displays, AND signs the same bytes with nothing hidden + * in a later chunk or trailing the words. That structural completeness is what + * binds the displayed decode to the signature; v2 has no tx_hash. + */ +static bool decode_v2_args(SignedMetadata* md, const EthereumSignTx* msg) { + uint32_t expected = 4u + 32u * (uint32_t)md->num_args; + uint32_t initsz = msg->data_initial_chunk.size; + uint32_t total = msg->has_data_length ? msg->data_length : initsz; + if (total != expected || initsz != expected) { + return false; + } + + for (uint8_t i = 0; i < md->num_args; i++) { + const uint8_t* word = msg->data_initial_chunk.bytes + 4 + 32u * i; + MetadataArg* arg = &md->args[i]; + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: + /* ABI address is a left-zero-padded 20-byte value; reject dirty high + * bytes rather than silently truncate (they could hide meaning). */ + for (int j = 0; j < 12; j++) { + if (word[j] != 0) { + return false; + } + } + memcpy(arg->value, word + 12, 20); + arg->value_len = 20; + break; + case ARG_FORMAT_AMOUNT: + case ARG_FORMAT_BYTES: + memcpy(arg->value, word, 32); + arg->value_len = 32; + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + /* value holds [decimals, symlen, symbol] from parse; append the amount. + * Derive the prefix from symlen (value[1]), NOT the current value_len, + * so a repeated decode of the same arg is idempotent (value_len already + * includes a previously-appended amount; value[1] does not change). */ + uint16_t prefix = (uint16_t)(2 + arg->value[1]); + if ((size_t)prefix + 32 > METADATA_MAX_ARG_VALUE_LEN) { + return false; + } + memcpy(arg->value + prefix, word, 32); + arg->value_len = (uint16_t)(prefix + 32); + break; + } + default: + return false; + } + } + return true; +} + +static void bn_from_metadata_bytes(const uint8_t* value, size_t value_len, + bignum256* out) { + uint8_t padded[32] = {0}; + if (value_len > sizeof(padded)) { + value_len = sizeof(padded); + } + memcpy(padded + (sizeof(padded) - value_len), value, value_len); + bn_read_be(padded, out); + memzero(padded, sizeof(padded)); +} + +bool signed_metadata_available(void) { return metadata_available; } + +bool signed_metadata_schema_decoded(void) { return metadata_schema_decoded; } + +bool signed_metadata_schema_moves_value(void) { + return metadata_schema_moves_value; +} + +void signed_metadata_clear(void) { + memzero(&stored_metadata, sizeof(stored_metadata)); + metadata_available = false; + relied_on_metadata = false; + metadata_signer_loaded = false; + metadata_schema_decoded = false; +} + +void signed_metadata_clear_signers(void) { + memzero(loaded_pubkeys, sizeof(loaded_pubkeys)); + memzero(loaded_aliases, sizeof(loaded_aliases)); +#if !ZCASH_PRIVACY + memzero(loaded_icons, sizeof(loaded_icons)); + memzero(loaded_icon_w, sizeof(loaded_icon_w)); + memzero(loaded_icon_h, sizeof(loaded_icon_h)); + memzero(loaded_icon_len, sizeof(loaded_icon_len)); +#endif + /* Metadata verified by a now-dropped signer must not outlive it. */ + signed_metadata_clear(); +} + +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias) { + curve_point point; + size_t alias_len; + + if (key_id >= METADATA_MAX_KEYS || !pubkey || pubkey_len != 33 || !alias) { + return false; + } + + /* Alias is rendered INSIDE quotes on the load screen and the per-tx warning + * ("Trust signer '%s' ..."). Restrict to a strict allowlist — letters, + * digits, space, '-' and '_' — so a host-chosen alias cannot break out of + * its quoted region or inject a semantic trust claim (e.g. a quote to close + * the quotes, or "." / "(" to append "verified by KeepKey."). '%' is also + * excluded so it can never reach the format string as a specifier. */ + alias_len = strlen(alias); + if (alias_len == 0 || alias_len > METADATA_ALIAS_MAX_LEN) { + return false; + } + for (size_t i = 0; i < alias_len; i++) { + char c = alias[i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + + /* Compressed form only — ecdsa_read_pubkey would read 65 bytes for an + * uncompressed 0x04 prefix, past our 33-byte buffer. Requiring 0x02/0x03 + * also excludes the all-zero "empty slot" sentinel. */ + if (pubkey[0] != 0x02 && pubkey[0] != 0x03) { + return false; + } + return ecdsa_read_pubkey(&secp256k1, pubkey, &point) == 1; +} + +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist) { + /* Fail before changing the RAM slot. A caller asking for persistence must + * never receive a session-only downgrade it could mistake for durable trust. + * Persistence can return only after authenticated storage binding exists. */ + if (persist || key_id >= METADATA_MAX_KEYS) { + return false; + } + memcpy(loaded_pubkeys[key_id], pubkey, sizeof(loaded_pubkeys[key_id])); + strlcpy(loaded_aliases[key_id], alias, sizeof(loaded_aliases[key_id])); + + /* A load without an icon clears any prior one for the slot (icon_len + * already validated <= max by the caller — belt-and-braces here). */ + bool has_icon = icon && icon_len > 0 && icon_len <= METADATA_ICON_MAX; + + /* Session icon into the RAM working slot. The Orchard build omits this + * cosmetic cache to preserve its tight SRAM margin; signers remain usable + * and render text-only after the mandatory load confirmation. */ +#if !ZCASH_PRIVACY + memzero(loaded_icons[key_id], sizeof(loaded_icons[key_id])); + if (has_icon) { + memcpy(loaded_icons[key_id], icon, icon_len); + loaded_icon_w[key_id] = icon_w; + loaded_icon_h[key_id] = icon_h; + loaded_icon_len[key_id] = icon_len; + } else { + loaded_icon_w[key_id] = 0; + loaded_icon_h[key_id] = 0; + loaded_icon_len[key_id] = 0; + } +#else + (void)has_icon; + (void)icon_w; + (void)icon_h; +#endif + + /* Replacing a signer invalidates anything the old one verified. */ + signed_metadata_clear(); + return true; +} + +/* Resolve the alias for a session slot. */ +const char* signed_metadata_signer_alias(uint8_t key_id) { + if (key_id >= METADATA_MAX_KEYS) return NULL; + if (loaded_pubkeys[key_id][0] != 0x00) return loaded_aliases[key_id]; + return NULL; +} + +/* Resolve the icon for a session slot. Returns false for a text-only slot. */ +/* An icon is renderable only if its geometry fits the confirm's icon column + * AND its RLE stream decodes exactly to that geometry. This is the single + * choke point for session icons: signed_metadata_signer_icon() is what both the + * load-confirm and the per-tx identity screen call, and the per-tx screen + * stages the frame itself (it never goes through stage_runtime_icon). Fail + * closed to a text-only identity: a missing logo is cosmetic, an over-wide one + * erases the alias, fingerprint and the "NOT verified by KeepKey" warning. */ +#if !ZCASH_PRIVACY +static bool icon_renderable(const uint8_t* icon, uint16_t icon_len, + uint8_t icon_w, uint8_t icon_h) { + if (!icon || icon_len == 0) return false; + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON) return false; + if (icon_h == 0 || icon_h > 64) return false; + return draw_bitmap_mono_rle_valid(icon, (uint32_t)icon_len, icon_w, icon_h); +} +#endif + +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out) { + if (key_id >= METADATA_MAX_KEYS) return false; + if (loaded_pubkeys[key_id][0] != 0x00) { +#if ZCASH_PRIVACY + (void)icon_out; + (void)w_out; + (void)h_out; + (void)len_out; + return false; +#else + if (loaded_icon_len[key_id] == 0) return false; + if (!icon_renderable(loaded_icons[key_id], loaded_icon_len[key_id], + loaded_icon_w[key_id], loaded_icon_h[key_id])) { + return false; + } + if (icon_out) *icon_out = loaded_icons[key_id]; + if (w_out) *w_out = loaded_icon_w[key_id]; + if (h_out) *h_out = loaded_icon_h[key_id]; + if (len_out) *len_out = loaded_icon_len[key_id]; + return true; +#endif + } + return false; +} + +/* Render an AnimationFrame from a stored icon into the confirm's left column. + * Image + frame are the CALLER's (must outlive the synchronous confirm); this + * only wires them up. Returns RUNTIME_ICON when an icon was set, else NO_ICON. + * Positioning tuned on device — icon column is ~40px, height 64px. */ +static IconType stage_runtime_icon(Image* img, AnimationFrame* frame, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + if (!icon || icon_len == 0) return NO_ICON; + /* Fail closed on an over-wide icon rather than drawing it at x=0: text begins + * at x=40 and the icon is drawn AFTER the text, so a wider icon would paint + * over the alias, fingerprint and the "NOT verified by KeepKey" warning. + * The load handler already checks this, but enforce it again at the point of + * use. Dropping the logo degrades to a text-only identity; letting it erase + * the warning does not. */ + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON || icon_h == 0 || + icon_h > 64) { + return NO_ICON; + } + img->w = icon_w; + img->h = icon_h; + img->length = icon_len; + img->data = icon; + /* Center inside the confirm's left icon column (LEFT_MARGIN_WITH_ICON=40px). + * Vertically center in the 64px height. */ + frame->x = (uint16_t)((LEFT_MARGIN_WITH_ICON - icon_w) / 2); + frame->y = (icon_h < 64) ? (uint16_t)((64 - icon_h) / 2) : 0; + frame->duration = 0; + /* Decoder does value*color/100; color=100 => data bytes are direct 0-255. */ + frame->color = 100; + frame->image = img; + layout_set_runtime_icon(frame); + return RUNTIME_ICON; +} + +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + Image icon_img; + AnimationFrame icon_frame; + IconType id_icon = stage_runtime_icon(&icon_img, &icon_frame, icon, icon_w, + icon_h, icon_len); + + char body[160]; + memset(body, 0, sizeof(body)); + /* Lead with the identity (its logo + alias + fingerprint). The trust model + * hangs on this consent; the fingerprint reappears on every per-tx screen. */ + snprintf(body, sizeof(body), + "Trust '%s' (%s) for this session to describe transactions? NOT " + "verified by KeepKey.", + alias, fingerprint); + bool ok = confirm_with_icon(ButtonRequestType_ButtonRequest_Other, id_icon, + _("Load Clearsigner"), "%s", body); + layout_set_runtime_icon(NULL); + return ok; +} + +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]) { + uint8_t digest[32]; + sha256_Raw(pubkey, 33, digest); + data2hex(digest, 4, out); + memzero(digest, sizeof(digest)); +} + +bool signed_metadata_from_loaded_signer(void) { + return metadata_available && metadata_signer_loaded; +} + +/* Resolve the verification key for a slot. */ +static const uint8_t* metadata_pubkey_for(uint8_t key_id, bool* is_loaded) { + *is_loaded = false; + if (key_id >= METADATA_MAX_KEYS) { + return NULL; + } + if (loaded_pubkeys[key_id][0] != 0x00) { + *is_loaded = true; + return loaded_pubkeys[key_id]; + } + return NULL; +} + +bool signed_metadata_signer_is_runtime(uint8_t key_id) { + bool is_loaded = false; + return metadata_pubkey_for(key_id, &is_loaded) != NULL && is_loaded; +} + +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]) { + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + signed_metadata_pubkey_fingerprint(pubkey, out); + return true; +} + +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len) { + if (!data || data_len == 0 || !sig || sig_len != 64) { + return false; + } + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + uint8_t digest[32]; + sha256_Raw(data, data_len, digest); + bool ok = ecdsa_verify_digest(&secp256k1, pubkey, sig, digest) == 0; + memzero(digest, sizeof(digest)); + return ok; +} + +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id) { + uint8_t digest[32]; + size_t signed_len; + bool is_loaded = false; + const uint8_t* pubkey; + + signed_metadata_clear(); + + pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode")) || + !payload || payload_len < 65) { + return METADATA_MALFORMED; + } + + if (!parse_metadata_binary(payload, payload_len, &stored_metadata) || + stored_metadata.key_id != key_id) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + signed_len = payload_len - sizeof(stored_metadata.signature) - 1; + sha256_Raw(payload, signed_len, digest); + + if (ecdsa_verify_digest(&secp256k1, pubkey, stored_metadata.signature, + digest) != 0) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + metadata_available = true; + metadata_signer_loaded = is_loaded; + return stored_metadata.classification; +} + +bool signed_metadata_matches_tx(const EthereumSignTx* msg) { + /* Reset the v2 decode proof up front: it must reflect ONLY the current call. + * Any early return below (unavailable, wrong contract/selector/chain) leaves + * it false, so a stale `true` from a prior successful match can never let + * signed_metadata_enforce() pass for a v2 blob that did not decode this tx. + */ + metadata_schema_decoded = false; + + if (!metadata_available || !msg || + stored_metadata.classification != METADATA_VERIFIED || + msg->to.size != sizeof(stored_metadata.contract_address) || + msg->data_initial_chunk.size < sizeof(stored_metadata.selector)) { + return false; + } + + /* Contract address binding */ + if (memcmp(stored_metadata.contract_address, msg->to.bytes, + sizeof(stored_metadata.contract_address)) != 0) { + return false; + } + + /* Function selector binding */ + if (memcmp(stored_metadata.selector, msg->data_initial_chunk.bytes, + sizeof(stored_metadata.selector)) != 0) { + return false; + } + + /* Chain ID binding */ + if ((msg->has_chain_id ? msg->chain_id : 0) != stored_metadata.chain_id) { + return false; + } + + if (stored_metadata.version == METADATA_VERSION_SCHEMA) { + /* v2 commits to calldata only — never to msg->value. A v2 match otherwise + * suppresses the native-value confirm screen in ethereum.c, which would + * let a payable method clear-sign an ETH transfer whose amount is never + * shown. Rather than refuse every payable call (which forced blind-signing + * on exactly the routes that most need review), record that this tx moves + * value; ethereum.c keeps the amount/recipient screen when it does. The + * device reads that amount from the transaction it is signing, so nothing + * unattested is displayed and the schema stays transaction-independent. */ + metadata_schema_moves_value = false; + for (uint32_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) { + metadata_schema_moves_value = true; + break; + } + } + /* v2 has no committed values or tx_hash: decode the args straight from the + * calldata this tx will sign. Success here means the schema fully accounts + * for the calldata (decode_v2_args enforces exact length + presence), so + * the display is bound to the signature structurally — nothing is enforced + * later against a digest (there is no tx_hash). A decode failure falls + * through to the normal blind-sign path. Record the decode explicitly: + * signed_metadata_enforce() requires it for v2, so a signature can never be + * emitted for a v2 blob whose args were not decoded from this tx. */ + metadata_schema_decoded = decode_v2_args(&stored_metadata, msg); + return metadata_schema_decoded; + } + + /* v1 only gates what we DISPLAY (so a benign-looking method screen can't be + * shown for the wrong call). The metadata commits to the full tx hash; that + * is enforced against the real signed digest in signed_metadata_enforce() + * because the digest does not exist until send_signature() finalizes it. */ + return true; +} + +/* Renders the clearsign screens in sequence. When a signer with an icon is + * loaded, its logo (the compass) is set as RUNTIME_ICON and STAYS set for the + * whole flow, so every screen — identity, method, contract, each arg — carries + * it. The caller (signed_metadata_confirm) clears the runtime icon once on + * return, covering every early-exit path. */ +static bool signed_metadata_confirm_screens(void) { + char body[128]; + /* Compass shown on every screen once a signer with an icon is loaded. */ + IconType screen_icon = NO_ICON; + Image icon_img; + AnimationFrame icon_frame; + + if (metadata_signer_loaded) { + /* Lead with the loaded IDENTITY (logo, if any, + alias + fingerprint) + * BEFORE any clearsign page. The user approved this identity as their + * trust anchor, so showing it — not a scary "NOT verified by KeepKey" + * banner — is the honest framing. The fingerprint stays reachable so a + * swapped provider is still detectable. */ + uint8_t key_id = stored_metadata.key_id; + bool is_loaded = false; + const uint8_t* pk = metadata_pubkey_for(key_id, &is_loaded); + const char* alias = signed_metadata_signer_alias(key_id); + char fingerprint[METADATA_FINGERPRINT_LEN]; + if (pk) { + signed_metadata_pubkey_fingerprint(pk, fingerprint); + } else { + strlcpy(fingerprint, "????????", sizeof(fingerprint)); + } + if (!alias) alias = "unknown"; + + /* Draw the identity logo in the confirm's left icon column if one was + * loaded. Image + frame are local — valid for the synchronous confirm + * call, then the runtime icon is cleared. (Positioning tuned on device.) */ + const uint8_t* icon_data; + uint8_t icon_w, icon_h; + uint16_t icon_len; + if (signed_metadata_signer_icon(key_id, &icon_data, &icon_w, &icon_h, + &icon_len)) { + icon_img.w = icon_w; + icon_img.h = icon_h; + icon_img.length = icon_len; + icon_img.data = icon_data; + icon_frame.x = 0; + icon_frame.y = (icon_h < 52) ? (uint16_t)((52 - icon_h) / 2 + 6) : 6; + icon_frame.duration = 0; + /* Decoder computes pixel = data * color / 100, so color=100 makes the + * icon's data bytes direct 0-255 intensities (matches the built-in + * icons). color=0xff would overflow uint8 and corrupt every pixel. */ + icon_frame.color = 100; + icon_frame.image = &icon_img; + layout_set_runtime_icon(&icon_frame); + screen_icon = RUNTIME_ICON; + } + + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "%s (%s)\ndescribes this tx.", alias, + fingerprint); + /* Runtime icon stays set from here on — every subsequent screen shows the + * compass. Cleared once by the caller. */ + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Identity", "%s", body)) { + return false; + } + + /* Method screen — same identity compass, no "Insight Verified" branding + * (that presentation is reserved for the built-in phase-2 keys). */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Call:\n%s", stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Clearsign", "%s", body)) { + return false; + } + } else { + /* Screen 1: Verified method — use review_with_icon for trust indicator */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Verified call:\n%s", + stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + VERIFIED_ICON, "Insight Verified", "%s", body)) { + return false; + } + } + + /* Screen 2: Contract address — ALWAYS show full address, never truncate. + * Truncation is a spoofing vector (attacker crafts matching prefix+suffix). + */ + char contract_addr[43] = "0x"; + ethereum_address_checksum(stored_metadata.contract_address, contract_addr + 2, + false, stored_metadata.chain_id); + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Contract:\n%s", contract_addr); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + + /* Screen 3..N: Each decoded argument */ + for (uint8_t i = 0; i < stored_metadata.num_args; i++) { + MetadataArg* arg = &stored_metadata.args[i]; + memset(body, 0, sizeof(body)); + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: { + char addr_full[43] = "0x"; + if (arg->value_len != 20) { + return false; + } + ethereum_address_checksum(arg->value, addr_full + 2, false, + stored_metadata.chain_id); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, addr_full); + break; + } + case ARG_FORMAT_AMOUNT: { + bignum256 amount; + bn_from_metadata_bytes(arg->value, arg->value_len, &amount); + /* Check for MAX_UINT256 (unlimited approval) */ + bool is_max = true; + for (uint16_t j = 0; j < arg->value_len; j++) { + if (arg->value[j] != 0xFF) { + is_max = false; + break; + } + } + if (is_max && arg->value_len == 32) { + snprintf(body, sizeof(body), "%s:\nUNLIMITED", arg->name); + } else { + char formatted[48]; + bn_format(&amount, NULL, " wei", 0, 0, false, formatted, + sizeof(formatted)); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, formatted); + } + break; + } + case ARG_FORMAT_STRING: { + /* Attested printable label, validated at parse (arg_value_ok). */ + char text[33]; + memcpy(text, arg->value, arg->value_len); + text[arg->value_len] = '\0'; + snprintf(body, sizeof(body), "%s:\n%s", arg->name, text); + break; + } + case ARG_FORMAT_TOKEN_AMOUNT: { + /* decimals + symbol + big-endian amount, validated at parse. + * This is the "Amount: 1,000 USDC" the clear-signing plan calls for + * instead of a raw wei integer. */ + uint8_t decimals = arg->value[0]; + uint8_t symlen = arg->value[1]; + char suffix[METADATA_MAX_TOKEN_SYMBOL_LEN + 2]; + suffix[0] = ' '; + memcpy(suffix + 1, arg->value + 2, symlen); + suffix[1 + symlen] = '\0'; + + const uint8_t* amt = arg->value + 2 + symlen; + uint16_t amt_len = arg->value_len - 2 - symlen; + bool is_max = amt_len == 32; + for (uint16_t j = 0; j < amt_len && is_max; j++) { + if (amt[j] != 0xFF) { + is_max = false; + } + } + if (is_max) { + snprintf(body, sizeof(body), "%s:\nUNLIMITED%s", arg->name, suffix); + } else { + bignum256 amount; + bn_from_metadata_bytes(amt, amt_len, &amount); + char formatted[48]; + bn_format(&amount, NULL, suffix, decimals, 0, false, formatted, + sizeof(formatted)); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, formatted); + } + break; + } + case ARG_FORMAT_BYTES: + case ARG_FORMAT_RAW: + default: { + char hex[(METADATA_MAX_ARG_VALUE_LEN * 2) + 1]; + size_t display_len = arg->value_len > 16 ? 16 : (size_t)arg->value_len; + data2hex(arg->value, display_len, hex); + snprintf(body, sizeof(body), "%s:\n%s%s", arg->name, hex, + arg->value_len > 16 ? "..." : ""); + break; + } + } + + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + } + + /* User approved the decoded who/what/why. From here the raw-data confirm is + * suppressed, so the signature MUST be bound to this metadata's tx hash. */ + relied_on_metadata = true; + return true; +} + +bool signed_metadata_confirm(void) { + if (!metadata_available || + stored_metadata.classification != METADATA_VERIFIED) { + return false; + } + bool ok = signed_metadata_confirm_screens(); + /* Single cleanup for every screen-flow exit — the runtime icon frame lives on + * the helper's stack, so it must not outlive this call. */ + layout_set_runtime_icon(NULL); + return ok; +} + +bool signed_metadata_relied(void) { return relied_on_metadata; } + +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash) { + if (!relied) { + return true; /* signature was not gated by metadata */ + } + /* Fail closed: relied on metadata but it's gone, not verified, or the signed + * digest differs from what was displayed → refuse to emit a signature. + * tx_hash is 32 bytes (see SignedMetadata). */ + return hash != NULL && stored_hash != NULL && available && + classification == METADATA_VERIFIED && + memcmp(stored_hash, hash, 32) == 0; +} + +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification) { + /* v2 (static schema) has no committed tx_hash. Its binding is structural: the + * args were decoded from the exact calldata being signed, and that calldata + * cannot change between decode and sign within one signing operation. So if + * we relied on a verified v2 decode, signing may proceed; there is no digest + * to compare. `decoded` is the explicit proof that decode_v2_args() ran and + * succeeded for this signing operation — required rather than inferred from + * call order, since v2 has no digest fallback. If we did not rely on the + * metadata, signing was never gated by it. */ + return !relied || + (available && decoded && classification == METADATA_VERIFIED); +} + +bool signed_metadata_enforce(const uint8_t hash[32]) { + if (metadata_available && + stored_metadata.version == METADATA_VERSION_SCHEMA) { + return signed_metadata_enforce_schema_decision( + relied_on_metadata, metadata_available, metadata_schema_decoded, + stored_metadata.classification); + } + return signed_metadata_enforce_decision( + relied_on_metadata, metadata_available, stored_metadata.classification, + stored_metadata.tx_hash, hash); +} + +const SignedMetadata* signed_metadata_get(void) { + return metadata_available ? &stored_metadata : NULL; +} diff --git a/lib/firmware/tiny-json.c b/lib/firmware/tiny-json.c index c915eff58..80f0e9e21 100644 --- a/lib/firmware/tiny-json.c +++ b/lib/firmware/tiny-json.c @@ -33,7 +33,9 @@ // #include -int errno = 0; +/* Renamed from `errno` to avoid colliding with the libc macro on + * glibc/MinGW (where errno expands to (*_errno())). Write-only, never read. */ +int json_errno = 0; /** Structure to handle a heap of JSON properties. */ typedef struct jsonStaticPool_s { @@ -73,7 +75,7 @@ static bool isEndOfPrimitive(char ch); json_t const* json_createWithPool(char* str, jsonPool_t* pool) { char* ptr = goBlank(str); if (!ptr || (*ptr != '{' && *ptr != '[')) { - errno = -1; + json_errno = -1; return 0; } json_t* obj = pool->init(pool); @@ -82,7 +84,7 @@ json_t const* json_createWithPool(char* str, jsonPool_t* pool) { obj->u.c.child = 0; ptr = objValue(ptr, obj, pool); if (!ptr) { - errno = -2; + json_errno = -2; return 0; } return obj; diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 6c7262aeb..b2c14c81d 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -6,6 +6,7 @@ set(sources ethereum.cpp nano.cpp recovery.cpp + signed_metadata.cpp ripple.cpp storage.cpp usb_rx.cpp diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp new file mode 100644 index 000000000..db609de40 --- /dev/null +++ b/unittests/firmware/signed_metadata.cpp @@ -0,0 +1,1780 @@ +/* + * Unit tests for the EVM clear-signing ("Insight") signed-metadata module. + * + * Phase 1 ships with NO built-in verification keys: every signer is loaded + * at runtime (signed_metadata_store_signer, + * reached in production through the user-confirmed LoadClearsignSigner FSM + * handler). The fixture loads the CI test key (02e3b3015c...ab5107) into + * slot 3 with alias "CI Test"; all vectors are signed in-process with the + * matching private key (f6d19e15...068a260) and embed key_id=3. + * + * No OLED/button I/O is exercised: signed_metadata_process() and + * signed_metadata_matches_tx() never draw, and signed_metadata_confirm() is + * only called on its no-I/O early-return guards. The relied-path enforce truth + * table is tested through the pure, exported signed_metadata_enforce_decision() + * (see SECTION 2), since relied_on_metadata is only set inside confirm()'s + * interactive tail. + */ + +extern "C" { +#include "messages-ethereum.pb.h" /* full EthereumSignTx definition */ +#include "keepkey/board/draw.h" /* draw_bitmap_mono_rle (icon decoder) */ +#include "keepkey/board/layout.h" /* LEFT_MARGIN_WITH_ICON */ +#include "keepkey/firmware/signed_metadata.h" +#include "keepkey/firmware/solana.h" /* SolanaTokenInfo, solana_token_info_trusted */ +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include + +namespace { + +/* Test signing key. Its compressed pubkey is loaded into slot 3 by the fixture. + */ +const uint8_t TEST_PRIV[32] = {0xf6, 0xd1, 0x9e, 0x15, 0xa4, 0x38, 0x5f, 0x03, + 0xb7, 0x8b, 0x5a, 0x1e, 0x16, 0x14, 0xe7, 0xd9, + 0xa1, 0x04, 0xd8, 0x1f, 0x73, 0x24, 0x49, 0x87, + 0x56, 0xe5, 0x71, 0x90, 0x40, 0x68, 0xa2, 0x60}; + +/* Compressed pubkey of TEST_PRIV; loaded into slot 3 by the fixture. */ +const uint8_t EXPECTED_SLOT3_PUB[33] = { + 0x02, 0xe3, 0xb3, 0x01, 0x5c, 0x47, 0xdd, 0xca, 0xab, 0xe4, 0xf8, + 0xe8, 0x72, 0xf1, 0xed, 0x8f, 0x09, 0xca, 0x14, 0x5a, 0x8d, 0x81, + 0x77, 0x0d, 0x92, 0x21, 0x3d, 0x56, 0xda, 0x31, 0xab, 0x51, 0x07}; + +const uint8_t TEST_KEY_ID = 3; + +/* Deterministic, opaque test data. Only internal consistency matters. */ +const uint8_t CONTRACT_A[20] = {0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, + 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, + 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48}; +const uint8_t CONTRACT_B[20] = {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}; +const uint8_t SEL_TRANSFER[4] = {0xa9, 0x05, 0x9c, 0xbb}; +const uint8_t SEL_APPROVE[4] = {0x09, 0x5e, 0xa7, 0xb3}; +const uint8_t TX_HASH[32] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20}; +const uint8_t RECIPIENT[20] = {0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, + 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53}; +const uint8_t AMOUNT32[32] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0x03, 0xe8}; + +/* ---- byte writers ------------------------------------------------------- */ + +void put_u8(std::vector& v, uint8_t x) { v.push_back(x); } +void put_be16(std::vector& v, uint16_t x) { + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_be32(std::vector& v, uint32_t x) { + v.push_back((uint8_t)(x >> 24)); + v.push_back((uint8_t)(x >> 16)); + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_bytes(std::vector& v, const uint8_t* b, size_t n) { + v.insert(v.end(), b, b + n); +} + +/* ---- metadata builder --------------------------------------------------- */ + +struct Arg { + std::string name; + uint8_t format; + std::vector value; + int value_len_override; // -1 => use value.size() +}; + +Arg mk_arg(const std::string& name, uint8_t format, const uint8_t* value, + size_t value_len) { + Arg a; + a.name = name; + a.format = format; + a.value.assign(value, value + value_len); + a.value_len_override = -1; + return a; +} + +struct Spec { + uint8_t version; + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::vector tx_hash; + std::string method; + std::vector args; + uint8_t classification; + uint32_t timestamp; + uint8_t key_id; + int method_len_override; // -1 => use method.size() + int num_args_override; // -1 => use args.size() +}; + +/* Canonical VERIFIED metadata: transfer(to:ADDRESS, amount:AMOUNT) on chain 1. + */ +Spec base_spec() { + Spec s; + s.version = 0x01; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.tx_hash.assign(TX_HASH, TX_HASH + 32); + s.method = "transfer"; + s.args.push_back(mk_arg("to", ARG_FORMAT_ADDRESS, RECIPIENT, 20)); + s.args.push_back(mk_arg("amount", ARG_FORMAT_AMOUNT, AMOUNT32, 32)); + s.classification = METADATA_VERIFIED; + s.timestamp = 0; + s.key_id = TEST_KEY_ID; + s.method_len_override = -1; + s.num_args_override = -1; + return s; +} + +/* Serialize the signed region (version .. key_id), exactly matching + * parse_metadata_binary() / serialize_metadata(). */ +std::vector build_body(const Spec& s) { + std::vector b; + put_u8(b, s.version); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_bytes(b, s.tx_hash.data(), s.tx_hash.size()); + + uint16_t mlen = s.method_len_override >= 0 ? (uint16_t)s.method_len_override + : (uint16_t)s.method.size(); + put_be16(b, mlen); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + + uint8_t na = s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size(); + put_u8(b, na); + for (const Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + uint16_t vl = a.value_len_override >= 0 ? (uint16_t)a.value_len_override + : (uint16_t)a.value.size(); + put_be16(b, vl); + put_bytes(b, a.value.data(), a.value.size()); + } + + put_u8(b, s.classification); + put_be32(b, s.timestamp); + put_u8(b, s.key_id); + return b; +} + +/* sha256(body) -> ecdsa sign with TEST_PRIV -> append sig(64) + recovery(1). + * Mirrors signed_metadata_process(): signed_len = payload_len - 64 - 1. */ +std::vector sign_body(std::vector body) { + uint8_t digest[32]; + sha256_Raw(body.data(), body.size(), digest); + uint8_t sig[64]; + uint8_t pby = 0; + int rc = ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, NULL); + EXPECT_EQ(rc, 0); + body.insert(body.end(), sig, sig + 64); + body.push_back((uint8_t)(27 + pby)); + return body; +} + +std::vector base_blob() { return sign_body(build_body(base_spec())); } + +void make_msg(EthereumSignTx* msg, const uint8_t contract[20], + const uint8_t* data, size_t data_len, bool has_chain, + uint32_t chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain; +} + +/* A standard transfer() calldata chunk that matches base_spec(). */ +void make_matching_msg(EthereumSignTx* msg) { + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + make_msg(msg, CONTRACT_A, data, sizeof(data), /*has_chain=*/true, 1); +} + +const char* TEST_ALIAS = "CI Test"; + +void set_advanced_mode_for_test(bool enabled) { + /* The full xunit binary may already have initialized emulator flash in an + * earlier fixture (notably Authenticator). Re-running storage_init() then + * attempts to migrate/decrypt an already-live shadow store. The allocation + * is the shared source of truth, and also keeps this suite runnable alone. */ + if (storage_getLocation() == FLASH_INVALID) { + setup(); + storage_init(); + } + ASSERT_TRUE(storage_setPolicy("AdvancedMode", enabled)); +} + +class SignedMetadataTest : public ::testing::Test { + protected: + void SetUp() override { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + } + void TearDown() override { + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); + } + + void ExpectMalformed(const std::vector& blob, uint8_t key_id) { + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), key_id), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + } +}; + +/* ===================================================================== * + * signed_metadata_process — happy path via a runtime-loaded signer + * ===================================================================== */ + +TEST_F(SignedMetadataTest, DerivedPubkeyMatchesSlot3) { + uint8_t pub[33]; + ecdsa_get_public_key33(&secp256k1, TEST_PRIV, pub); + EXPECT_EQ(memcmp(pub, EXPECTED_SLOT3_PUB, sizeof(pub)), 0) + << "TEST_PRIV must derive the loaded slot-3 test pubkey"; +} + +TEST_F(SignedMetadataTest, ValidVerifiedSlot3) { + std::vector blob = base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->classification, METADATA_VERIFIED); + EXPECT_EQ(m->chain_id, 1u); + EXPECT_STREQ(m->method_name, "transfer"); + EXPECT_EQ(m->num_args, 2); + EXPECT_EQ(memcmp(m->contract_address, CONTRACT_A, 20), 0); + EXPECT_EQ(memcmp(m->selector, SEL_TRANSFER, 4), 0); + EXPECT_EQ(memcmp(m->tx_hash, TX_HASH, 32), 0); + EXPECT_EQ(m->key_id, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, RuntimeMetadataIsInertOutsideAdvancedMode) { + std::vector blob = base_blob(); + set_advanced_mode_for_test(false); + ExpectMalformed(blob, TEST_KEY_ID); + + const uint8_t data[] = "advanced-mode-gate"; + uint8_t digest[32]; + uint8_t sig[64]; + sha256_Raw(data, sizeof(data) - 1, digest); + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, data, sizeof(data) - 1, sig, sizeof(sig))); + + set_advanced_mode_for_test(true); +} + +TEST_F(SignedMetadataTest, ValidOpaqueClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; // 0 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_TRUE(signed_metadata_available()); // available, but not VERIFIED + EXPECT_NE(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, SelfDeclaredMalformedWithValidSignature) { + /* A trusted signer can self-declare MALFORMED(2). Signature verifies, so + * process() returns MALFORMED but leaves the (inert) metadata available. It + * must never be displayed or relied upon. */ + Spec s = base_spec(); + s.classification = METADATA_MALFORMED; // 2 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_TRUE(signed_metadata_available()); + EXPECT_NE(signed_metadata_get(), nullptr); + + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); // gated on VERIFIED + EXPECT_FALSE(signed_metadata_confirm()); // gated on VERIFIED +} + +/* ===================================================================== * + * signed_metadata_process — key-slot guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, KeyIdOutOfRange) { + ExpectMalformed(base_blob(), /*key_id=*/4); // >= METADATA_MAX_KEYS +} + +TEST_F(SignedMetadataTest, EmptyRotationSlot) { + Spec s = base_spec(); + s.key_id = 1; // slot 1: no built-in key, nothing loaded + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/1); +} + +TEST_F(SignedMetadataTest, NullPayload) { + EXPECT_EQ(signed_metadata_process(nullptr, 200, TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, EmbeddedKeyIdMismatch) { + Spec s = base_spec(); + s.key_id = 2; // embedded != protocol key_id (3) + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/3); +} + +TEST_F(SignedMetadataTest, SignatureVerificationFails) { + std::vector blob = base_blob(); + blob[146] ^= 0x01; // flip first signature byte (sig starts after 146B body) + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_process — length guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, PayloadShorterThan65) { + std::vector blob = base_blob(); + blob.resize(64); // process() early guard: payload_len < 65 + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, PayloadBetween65And135) { + std::vector blob = base_blob(); + blob.resize(100); // passes <65 guard, fails parser <136 minimum + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, TrailingByteAfterRecovery) { + std::vector blob = base_blob(); + blob.push_back(0x00); // cursor != end + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MissingRecoveryByte) { + std::vector blob = base_blob(); + blob.pop_back(); // truncated tail: read recovery fails + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * parse_metadata_binary — field guards (all re-signed so the PARSE guard, + * not the signature check, is what rejects the blob) + * ===================================================================== */ + +TEST_F(SignedMetadataTest, BadVersion) { + Spec s = base_spec(); + s.version = 0x02; + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, EmptyMethodName) { + Spec s = base_spec(); + s.method = ""; // 2-byte length prefix == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameTooLong) { + Spec s = base_spec(); + s.method = std::string(65, 'A'); // > METADATA_MAX_METHOD_LEN (64) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameLengthOverrun) { + /* Length prefix claims 64 but only "transfer" (8B) is the method; the read + * consumes downstream bytes and parsing misaligns -> MALFORMED. The clean + * read_string short-read guard is unreachable under the >=136 floor (after + * the 63-byte fixed prefix at least 73 bytes always remain), so this pins + * the observable contract rather than a specific internal branch. The + * corrupted signature byte makes rejection deterministic even in the + * vanishingly unlikely event the misaligned parse re-aligns to the end. */ + Spec s = base_spec(); + s.method_len_override = 64; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, NumArgsTooMany) { + Spec s = base_spec(); + s.num_args_override = 9; // > METADATA_MAX_ARGS (8) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameEmpty) { + Spec s = base_spec(); + s.args[0] = mk_arg("", ARG_FORMAT_ADDRESS, RECIPIENT, 20); // name_len == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameTooLong) { + Spec s = base_spec(); + std::string long_name(33, 'x'); // > METADATA_MAX_ARG_NAME_LEN (32) + s.args[0] = mk_arg(long_name, ARG_FORMAT_ADDRESS, RECIPIENT, 20); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgFormatOutOfRange) { + Spec s = base_spec(); + s.args[0].format = 6; // > ARG_FORMAT_TOKEN_AMOUNT (5) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_STRING (attested printable label) ----------------------- */ + +TEST_F(SignedMetadataTest, StringArgAccepted) { + Spec s = base_spec(); + const char* label = "Uniswap V2"; + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, (const uint8_t*)label, + strlen(label)); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[0].format, ARG_FORMAT_STRING); + EXPECT_EQ(memcmp(m->args[0].value, label, strlen(label)), 0); +} + +TEST_F(SignedMetadataTest, StringArgRejectsUnprintableAndPercent) { + const uint8_t nl[] = {'a', '\n', 'b'}; + Spec s = base_spec(); + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, nl, sizeof(nl)); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + const uint8_t pct[] = {'a', '%', 's'}; + Spec s2 = base_spec(); + s2.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, sizeof(pct)); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + Spec s3 = base_spec(); + s3.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, 0); // empty string + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_TOKEN_AMOUNT (decimals + symbol + amount) --------------- */ + +std::vector token_amount_value(uint8_t decimals, + const std::string& symbol, + const std::vector& amount) { + std::vector v; + v.push_back(decimals); + v.push_back((uint8_t)symbol.size()); + v.insert(v.end(), symbol.begin(), symbol.end()); + v.insert(v.end(), amount.begin(), amount.end()); + return v; +} + +TEST_F(SignedMetadataTest, TokenAmountAccepted) { + /* 1.00 USDC: 1000000 raw, 6 decimals */ + std::vector amt = {0x0F, 0x42, 0x40}; + std::vector val = token_amount_value(6, "USDC", amt); + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(m->args[1].value_len, val.size()); +} + +TEST_F(SignedMetadataTest, TokenAmountUnlimited32BytesAccepted) { + /* UNLIMITED approve: 32 x 0xFF + symbol -> value_len 38 (> old 32 cap) */ + std::vector amt(32, 0xFF); + std::vector val = token_amount_value(6, "USDC", amt); + EXPECT_EQ(val.size(), 38u); // 1+1+4+32 + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +TEST_F(SignedMetadataTest, TokenAmountRejectsBadLayout) { + Spec s = base_spec(); + /* symbol chars outside [A-Za-z0-9] */ + std::vector bad_sym = token_amount_value(6, "US-C", {0x01}); + s.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_sym.data(), bad_sym.size()); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + /* decimals > 36 */ + Spec s2 = base_spec(); + std::vector bad_dec = token_amount_value(37, "USDC", {0x01}); + s2.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_dec.data(), bad_dec.size()); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + /* symbol_len runs past the value (no amount bytes left) */ + Spec s3 = base_spec(); + std::vector no_amt = {6, 4, 'U', 'S', 'D', 'C'}; + s3.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, no_amt.data(), no_amt.size()); + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); + + /* legacy formats must NOT accept the larger 44-byte cap */ + Spec s4 = base_spec(); + std::vector big(40, 0xAB); + s4.args[1] = mk_arg("amount", ARG_FORMAT_AMOUNT, big.data(), big.size()); + ExpectMalformed(sign_body(build_body(s4)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueTooLong) { + Spec s = base_spec(); + uint8_t big[33] = {0}; + s.args[0] = mk_arg("to", ARG_FORMAT_BYTES, big, 33); // > 32 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueLengthOverrun) { + /* value_len prefix claims 32 but only 4 value bytes follow; the read eats + * into the fixed tail and parsing misaligns -> MALFORMED. As with the method + * case, the read_bytes short-read guard is dominated by the >=71-byte fixed + * tail, so this asserts the observable MALFORMED outcome. */ + Spec s = base_spec(); + uint8_t four[4] = {0xde, 0xad, 0xbe, 0xef}; + Arg a = mk_arg("amount", ARG_FORMAT_AMOUNT, four, 4); + a.value_len_override = 32; + s.args[1] = a; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ClassificationOutOfRange) { + Spec s = base_spec(); + s.classification = 3; // > 2 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_matches_tx — display gate + * ===================================================================== */ + +TEST_F(SignedMetadataTest, MatchesTxAllBindingsMatch) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotAvailable) { + signed_metadata_clear(); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNullMsg) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_matches_tx(nullptr)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotVerifiedClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongToSize) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.to.size = 19; // not 20 (e.g. contract-create has 0) + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxDataTooShortForSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.data_initial_chunk.size = 3; // < 4 + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongContract) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_B, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_APPROVE, 4); // approve, not transfer + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_A, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongChainId) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + + EthereumSignTx wrong_chain; + make_msg(&wrong_chain, CONTRACT_A, data, sizeof(data), true, 137); + EXPECT_FALSE(signed_metadata_matches_tx(&wrong_chain)); + + EthereumSignTx no_chain; + make_msg(&no_chain, CONTRACT_A, data, sizeof(data), false, + 0); // treated as 0 + EXPECT_FALSE(signed_metadata_matches_tx(&no_chain)); +} + +/* ===================================================================== * + * signed_metadata_confirm — no-I/O early guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, ConfirmNotAvailable) { + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_confirm()); +} + +TEST_F(SignedMetadataTest, ConfirmNotVerified) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_FALSE(signed_metadata_confirm()); +} + +/* ===================================================================== * + * signed_metadata_enforce — module-level not-relied path (reachable + * without confirm()'s interactive tail) and clear() reset + * ===================================================================== */ + +TEST_F(SignedMetadataTest, EnforceNotReliedAlwaysAllows) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_FALSE(signed_metadata_relied()); // process() never sets relied + + uint8_t wrong[32]; + memcpy(wrong, TX_HASH, 32); + wrong[0] ^= 0xFF; + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); + EXPECT_TRUE(signed_metadata_enforce(wrong)); + EXPECT_TRUE(signed_metadata_enforce(nullptr)); +} + +TEST_F(SignedMetadataTest, ClearResetsAllState) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_TRUE(signed_metadata_available()); + + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_FALSE(signed_metadata_relied()); + EXPECT_EQ(signed_metadata_get(), nullptr); + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); // not relied +} + +/* ===================================================================== * + * Runtime signer loading — the phase-1 trust path + * ===================================================================== */ + +TEST_F(SignedMetadataTest, NoSignerLoadedRejects) { + signed_metadata_clear_signers(); // undo the fixture's load + ExpectMalformed(base_blob(), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, FromLoadedSignerTracksMetadata) { + EXPECT_FALSE(signed_metadata_from_loaded_signer()); // nothing processed + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_from_loaded_signer()); + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_from_loaded_signer()); +} + +TEST_F(SignedMetadataTest, ClearSignersDropsKeyAndMetadata) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + ExpectMalformed(blob, TEST_KEY_ID); // the key itself is gone too +} + +TEST_F(SignedMetadataTest, StoreSignerReplacementInvalidatesOldKey) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + uint8_t priv2[32]; + memcpy(priv2, TEST_PRIV, sizeof(priv2)); + priv2[31] ^= 0x5a; // a different valid scalar + uint8_t pub2[33]; + ecdsa_get_public_key33(&secp256k1, priv2, pub2); + signed_metadata_store_signer(TEST_KEY_ID, pub2, "Replacement", NULL, 0, 0, 0, + false); + + /* Replacing a signer drops metadata the old one verified... */ + EXPECT_FALSE(signed_metadata_available()); + /* ...and the old key no longer verifies anything. */ + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ---- signed_metadata_signer_valid (pure) ------------------------------ */ + +/* + * ── Identity-icon decoder hardening ──────────────────────────────────────── + * + * The clearsign identity icon is HOST-SUPPLIED and is rendered on the trust + * screen, so the decoder is an attack surface reachable before the user has + * approved anything. Regression guards for two review findings: + * + * (1) A 0x80 (n = -128) packet is undecodable: draw_bitmap_mono_rle's counter + * is int8_t, so -(-128) wraps back to -128 and breaks its `> 0` invariant. + * Under NDEBUG the assert is compiled out and decoding proceeded with a + * negative counter (signed-overflow UB). Must fail closed instead. + * (2) icon_width must not exceed LEFT_MARGIN_WITH_ICON: text starts at x=40 + * and the icon is drawn AFTER the text, so a wider icon overwrites the + * alias / fingerprint / "NOT verified by KeepKey" warning. + */ +namespace { + +struct IconCanvas { + uint8_t buf[64 * 256]; + Canvas canvas; + IconCanvas() { + memset(buf, 0, sizeof(buf)); + canvas.buffer = buf; + canvas.width = 256; + canvas.height = 64; + canvas.dirty = false; + } +}; + +bool decode_icon(const std::vector& data, uint16_t w, uint16_t h, + IconCanvas* ic) { + Image img; + img.w = w; + img.h = h; + img.length = (uint32_t)data.size(); + img.data = data.data(); + AnimationFrame frame; + frame.x = 0; + frame.y = 0; + frame.duration = 0; + frame.color = 100; /* value*100/100 => data bytes land verbatim */ + frame.image = &img; + return draw_bitmap_mono_rle(&ic->canvas, &frame, /*erase=*/false); +} + +} // namespace + +TEST(SignedMetadataIcon, GoldenVectorDecodes) { + /* The vector published in messages-ethereum.proto: 03 FF FF 00 (w=2,h=2). */ + IconCanvas ic; + ASSERT_TRUE(decode_icon({0x03, 0xFF, 0xFF, 0x00}, 2, 2, &ic)); + EXPECT_EQ(ic.buf[0 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[0 * 256 + 1], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 1], 0x00); +} + +TEST(SignedMetadataIcon, LiteralOf128IsRejected) { + /* n = 0x80 = -128. Spec-valid under the old doc, undecodable in fact: + * previously asserted (debug) or decoded with a negative counter (NDEBUG). */ + std::vector data; + data.push_back(0x80); + for (int i = 0; i < 128; i++) data.push_back(0xAA); + IconCanvas ic; + EXPECT_FALSE(decode_icon(data, 128, 1, &ic)); +} + +TEST(SignedMetadataIcon, ZeroCountIsRejected) { + /* n == 0 leaves both counters at 0 and hits the same broken invariant. */ + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x00, 0xFF}, 1, 1, &ic)); +} + +TEST(SignedMetadataIcon, MaxLiteralOf127Decodes) { + /* The boundary that IS valid: n = -127 (0x81). */ + std::vector data; + data.push_back(0x81); + for (int i = 0; i < 127; i++) data.push_back((uint8_t)i); + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x00); + EXPECT_EQ(ic.buf[126], 126); +} + +TEST(SignedMetadataIcon, MaxRunOf127Decodes) { + std::vector data{0x7F, 0x5A}; + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x5A); + EXPECT_EQ(ic.buf[126], 0x5A); +} + +TEST(SignedMetadataIcon, TruncatedStreamIsRejected) { + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x08, 0xFF}, 4, 4, &ic)); /* claims 8, has 2 */ +} + +/* ── Exact-validation guards (review round 2) ────────────────────────────── + * The render path is lenient by construction: it fills the canvas and stops, + * so it cannot reject a final run that straddles the image or trailing packets. + * Callers gate on the validator, so the validator must be exact. */ + +TEST(SignedMetadataIcon, StraddlingRunIsRejected) { + /* 05 FF for a 2x2: a RUN of 5 into a 4-pixel image. The draw loop would fill + * 4 and report success; the stream is not well-formed. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x05\xFF", 2, 2, 2)); + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x05, 0xFF}, 2, 2, &ic)); +} + +TEST(SignedMetadataIcon, TrailingPacketsAreRejected) { + /* Exactly fills 2x2, then carries an unread packet. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x04\xFF\x01\xAA", 4, 2, 2)); +} + +TEST(SignedMetadataIcon, TruncatedLiteralBodyIsRejected) { + /* n = -3 promises 3 value bytes, only 2 present. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\xFD\x01\x02", 3, 3, 1)); +} + +TEST(SignedMetadataIcon, MissingRunValueByteIsRejected) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x04", 1, 4, 1)); +} + +TEST(SignedMetadataIcon, ValidatorAcceptsExactStreams) { + /* The golden vector, and the valid boundaries. */ + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x03\xFF\xFF\x00", 4, 2, 2)); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x7F\x5A", 2, 127, 1)); + std::vector lit; + lit.push_back(0x81); + for (int i = 0; i < 127; i++) lit.push_back((uint8_t)i); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid(lit.data(), (uint32_t)lit.size(), 127, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsUndecodableAndZeroCounts) { + std::vector lit128; + lit128.push_back(0x80); + for (int i = 0; i < 128; i++) lit128.push_back(0xAA); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(lit128.data(), + (uint32_t)lit128.size(), 128, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x00\xFF", 2, 1, 1)); + /* The 1x1 accept-and-persist case: 80 FF was previously stored despite never + * rendering, because only size+dims were checked at the trust boundary. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x80\xFF", 2, 1, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsDegenerateGeometry) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 0, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 1, 0)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(NULL, 0, 1, 1)); +} + +TEST(SignedMetadataIcon, IconColumnCapIsNarrowerThanTheIconHeight) { + /* The width cap is the 40px text column, NOT the 64px height. A 64px-wide + * icon at x=0 would span into the text that begins at x=40 and, because the + * icon is drawn after the text, erase the "NOT verified" warning. */ + EXPECT_EQ(LEFT_MARGIN_WITH_ICON, 40); + EXPECT_LT(LEFT_MARGIN_WITH_ICON, 64); +} + +TEST(SignedMetadataSignerValid, AcceptsValidCompressedKeyAllSlots) { + for (uint8_t slot = 0; slot < METADATA_MAX_KEYS; slot++) { + EXPECT_TRUE( + signed_metadata_signer_valid(slot, EXPECTED_SLOT3_PUB, 33, "CI Test")) + << "slot " << (int)slot; + } +} + +TEST(SignedMetadataSignerValid, RejectsKeyIdOutOfRange) { + EXPECT_FALSE(signed_metadata_signer_valid(METADATA_MAX_KEYS, + EXPECTED_SLOT3_PUB, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsWrongPubkeyLength) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 32, "CI Test")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 65, "CI Test")); + EXPECT_FALSE(signed_metadata_signer_valid(0, nullptr, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsNonCompressedPrefix) { + /* 0x04 would make ecdsa_read_pubkey read 65 bytes from a 33-byte buffer — + * the prefix guard must reject it before the parser ever runs. */ + uint8_t bad[33]; + memcpy(bad, EXPECTED_SLOT3_PUB, sizeof(bad)); + bad[0] = 0x04; + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); + bad[0] = 0x00; // the "empty slot" sentinel must never load as a key + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsBadAlias) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, nullptr)); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "")); + std::string too_long(METADATA_ALIAS_MAX_LEN + 1, 'a'); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + too_long.c_str())); + std::string max_len(METADATA_ALIAS_MAX_LEN, 'a'); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, max_len.c_str())); + /* Realistic aliases (letters/digits/space/-/_) are accepted. */ + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "Pioneer")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "KeepKey Swap")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "my-signer_1")); + /* Rendered inside quotes on the trust screen — control chars, '%', and + * semantic-injection punctuation (quote breakout, "." / "(" appending a + * false "verified by KeepKey." claim) are all rejected. */ + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a\nb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a%sb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "a\x7f" + "b")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "x' verified by KeepKey. Safe (")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "safe.KeepKey")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "trust(me)")); +} + +TEST(SignedMetadataSignerStore, RejectsPersistenceBeforeSessionMutation) { + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_store_signer( + TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, nullptr, 0, 0, 0, true)); + EXPECT_EQ(signed_metadata_signer_alias(TEST_KEY_ID), nullptr); + char fingerprint[METADATA_FINGERPRINT_LEN]; + EXPECT_FALSE(signed_metadata_signer_fingerprint(TEST_KEY_ID, fingerprint)); + signed_metadata_clear_signers(); +} + +/* ---- signed_metadata_pubkey_fingerprint -------------------------------- */ + +TEST(SignedMetadataFingerprint, IsSha256Prefix) { + char fp[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(EXPECTED_SLOT3_PUB, fp); + + uint8_t digest[32]; + sha256_Raw(EXPECTED_SLOT3_PUB, 33, digest); + char expected[METADATA_FINGERPRINT_LEN]; + snprintf(expected, sizeof(expected), "%02X%02X%02X%02X", digest[0], digest[1], + digest[2], digest[3]); + EXPECT_STREQ(fp, expected); +} + +/* ===================================================================== * + * signed_metadata_enforce_decision — pure enforce truth table (SECTION 2). + * Exercises the relied==true cases that confirm()'s OLED/button I/O makes + * unreachable from the module-state API in a unit test. + * ===================================================================== */ + +TEST(SignedMetadataEnforce, NotReliedAlwaysAllow) { + uint8_t h[32] = {0}; + uint8_t hw[32] = {1}; + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, h)); + EXPECT_TRUE(signed_metadata_enforce_decision(false, false, METADATA_OPAQUE, + nullptr, nullptr)); + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, hw)); +} + +TEST(SignedMetadataEnforce, ReliedHashMatches) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_TRUE( + signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedHashMismatch) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + uint8_t got[32]; + memcpy(got, TX_HASH, 32); + got[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, got)); +} + +TEST(SignedMetadataEnforce, ReliedHashNull) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, nullptr)); +} + +TEST(SignedMetadataEnforce, ReliedNotAvailable) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, false, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedNotVerified) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_OPAQUE, h, h)); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_MALFORMED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedStoredHashNull) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + nullptr, h)); +} + +/* ===================================================================== * + * SECTION 3 — v2 static-schema blobs + on-device calldata decode + * + * v2 carries NO tx_hash and NO argument values. The blob attests only the + * static schema (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]); the device decodes the actual argument + * values from the calldata it is about to sign. These tests drive the full + * parse -> verify -> signed_metadata_matches_tx (which decodes) path and check + * the decoded MetadataArg values, plus the malformed/rejection cases. + * ===================================================================== */ + +struct V2Arg { + std::string name; + uint8_t format; + uint8_t decimals; /* TOKEN_AMOUNT only */ + std::string symbol; /* TOKEN_AMOUNT only */ +}; + +V2Arg v2_addr(const std::string& name) { + return V2Arg{name, ARG_FORMAT_ADDRESS, 0, ""}; +} +V2Arg v2_token(const std::string& name, uint8_t decimals, + const std::string& symbol) { + return V2Arg{name, ARG_FORMAT_TOKEN_AMOUNT, decimals, symbol}; +} + +struct V2Spec { + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::string method; + std::vector args; + uint8_t classification; + uint8_t key_id; + int num_args_override; // -1 => use args.size() +}; + +V2Spec v2_base_spec() { + V2Spec s; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.method = "transfer"; + s.args.push_back(v2_addr("to")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.classification = METADATA_VERIFIED; + s.key_id = TEST_KEY_ID; + s.num_args_override = -1; + return s; +} + +std::vector build_v2_body(const V2Spec& s) { + std::vector b; + put_u8(b, METADATA_VERSION_SCHEMA); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_be16(b, (uint16_t)s.method.size()); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + put_u8(b, s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size()); + for (const V2Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + if (a.format == ARG_FORMAT_TOKEN_AMOUNT) { + put_u8(b, a.decimals); + put_u8(b, (uint8_t)a.symbol.size()); + put_bytes(b, (const uint8_t*)a.symbol.data(), a.symbol.size()); + } + } + put_u8(b, s.classification); + put_be32(b, 0); // timestamp + put_u8(b, s.key_id); + return b; +} + +std::vector v2_base_blob() { + return sign_body(build_v2_body(v2_base_spec())); +} + +/* ABI calldata: selector + one 32-byte head word per arg. */ +void put_addr_word(std::vector& d, const uint8_t addr[20]) { + for (int i = 0; i < 12; i++) d.push_back(0); + d.insert(d.end(), addr, addr + 20); +} + +/* Canonical transfer(to=RECIPIENT, amount=AMOUNT32) calldata (4 + 64 = 68). */ +std::vector v2_transfer_calldata() { + std::vector d(SEL_TRANSFER, SEL_TRANSFER + 4); + put_addr_word(d, RECIPIENT); + d.insert(d.end(), AMOUNT32, AMOUNT32 + 32); + return d; +} + +void make_v2_msg(EthereumSignTx* msg, const uint8_t contract[20], + const std::vector& data, bool has_len, + uint32_t data_length) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data.size(); + memcpy(msg->data_initial_chunk.bytes, data.data(), data.size()); + msg->has_chain_id = true; + msg->chain_id = 1; + msg->has_data_length = has_len; + msg->data_length = has_len ? data_length : 0; +} + +/* Happy path: parse+verify a v2 blob, then matches_tx decodes the args from the + * transfer calldata and populates stored_metadata. */ +TEST_F(SignedMetadataTest, V2SchemaDecodesTransferArgs) { + std::vector blob = v2_base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].value_len, 0); // undecoded before matches_tx + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(md->args[1].value[0], 6); // decimals + EXPECT_EQ(md->args[1].value[1], 4); // symlen + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* THE v2 drain preventer, restated. + * + * A v2 schema commits to calldata only — never to msg->value — so it cannot + * bind a payable call's amount. The original guard refused any nonzero value, + * which meant every value-bearing route (a Relay ETH->SOL bridge deposit, for + * one) was forced to blind-sign: precisely the transactions most worth + * reviewing. Refusing was not what kept funds safe; SHOWING the amount is. + * + * So the match now succeeds and the schema reports that the tx moves value. + * ethereum.c consumes that to keep the native amount/recipient screen instead + * of suppressing it, so the user sees the decoded call AND the ETH leaving. + * The amount is read from the transaction being signed, so nothing unattested + * reaches the screen and the schema stays transaction-independent. */ +TEST_F(SignedMetadataTest, V2SchemaPayableKeepsValueScreen) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + msg.value.size = 1; + msg.value.bytes[0] = 0x01; // 1 wei — any nonzero value is "payable" + + /* Clear-signs, AND flags that the amount screen must still run. */ + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + /* Zero value: same match, but no extra screen is demanded — proving the + * flag tracks the value rather than being always-on. */ + msg.value.size = 0; + msg.has_value = false; + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_FALSE(signed_metadata_schema_moves_value()); +} + +/* A large, realistic value must set the flag too — not just a 1-wei probe. */ +TEST_F(SignedMetadataTest, V2SchemaPayableFlagsRealisticValue) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + /* 0.00798 ETH = 0x1c5145d9b6b3ff — the Relay ETH->SOL deposit from a real + * quote, whose blind-signing prompted this change. */ + const uint8_t kValue[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.value.size = sizeof(kValue); + memcpy(msg.value.bytes, kValue, sizeof(kValue)); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); +} + +/* THE transaction this whole path exists for: a real Relay ETH->SOL bridge + * deposit, captured from api.relay.link on 2026-07-27. + * + * to 0x4cd00e387622c35bddb9b4c962c136462338bc31 + * value 7980129999999999 wei (0.00798 ETH) <-- PAYABLE + * calldata 0x49290c1c + address(depositor) + bytes32(orderId) = 68 bytes + * + * Three things had to be true for this to clear-sign, and each was a real + * blocker: the call is payable (was refused outright), one arg is an opaque + * word (BYTES was not accepted in the v2 arg parser), and 4 + 2*32 must + * exactly equal the calldata length (structural completeness). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelayEthToSolanaDeposit) { + const uint8_t RELAY_ROUTER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL[4] = {0x49, 0x29, 0x0c, 0x1c}; + /* depositor 0x909Ef6B32DfDc12CA86aA710b54c991af3C5F82E */ + const uint8_t DEPOSITOR[20] = {0x90, 0x9e, 0xf6, 0xb3, 0x2d, 0xfd, 0xc1, + 0x2c, 0xa8, 0x6a, 0xa7, 0x10, 0xb5, 0x4c, + 0x99, 0x1a, 0xf3, 0xc5, 0xf8, 0x2e}; + /* orderId 0x8a2c1211...cb1, verbatim from the quote */ + const uint8_t ORDER_ID[32] = {0x8a, 0x2c, 0x12, 0x11, 0x97, 0xef, 0xc9, 0x5c, + 0x42, 0xf5, 0x31, 0x42, 0xab, 0x40, 0x97, 0x35, + 0xee, 0x35, 0x32, 0x87, 0xf8, 0x77, 0xed, 0x4d, + 0x35, 0x1f, 0x63, 0x09, 0x4d, 0x5b, 0xfc, 0xb1}; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_ROUTER, RELAY_ROUTER + 20); + s.selector.assign(SEL, SEL + 4); + s.method = "bridgeDeposit"; + s.args.clear(); + s.args.push_back(v2_addr("depositor")); + s.args.push_back(V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + std::vector data(SEL, SEL + 4); + put_addr_word(data, DEPOSITOR); + data.insert(data.end(), ORDER_ID, ORDER_ID + 32); + ASSERT_EQ(data.size(), 68u); /* 4 + 2*32, exactly — no remainder */ + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_ROUTER, data, /*has_len=*/true, + (uint32_t)data.size()); + /* 0.00798 ETH — the payable part that used to force blind-signing. */ + const uint8_t VALUE[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.has_value = true; + msg.value.size = sizeof(VALUE); + memcpy(msg.value.bytes, VALUE, sizeof(VALUE)); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + /* ...and the ETH amount screen must still run, since the schema cannot + * bind the value. */ + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, DEPOSITOR, 20), 0); + EXPECT_EQ(md->args[1].format, ARG_FORMAT_BYTES); + EXPECT_EQ(md->args[1].value_len, 32); + EXPECT_EQ(memcmp(md->args[1].value, ORDER_ID, 32), 0); +} + +/* Relay solver swap: selector 0x02d5f05f(token address, amount, requestId) — + * three fixed single words, EXACTLY the shape pulled from real relay traffic + * (100-byte calldata: 4 + 3*32, zero remainder, verified across 22 live + * samples). Proves a v2 static schema clear-signs a relay swap: the device + * decodes token+amount+id from the very calldata it is about to sign — no + * tx_hash, no per-tx online signer, schema signed once offline. This is the + * "add a new service via a signed payload" path for a NON-native contract + * (relay is not in ethereum_contractHandled). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { + const uint8_t RELAY_SOLVER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL_RELAY[4] = {0x02, 0xd5, 0xf0, 0x5f}; + uint8_t REQ_ID[32] = {0}; // requestId 0x...cd7c from a real sample + REQ_ID[30] = 0xcd; + REQ_ID[31] = 0x7c; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_SOLVER, RELAY_SOLVER + 20); + s.selector.assign(SEL_RELAY, SEL_RELAY + 4); + s.method = "relaySwap"; + s.args.clear(); + s.args.push_back(v2_addr("token")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.args.push_back(V2Arg{"requestId", ARG_FORMAT_AMOUNT, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 3); + + // Real relay calldata: selector + token(USDC=CONTRACT_A) + amount + + // requestId. + std::vector data(SEL_RELAY, SEL_RELAY + 4); + put_addr_word(data, CONTRACT_A); + data.insert(data.end(), AMOUNT32, AMOUNT32 + 32); + data.insert(data.end(), REQ_ID, REQ_ID + 32); + EXPECT_EQ(data.size(), 100u); + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_SOLVER, data, /*has_len=*/true, + (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + // token → full 20-byte USDC address (never truncated). + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, CONTRACT_A, 20), 0); + + // amount → TOKEN_AMOUNT [decimals=6, "USDC", 32-byte amount]. + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value[0], 6); + EXPECT_EQ(md->args[1].value[1], 4); + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); + + // requestId → raw 32-byte AMOUNT word. + EXPECT_EQ(md->args[2].format, ARG_FORMAT_AMOUNT); + EXPECT_EQ(md->args[2].value_len, 32); + EXPECT_EQ(memcmp(md->args[2].value, REQ_ID, 32), 0); +} + +/* has_data_length omitted but the initial chunk IS the whole calldata: allowed. + */ +TEST_F(SignedMetadataTest, V2AcceptsNoDataLengthWhenChunkComplete) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/false, 0); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +/* Reject when the tx claims MORE calldata than the schema accounts for. */ +TEST_F(SignedMetadataTest, V2RejectsExtraCalldataLength) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); // 68 bytes + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 100); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject a partial initial chunk (rest would stream later). */ +TEST_F(SignedMetadataTest, V2RejectsPartialInitialChunk) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data.resize(40); // selector + partial first word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 68); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject an ABI address word with non-zero high bytes. */ +TEST_F(SignedMetadataTest, V2RejectsDirtyAddressWord) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data[4] = 0x01; // first (should-be-zero) byte of the address word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Wrong selector in calldata -> matches_tx fails before decode. */ +TEST_F(SignedMetadataTest, V2RejectsSelectorMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + memcpy(data.data(), SEL_APPROVE, 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* An unsupported display format -> MALFORMED. + * + * v2 renders fixed single ABI words only: ADDRESS, AMOUNT, BYTES and + * TOKEN_AMOUNT. STRING is dynamic (offset + length + payload), so it cannot be + * read from one 32-byte word and must stay out of scope — accepting it would + * break the "declared widths equal the calldata length" rule that makes a + * schema safe without a tx_hash. An out-of-range format byte must fail too. */ +TEST_F(SignedMetadataTest, V2RejectsUnsupportedFormat) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"data", ARG_FORMAT_STRING, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + ExpectMalformed(blob, TEST_KEY_ID); + + V2Spec bogus = v2_base_spec(); + bogus.args[1] = V2Arg{"data", (ArgFormat)0x7f, 0, ""}; + std::vector blob2 = sign_body(build_v2_body(bogus)); + ExpectMalformed(blob2, TEST_KEY_ID); +} + +/* BYTES IS supported in v2: an opaque fixed word (a router's order id) still + * occupies exactly one ABI word, so it neither breaks structural completeness + * nor needs a dynamic decoder. */ +TEST_F(SignedMetadataTest, V2AcceptsBytesArg) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +/* Tampered v2 body must fail the signature check. */ +TEST_F(SignedMetadataTest, V2RejectsTamperedBody) { + std::vector blob = v2_base_blob(); + blob[5] ^= 0xFF; // flip a contract-address byte in the signed region + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* Zero-arg v2 schema (selector-only call): valid, decodes nothing. */ +TEST_F(SignedMetadataTest, V2ZeroArgsSelectorOnly) { + V2Spec s = v2_base_spec(); + s.args.clear(); + s.method = "poke"; + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data(SEL_TRANSFER, SEL_TRANSFER + 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 4); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_EQ(signed_metadata_get()->num_args, 0); +} + +/* matches_tx() must be idempotent: a second call decodes to the same values + * (regression for the TOKEN_AMOUNT prefix that used to grow on each call). */ +TEST_F(SignedMetadataTest, V2MatchesTxIsIdempotent) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + const SignedMetadata* md = signed_metadata_get(); + uint16_t len_addr = md->args[0].value_len, len_tok = md->args[1].value_len; + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); // second call + EXPECT_EQ(md->args[0].value_len, len_addr); + EXPECT_EQ(md->args[1].value_len, len_tok); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* The v2 decode flag must reflect ONLY the latest matches_tx() call: a + * successful decode followed by a mismatching tx must leave it false, so a + * stale "decoded" proof can never survive into enforce. */ +TEST_F(SignedMetadataTest, V2SchemaDecodedFlagNotStaleAfterMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_schema_decoded()); // not decoded yet + + EthereumSignTx ok; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&ok, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + ASSERT_TRUE(signed_metadata_matches_tx(&ok)); + EXPECT_TRUE(signed_metadata_schema_decoded()); // decoded this tx + + /* Now a tx that fails an EARLY binding (wrong contract) — before the decode + * branch. The flag must be cleared, not left over from the match above. */ + EthereumSignTx bad; + make_v2_msg(&bad, CONTRACT_B, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&bad)); + EXPECT_FALSE(signed_metadata_schema_decoded()); +} + +/* ---- v2 enforce truth table (pure, no I/O) ------------------------------ */ +/* Signature: (relied, available, decoded, classification). */ + +TEST(SignedMetadataEnforceSchema, NotReliedAlwaysAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, true, true, + METADATA_VERIFIED)); + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, false, false, + METADATA_OPAQUE)); +} + +TEST(SignedMetadataEnforceSchema, ReliedVerifiedDecodedAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButNotDecodedFails) { + /* The core hardening: relied + available + VERIFIED but decode never ran. */ + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, false, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButUnavailableOrUnverifiedFails) { + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, false, true, + METADATA_VERIFIED)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_OPAQUE)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_MALFORMED)); +} + +// Generic attestation primitive (used by the Solana signed-token-definition +// path): a valid signature from a loaded signer verifies; tampering, an +// unloaded key_id, or a wrong signature length are all rejected. +TEST(SignedMetadataAttestation, VerifiesValidRejectsTampered) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + const uint8_t data[] = "KeepKeySolanaTokenDef/1|mint|decimals|USDC"; + const size_t len = sizeof(data) - 1; + uint8_t digest[32]; + sha256_Raw(data, len, digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + + EXPECT_TRUE(signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, + sizeof(sig))); + + std::vector bad(data, data + len); + bad[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_verify_attestation(TEST_KEY_ID, bad.data(), len, + sig, sizeof(sig))); + EXPECT_FALSE(signed_metadata_verify_attestation((uint8_t)(TEST_KEY_ID + 1), + data, len, sig, sizeof(sig))); + EXPECT_FALSE( + signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, 63)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +// End-to-end test of the production Solana token-definition path: builds the +// exact domain-separated preimage solana_token_info_trusted() reconstructs, +// signs it, and checks acceptance + every rejection branch. +TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + SolanaTokenInfo ti; + memset(&ti, 0, sizeof(ti)); + ti.has_mint = true; + ti.mint.size = 32; + memset(ti.mint.bytes, 0xAB, 32); + ti.has_symbol = true; + strcpy(ti.symbol, "USDC"); + ti.has_decimals = true; + ti.decimals = 6; + ti.has_signer_key_id = true; + ti.signer_key_id = TEST_KEY_ID; + + // Canonical preimage: tag || mint(32) || decimals(le32) || symbol. + std::vector pre; + const char* tag = "KeepKeySolanaTokenDef/1"; + pre.insert(pre.end(), tag, tag + strlen(tag)); + pre.insert(pre.end(), ti.mint.bytes, ti.mint.bytes + 32); + pre.push_back(6); + pre.push_back(0); + pre.push_back(0); + pre.push_back(0); + pre.insert(pre.end(), ti.symbol, ti.symbol + strlen(ti.symbol)); + + uint8_t digest[32]; + sha256_Raw(pre.data(), pre.size(), digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + ti.has_signature = true; + ti.signature.size = 64; + memcpy(ti.signature.bytes, sig, 64); + + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Attested-tuple disagreement: a different decimals no longer matches the + // sig. + ti.decimals = 9; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.decimals = 6; + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Corrupted signature. + ti.signature.bytes[10] ^= 0x40; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signature.bytes[10] ^= 0x40; + + // Out-of-range signer slot (256 would narrow to slot 0 without the guard). + ti.signer_key_id = 256; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signer_key_id = TEST_KEY_ID; + + // No attestation -> not trusted (the caller falls back to unsigned display). + ti.has_signature = false; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +/* ===================================================================== * + * Clearsign attestor: the issuer/verifier digest contract + * + * fsm_msgClearsignAttestorSign signs sha256(payload) as a 64-byte compact + * ECDSA signature; verifying devices check it through + * signed_metadata_verify_attestation. Those two constructions living in + * different files is exactly how SignIdentity ended up unusable for this + * (Bitcoin message header + double hash, 65 bytes). This pins the contract + * so a change on either side fails here rather than in the field. + * ===================================================================== */ + +TEST(ClearsignAttestor, SignedSchemaVerifiesOnTheVerifyingDevice) { + set_advanced_mode_for_test(true); + /* Smallest valid KKSOLSC1 payload: no args, no accounts. What matters here + * is the digest construction, not the schema body. */ + std::vector payload; + auto push = [&](const void* p, size_t n) { + const uint8_t* b = static_cast(p); + payload.insert(payload.end(), b, b + n); + }; + push("KKSOLSC1", 8); + payload.push_back(1); /* version */ + payload.insert(payload.end(), 32, 0x42); + payload.push_back(1); /* disc_len */ + payload.push_back(0x0d); /* discriminator */ + payload.push_back(5); + push("Relay", 5); + payload.push_back(7); + push("deposit", 7); + payload.push_back(0); /* no args */ + payload.push_back(0); /* no accounts */ + + SolanaInstrSchema schema; + ASSERT_TRUE(solana_parseInstrSchema(payload.data(), payload.size(), &schema)) + << "the attestor refuses to sign what it cannot parse"; + + /* Issuer side, byte for byte what the handler does. */ + uint8_t digest[32]; + sha256_Raw(payload.data(), payload.size(), digest); + uint8_t sig[64]; + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + + /* Verifier side. */ + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + EXPECT_TRUE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + /* A schema the attestor never saw must not ride the same signature. */ + payload[9] ^= 0x01; /* first byte of the program id */ + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +} // namespace