diff --git a/deps/python-keepkey b/deps/python-keepkey index 7535e3fe8..2ed835472 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 7535e3fe8fb0fb47370e8cc2194e7dd30820caa0 +Subproject commit 2ed835472cb9c2308a729e7ff8ccfb050fa16312 diff --git a/docs/ReproducibleBuilds.md b/docs/ReproducibleBuilds.md new file mode 100644 index 000000000..f1e50d14c --- /dev/null +++ b/docs/ReproducibleBuilds.md @@ -0,0 +1,83 @@ +# Reproducible Builds + +KeepKey firmware releases are built deterministically: anyone can rebuild a +release tag from source and confirm, byte for byte, that it matches the binary +we ship. This page explains how to verify that, and why the naive "hash both +files" comparison is guaranteed to fail even for a perfect build. + +## One-command verification + +```bash +git clone https://github.com/keepkey/keepkey-firmware +cd keepkey-firmware +./scripts/build/verify-repro.sh v7.15.0 +``` + +The script clones the tag into a temporary directory, builds it with the +official Docker image (`kktech/firmware:v15`, the same pinned image used for +releases), downloads the signed release binary from GitHub, and compares the +device-verifiable payload hashes. It prints all four hashes and `PASS`/`FAIL`. + +Requirements: `git`, `docker`, `curl`, ~2 GB of disk, and about 15 minutes. + +## The 256-byte header, or: why full-file hashes never match + +`firmware.keepkey.bin` starts with a 256-byte metadata header +(`tools/firmware/header.s`): + +| offset (bytes) | size | contents | +|---------------:|-----:|------------------------------| +| 0 | 4 | magic `KPKY` | +| 4 | 4 | payload length | +| 8 | 3 | signature key indexes | +| 11 | 1 | flags | +| 12 | 52 | reserved (zero) | +| 64 | 192 | three 64-byte signatures | +| 256 | — | payload (the actual firmware)| + +In a freshly built binary the signature indexes and signature slots are all +**zeros**. The release process signs that exact binary, filling in the key +indexes and the three secp256k1 signatures (3-of-5 against the public keys in +`include/keepkey/board/pubkeys.h`). Nothing after byte 256 changes. + +Consequently, for any correctly reproduced build: + +- `sha256(built file)` **never** equals `sha256(signed release file)` — the + header bytes differ by construction; +- `sha256` of everything **after** byte 256 is identical on both sides — this + payload hash is the reproducibility check. + +## Manual verification + +```bash +git clone https://github.com/keepkey/keepkey-firmware +cd keepkey-firmware +git checkout v7.15.0 +git submodule update --init --recursive +./scripts/build/docker/device/release.sh + +# payload hash of your build: +tail -c +257 bin/firmware.keepkey.bin | sha256sum + +# payload hash of the official release: +curl -fsSL -o official.bin \ + https://github.com/keepkey/keepkey-firmware/releases/download/v7.15.0/firmware.keepkey.bin +tail -c +257 official.bin | sha256sum +``` + +The two payload hashes must be equal. Each release's `HASHES.txt` asset lists +the expected values. + +## Cross-checks + +- The firmware embeds the git commit it was built from + (`lib/firmware/scm_revision.h.in`); a device reports it in + `Features.revision`, so you can confirm which commit produced the binary a + device is actually running. +- The device also reports `Features.firmware_hash` + (`memory_firmware_hash()` in `lib/board/memory.c`), a sha256 over the + **installed header (including the filled-in signatures) plus payload** — + i.e. the full signed file, not the payload alone. It matches the + "full signed file" line in `HASHES.txt`, **not** the payload line above. + Host software comparing `Features.firmware_hash` against a release must use + that full-file hash. diff --git a/include/keepkey/firmware/eip712_stream.h b/include/keepkey/firmware/eip712_stream.h index d21d21e64..0ec032158 100644 --- a/include/keepkey/firmware/eip712_stream.h +++ b/include/keepkey/firmware/eip712_stream.h @@ -66,6 +66,11 @@ * not occur in practice and every one costs EIP712_MAX_STRUCTS bytes. */ #define EIP712_MAX_STRUCT_NAME 32 +/* Canonical ASCII Solidity identifier. Besides being part of encodeType, a + * member name is also the review-screen title, so this guarantees the exact + * bytes hashed are the exact bytes rendered (no truncation/control glyphs). */ +bool eip712_identifier_ok(const char *name); + /* Fetch one struct's member list by name. Returns NULL if the host has not * supplied it. Firmware backs this with the streaming state machine; the unit * tests back it with a fixture table, which is what makes encodeType testable diff --git a/include/keepkey/firmware/ethereum.h b/include/keepkey/firmware/ethereum.h index 14ead9f8d..ef7b93adc 100644 --- a/include/keepkey/firmware/ethereum.h +++ b/include/keepkey/firmware/ethereum.h @@ -72,6 +72,9 @@ void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg, const HDNode* node, EthereumTypedDataSignature* resp); bool ethereum_typed_hash_policy_allows(bool advanced_mode); +/* Canonical device-driven EIP-712 streaming is available without blind-sign + policy. The withdrawn whole-JSON parser remains separately disabled. */ +bool ethereum_streamed_eip712_enabled(void); bool ethereum_structured_eip712_enabled(void); /* True only for the exact string "EIP712Domain" -- the primaryType whose signature legitimately carries no message hash. Never a prefix match. */ diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h index 4a8cd0b6a..b6a233000 100644 --- a/include/keepkey/firmware/signed_metadata.h +++ b/include/keepkey/firmware/signed_metadata.h @@ -47,11 +47,26 @@ typedef enum { #define METADATA_VERSION_LEGACY 0x01 #define METADATA_VERSION_SCHEMA 0x02 -/* 7.16: a KeepKey-delegated envelope. [0x03][cert 139][inner v2 payload]. +/* 7.16: a KeepKey-delegated envelope. [0x03][cert 139][device-decoded schema]. * The certificate is verified and DISCARDED inside one message -- nothing about * a delegation survives into the next transaction. */ #define METADATA_VERSION_CERTIFIED 0x03 +/* DYNAMIC_SCHEMA (v4): a certified, firmware-owned decoder selected by a + * signed one-byte decoder id. Unlike v1, the signer supplies no displayed + * values; unlike v2, the calldata need not be a flat list of ABI words. The + * device's named decoder validates and extracts every displayed value from the + * transaction it is signing. v4 is initially used only for the exact Portals + * OrderPayload ABI, whose dynamic call array is larger than the 1024-byte + * initial protobuf chunk while its safety-defining outer order is wholly in + * that chunk. */ +#define METADATA_VERSION_DYNAMIC_SCHEMA 0x04 + +typedef enum { + METADATA_DECODER_NONE = 0, + METADATA_DECODER_PORTALS_NATIVE_ORDER_V1 = 1, +} MetadataDecoder; + /* The delegate is addressed by a sentinel that is >= METADATA_MAX_KEYS BY * CONSTRUCTION, so it can never name a runtime slot. * @@ -114,6 +129,7 @@ typedef struct { typedef struct { uint8_t version; + uint8_t decoder_id; uint32_t chain_id; uint8_t contract_address[20]; uint8_t selector[4]; @@ -130,6 +146,14 @@ typedef struct { bool signed_metadata_available(void); +/* True only for the reserved KeepKey-certified envelope shape. This is the + * narrow pre-verification predicate used by the FSM to let a v3 certificate + * reach signed_metadata_process() while AdvancedMode is off. It grants no + * trust by itself: the compiled root, certificate, delegate signature, and + * device-decoded schema are still verified by signed_metadata_process(). */ +bool signed_metadata_is_certified_envelope(const uint8_t* payload, + size_t payload_len, uint32_t key_id); + /* 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 @@ -145,14 +169,14 @@ bool signed_metadata_schema_moves_value(void); void signed_metadata_clear(void); /* - * Runtime-loaded clearsign signers (phase 1: the ONLY verification path). + * Runtime-loaded clearsign signers (the development/self-service 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. + * warning screen naming the alias before any clearsign page. The production + * path instead carries a root-certified delegate in each v3 envelope. */ /* Pure validation: slot in range and not occupied by a built-in key, pubkey a diff --git a/lib/firmware/eip712_stream.c b/lib/firmware/eip712_stream.c index f07758525..e5c2b4efa 100644 --- a/lib/firmware/eip712_stream.c +++ b/lib/firmware/eip712_stream.c @@ -44,6 +44,25 @@ typedef EthereumTypedDataStructAck_EthereumFieldType Eip712FieldType; typedef EthereumTypedDataStructAck_EthereumDataType Eip712DataType; +bool eip712_identifier_ok(const char *name) { + if (!name) return false; + size_t len = strlen(name); + if (len == 0 || len + 1 > EIP712_MAX_STRUCT_NAME) return false; + char first = name[0]; + if (!((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || + first == '_' || first == '$')) { + return false; + } + for (size_t i = 1; i < len; i++) { + char c = name[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '$')) { + return false; + } + } + return true; +} + /* ── encodeType spelling ───────────────────────────────────────────── * * The type string is hashed into typeHash, so getting a character wrong here @@ -92,7 +111,7 @@ bool eip712_type_name(const Eip712FieldType *field, char *out, size_t out_len) { base = "address"; break; case EthereumTypedDataStructAck_EthereumDataType_STRUCT: - if (!field->has_struct_name || field->struct_name[0] == '\0') + if (!field->has_struct_name || !eip712_identifier_ok(field->struct_name)) return false; base = field->struct_name; break; @@ -300,7 +319,7 @@ static bool closure_contains(const Eip712Closure *c, const char *name) { static bool closure_add(Eip712Closure *c, const char *name) { size_t len = strlen(name); - if (len == 0 || len + 1 > EIP712_MAX_STRUCT_NAME) return false; + if (!eip712_identifier_ok(name)) return false; if (closure_contains(c, name)) return true; if (c->count >= EIP712_MAX_STRUCTS) return false; memcpy(c->names[c->count], name, len + 1); @@ -362,7 +381,7 @@ static bool closure_collect(const char *name, Eip712StructLookup lookup, static bool hash_segment_from_ack(const char *name, const EthereumTypedDataStructAck *def, SHA3_CTX *hash) { - if (!def) return false; + if (!def || !eip712_identifier_ok(name)) return false; keccak_Update(hash, (const uint8_t *)name, strlen(name)); keccak_Update(hash, (const uint8_t *)"(", 1); @@ -372,7 +391,10 @@ static bool hash_segment_from_ack(const char *name, if (!eip712_type_name(&def->members[m].type, type_name, sizeof(type_name))) return false; const char *member_name = def->members[m].name; - if (member_name[0] == '\0') return false; + if (!eip712_identifier_ok(member_name)) return false; + for (size_t prior = 0; prior < m; prior++) { + if (strcmp(member_name, def->members[prior].name) == 0) return false; + } if (m > 0) keccak_Update(hash, (const uint8_t *)",", 1); keccak_Update(hash, (const uint8_t *)type_name, strlen(type_name)); @@ -757,8 +779,7 @@ bool eip712_stream_begin(const EthereumSignTypedData *msg) { } /* primary_type is `required` on the wire, so nanopb emits no has_ flag -- * an absent one cannot decode at all. Empty and over-long still can. */ - if (msg->primary_type[0] == '\0' || - strlen(msg->primary_type) + 1 > EIP712_MAX_STRUCT_NAME) { + if (!eip712_identifier_ok(msg->primary_type)) { fail("EIP-712 primary type missing or too long"); return false; } @@ -797,6 +818,27 @@ bool eip712_stream_on_struct(const EthereumTypedDataStructAck *ack) { return false; } + /* Names are both encodeType bytes and screen titles. Restrict them to the + * canonical ASCII identifier subset that fits pending_name exactly; a host + * cannot smuggle controls, Unicode lookalikes or a truncated label onto the + * review screen. Duplicate members are not a canonical struct definition. */ + for (size_t i = 0; i < ack->members_count; i++) { + const char *member_name = ack->members[i].name; + char type_name[EIP712_MAX_TYPE_NAME]; + if (!eip712_identifier_ok(member_name) || + !eip712_type_name(&ack->members[i].type, type_name, + sizeof(type_name))) { + fail("EIP-712 struct member is malformed"); + return false; + } + for (size_t j = 0; j < i; j++) { + if (strcmp(member_name, ack->members[j].name) == 0) { + fail("EIP-712 struct has duplicate members"); + return false; + } + } + } + switch (e712.phase) { case PH_DISCOVER: { /* Note every struct this one references, then move to the next unvisited diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index 31fc59daf..662683666 100644 --- a/lib/firmware/ethereum.c +++ b/lib/firmware/ethereum.c @@ -53,6 +53,11 @@ bool ethereum_typed_hash_policy_allows(bool advanced_mode) { return advanced_mode; } +/* The device-driven stream validates, renders and hashes each leaf from the + * same byte buffer. It is not blind signing and therefore does not inherit the + * AdvancedMode requirement of the precomputed-hash endpoint. */ +bool ethereum_streamed_eip712_enabled(void) { return true; } + /* The legacy JSON parser cannot guarantee that every displayed value is the * canonical value hashed by EIP-712. Keep the protocol symbol for compatibility * but fail closed in the FSM until the complete parser hardening is backported. diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index e58b56962..eaa8d283d 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -138,14 +138,27 @@ void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) { return; } - CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), - _("AdvancedMode required for clearsign metadata")); + CHECK_PARAM(!msg->has_key_id || msg->key_id <= 0xff, + _("clearsign metadata key_id out of range")); + + /* Runtime/self-service signers remain behind AdvancedMode. A production v3 + * envelope is allowed through only when it uses the reserved delegate key + * id and has enough bytes to contain a certificate plus inner payload. This + * shape check grants no trust: signed_metadata_process() still verifies the + * compiled root, certificate, delegate signature, and device-owned decode + * before the metadata can affect signing or suppress raw review. */ + bool certified = + msg->has_signed_payload && msg->has_key_id && + signed_metadata_is_certified_envelope( + msg->signed_payload.bytes, msg->signed_payload.size, msg->key_id); + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode") || certified, + _("AdvancedMode required for uncertified clearsign metadata")); RESP_INIT(EthereumMetadataAck); MetadataClassification result = signed_metadata_process( msg->signed_payload.bytes, msg->signed_payload.size, - msg->has_key_id ? msg->key_id : 0); + msg->has_key_id ? (uint8_t)msg->key_id : 0); resp->classification = (uint32_t)result; resp->has_display_summary = true; @@ -618,13 +631,13 @@ void fsm_msgEthereumSignTypedData(const EthereumSignTypedData* msg) { CHECK_INITIALIZED CHECK_PIN - /* Same gate the hashed path carries. Structured display is strictly more - * information than the blind path it replaces, but this is new parser - * surface reachable from a website, so it stays behind AdvancedMode until it - * has hardware evidence behind it. */ - if (!storage_isPolicyEnabled("AdvancedMode")) { + /* This is the canonical device-driven stream, not the withdrawn whole-JSON + * parser and not the blind typed-hash endpoint. Every leaf is validated, + * rendered and hashed from the same bytes, so AdvancedMode is neither needed + * nor consulted. */ + if (!ethereum_streamed_eip712_enabled()) { fsm_sendFailure(FailureType_Failure_Other, - _("Enable AdvancedMode to sign typed data")); + _("Structured EIP-712 is unavailable")); layout_home(); return; } diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index afc5851d1..b9ba43350 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -51,6 +51,44 @@ static bool solana_confirmPubkey(const char* title, const char* label, label, key_str); } +/* Compute-budget prices are micro-lamports per compute unit. Raw price/limit + * screens do not tell the user the SOL at risk, and the fee is charged even + * when execution fails. Show the fee payer and a non-understated maximum, + * using Solana's 1.4M-CU transaction cap when no explicit limit is present. */ +static bool solana_confirmPriorityFee(const SolanaParsedTx* tx) { + uint64_t price = 0; + uint64_t limit = 1400000u; + bool have_price = false; + + for (uint8_t i = 0; i < tx->num_instructions; i++) { + const SolanaParsedInstruction* pi = &tx->instructions[i]; + if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE) { + price = pi->extra_value; + have_price = true; + } else if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT) { + limit = pi->extra_value; + } + } + if (!have_price || price == 0) return true; + + uint64_t lamports = 0; + if (!solana_priority_fee_lamports(price, limit, &lamports)) { + (void)confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Fee", + "Priority fee too large to display. Refusing to sign."); + return false; + } + + if (tx->num_accounts > 0 && + !solana_confirmPubkey("Fee", "Fee payer", tx->accounts[0])) { + return false; + } + + char fee_str[40]; + solana_formatAmount(fee_str, sizeof(fee_str), lamports); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Fee", + "Max priority fee\n%s", fee_str); +} + /* Confirm a single parsed instruction. * * Takes no SolanaSignTx on purpose: every value on these screens is decoded @@ -397,8 +435,10 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, (unsigned long long)pi->extra_value); case SOL_INSTR_MEMO: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Memo attached"); + /* The memo is signed instruction data. Page the exact retained slice so + * two memos with the same prefix cannot produce the same review. */ + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, + "Solana Memo", pi->data, pi->data_len); case SOL_INSTR_UNKNOWN: default: { @@ -1045,6 +1085,18 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { return; } + /* Bind the raw compute-budget fields above to the actual SOL at risk. This + * is required for every fully verified path, including certified schemas. */ + if (tx_review == SOL_TX_REVIEW_VERIFIED && + !solana_confirmPriorityFee(&parsed)) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + /* Final confirmation */ if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Solana", "Sign this Solana transaction?")) { @@ -1112,44 +1164,18 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Always require on-device confirmation (matches Ethereum behavior). - * Display message content if printable, hex preview otherwise. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Sign Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Sign Bytes"; - /* Show hex preview (up to 64 hex chars = 32 bytes) */ - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* Raw Ed25519 has no version or domain separator. Bind that fact to consent, + * then page every signed byte; a prefix-plus-length preview is insufficient. + */ + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Solana Message", + "Format: raw Ed25519. Version: none. Domain: none.") || + !confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, "Raw Message", + msg->message.bytes, msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } /* Ed25519 sign */ @@ -1226,45 +1252,19 @@ void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Confirm dialog. Format 0 (ASCII) is always renderable; format 1 - * (UTF-8 limited) we render as printable bytes only — non-printable - * sequences fall through to a hex preview to avoid encoding - * surprises on the OLED. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Off-chain Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Off-chain Bytes"; - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* The off-chain envelope signs its version, format, and every message byte. + * Show the envelope fields explicitly and page the complete payload. */ + const char* format_label = format == 0 ? "ASCII" : "UTF-8 limited"; + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Solana Off-chain", + "Version: 0. Format: %s.", format_label) || + !confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Off-chain Message", msg->message.bytes, + msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } if (!solana_offchain_message_sign(node, msg, resp)) { diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c index 9bf6d928a..576b5c379 100644 --- a/lib/firmware/signed_metadata.c +++ b/lib/firmware/signed_metadata.c @@ -41,9 +41,9 @@ 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 signer slots remain a development/self-service lane. Production v3 + * metadata carries a root-certified delegate in the message and never writes + * that delegate into this ring. */ /* Runtime-loaded signers. RAM only — cleared on reboot by construction. RC18 * deliberately rejects persistent trust anchors: the public storage section @@ -331,6 +331,17 @@ static bool parse_metadata_binary(const uint8_t* payload, size_t payload_len, !parse_v2_args(&cursor, end, out)) { return false; } + } else if (out->version == METADATA_VERSION_DYNAMIC_SCHEMA) { + /* Min: version(1)+chain_id(4)+contract(20)+selector(4)+method_len(2)+ + * method(1)+decoder_id(1)+trailer(71) = 104. The decoder owns the + * argument labels and formats so a delegate cannot change what the device + * claims to have parsed. */ + 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->decoder_id) || + out->decoder_id != METADATA_DECODER_PORTALS_NATIVE_ORDER_V1) { + return false; + } } else { return false; } @@ -397,6 +408,160 @@ static bool decode_v2_args(SignedMetadata* md, const EthereumSignTx* msg) { return true; } +static bool word_is_zero(const uint8_t* word) { + for (size_t i = 0; i < 32; i++) { + if (word[i] != 0) return false; + } + return true; +} + +static bool word_u32(const uint8_t* word, uint32_t* out) { + for (size_t i = 0; i < 28; i++) { + if (word[i] != 0) return false; + } + *out = ((uint32_t)word[28] << 24) | ((uint32_t)word[29] << 16) | + ((uint32_t)word[30] << 8) | word[31]; + return true; +} + +static bool word_address(const uint8_t* word, uint8_t out[20]) { + for (size_t i = 0; i < 12; i++) { + if (word[i] != 0) return false; + } + memcpy(out, word + 12, 20); + return true; +} + +static bool bytes_nonzero(const uint8_t* bytes, size_t size) { + for (size_t i = 0; i < size; i++) { + if (bytes[i] != 0) return true; + } + return false; +} + +static bool word_matches_value(const uint8_t word[32], + const EthereumSignTx* msg) { + size_t value_len = msg->has_value ? msg->value.size : 0; + if (value_len > 32) return false; + size_t pad = 32 - value_len; + for (size_t i = 0; i < pad; i++) { + if (word[i] != 0) return false; + } + return value_len == 0 || memcmp(word + pad, msg->value.bytes, value_len) == 0; +} + +static void set_dynamic_arg(MetadataArg* arg, const char* name, + ArgFormat format, const uint8_t* value, + uint16_t value_len) { + strlcpy(arg->name, name, sizeof(arg->name)); + arg->format = format; + memcpy(arg->value, value, value_len); + arg->value_len = value_len; +} + +/* PortalsRouter.portal((Order,Call[]),partner), selector 0xa2e42c65. + * + * The exact contract and selector are independently bound by matches_tx(). + * PortalsRouter's verified implementation snapshots recipient's output-token + * balance, runs the dynamic calls, and reverts unless the received delta is at + * least minOutputAmount. Consequently the safety-defining fields are the + * outer Order, all of which are in the first 260 bytes. The calls may extend + * past the 1024-byte initial chunk; they can choose HOW the immutable router + * obtains the output, but cannot change WHAT token/recipient/minimum the router + * enforces or spend more native value than this transaction supplies. + * + * We still validate canonical outer offsets and the complete Call[] offset + * table. Any ambiguity, dirty address word, non-native input, value mismatch, + * empty call set, or malformed boundary fails back to blind signing. */ +static bool decode_portals_native_order(SignedMetadata* md, + const EthereumSignTx* msg) { + enum { + PORTALS_MIN_INITIAL = 292, + PORTALS_MIN_TOTAL = 452, + PORTALS_MAX_TOTAL = 16388, + PORTALS_MAX_CALLS = 16, + }; + static const uint8_t portals_router[20] = { + 0xbf, 0x5a, 0x7f, 0x36, 0x29, 0xfb, 0x32, 0x5e, 0x2a, 0x84, + 0x53, 0xd5, 0x95, 0xab, 0x10, 0x34, 0x65, 0xf7, 0x5e, 0x62}; + static const uint8_t portals_selector[4] = {0xa2, 0xe4, 0x2c, 0x65}; + + /* This decoder relies on this immutable router's verified postcondition, so + * the hot delegate must not be able to assign it to an arbitrary contract + * with the same ABI. Pin all three identity dimensions in firmware in + * addition to the signed schema/transaction match performed by the caller. */ + if (md->chain_id != 1 || + memcmp(md->contract_address, portals_router, sizeof(portals_router)) != + 0 || + memcmp(md->selector, portals_selector, sizeof(portals_selector)) != 0) { + return false; + } + uint32_t initsz = msg->data_initial_chunk.size; + uint32_t total = msg->has_data_length ? msg->data_length : initsz; + if (initsz < PORTALS_MIN_INITIAL || total < PORTALS_MIN_TOTAL || + total > PORTALS_MAX_TOTAL || total < initsz || (total - 4u) % 32u != 0) { + return false; + } + + const uint8_t* data = msg->data_initial_chunk.bytes; + uint32_t order_offset = 0, calls_offset = 0, call_count = 0; + if (!word_u32(data + 4, &order_offset) || order_offset != 0x40 || + !word_u32(data + 228, &calls_offset) || calls_offset != 0xc0 || + !word_u32(data + 260, &call_count) || call_count == 0 || + call_count > PORTALS_MAX_CALLS) { + return false; + } + + /* The complete element-offset table must be in the authenticated initial + * chunk. Offsets are relative to the byte immediately after array length. */ + uint32_t offset_table_end = 292u + call_count * 32u; + if (offset_table_end > initsz) return false; + uint32_t previous = 0; + for (uint32_t i = 0; i < call_count; i++) { + uint32_t offset = 0; + if (!word_u32(data + 292u + i * 32u, &offset) || offset % 32u != 0 || + offset < call_count * 32u || (i == 0 && offset != call_count * 32u) || + (i > 0 && offset <= previous) || offset > total - 292u - 128u) { + return false; + } + previous = offset; + } + + const uint8_t* input_token = data + 68; + const uint8_t* input_amount = data + 100; + const uint8_t* output_token_word = data + 132; + const uint8_t* minimum_output = data + 164; + const uint8_t* recipient_word = data + 196; + const uint8_t* partner_word = data + 36; + uint8_t output_token[20], recipient[20], partner[20]; + if (!word_is_zero(input_token) || !bytes_nonzero(input_amount, 32) || + !word_matches_value(input_amount, msg) || + !word_address(output_token_word, output_token) || + !bytes_nonzero(output_token, sizeof(output_token)) || + !bytes_nonzero(minimum_output, 32) || + !word_address(recipient_word, recipient) || + !bytes_nonzero(recipient, sizeof(recipient)) || + !word_address(partner_word, partner)) { + return false; + } + + md->num_args = 3; + strlcpy(md->method_name, "Portals swap", sizeof(md->method_name)); + set_dynamic_arg(&md->args[0], "Output token", ARG_FORMAT_ADDRESS, + output_token, sizeof(output_token)); + + /* TOKEN_AMOUNT with zero decimals and the literal unit label gives an + * honest decimal integer without incorrectly calling arbitrary ERC-20 base + * units "wei". Token decimals are not part of this router calldata. */ + uint8_t units_value[2 + 5 + 32] = {0, 5, 'u', 'n', 'i', 't', 's'}; + memcpy(units_value + 7, minimum_output, 32); + set_dynamic_arg(&md->args[1], "Minimum output", ARG_FORMAT_TOKEN_AMOUNT, + units_value, sizeof(units_value)); + set_dynamic_arg(&md->args[2], "Recipient", ARG_FORMAT_ADDRESS, recipient, + sizeof(recipient)); + return true; +} + static void bn_from_metadata_bytes(const uint8_t* value, size_t value_len, bignum256* out) { uint8_t padded[32] = {0}; @@ -692,6 +857,14 @@ bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, static MetadataClassification process_certified(const uint8_t* payload, size_t payload_len); +bool signed_metadata_is_certified_envelope(const uint8_t* payload, + size_t payload_len, + uint32_t key_id) { + return payload != NULL && payload_len > 1 + CLEARSIGN_CERT_LEN && + payload[0] == METADATA_VERSION_CERTIFIED && + key_id == METADATA_KEYID_DELEGATE; +} + MetadataClassification signed_metadata_process(const uint8_t* payload, size_t payload_len, uint8_t key_id) { @@ -707,12 +880,7 @@ MetadataClassification signed_metadata_process(const uint8_t* payload, * is consulted -- the delegate is not in that ring and must never be put * there. key_id is required to be the reserved sentinel so a certified * envelope can never be confused with a runtime slot. */ - if (payload && payload_len > 1 + CLEARSIGN_CERT_LEN && - payload[0] == METADATA_VERSION_CERTIFIED) { - if (key_id != METADATA_KEYID_DELEGATE) { - signed_metadata_clear(); - return METADATA_MALFORMED; - } + if (signed_metadata_is_certified_envelope(payload, payload_len, key_id)) { MetadataClassification c = process_certified(payload, payload_len); if (c == METADATA_MALFORMED) signed_metadata_clear(); return c; @@ -746,10 +914,11 @@ MetadataClassification signed_metadata_process(const uint8_t* payload, /* ── The KeepKey tier ──────────────────────────────────────────────── * - * A certified envelope is [0x03][cert 139][inner v2 payload]. The certificate - * is verified against the compiled-in root, its fields are copied out for the - * screen, and the certificate itself is DISCARDED -- the inner payload is then - * processed exactly as a v2 payload would be, against the delegate's key. + * A certified envelope is [0x03][cert 139][device-decoded schema]. The + * certificate is verified against the compiled-in root, its fields are copied + * out for the screen, and the certificate itself is DISCARDED -- the inner + * payload is then processed exactly as a v2 payload would be, against the + * delegate's key. * * Nothing about a delegation survives the message. There is no slot to * promote, nothing to revoke at runtime, and no state a later transaction can @@ -776,7 +945,8 @@ static MetadataClassification process_certified(const uint8_t* payload, if (!parse_metadata_binary(inner, inner_len, &stored_metadata)) return METADATA_MALFORMED; - /* The inner payload MUST be v2. This is the load-bearing check of the whole + /* The inner payload MUST be a device-decoded schema. This is the load-bearing + * check of the whole * tier, not a format nicety. * * The reason a KeepKey-certified describer is allowed to suppress the raw @@ -794,7 +964,8 @@ static MetadataClassification process_certified(const uint8_t* payload, * * Rejecting degrades to the additive 7.15 path, which is exactly where a v1 * describer belongs. */ - if (stored_metadata.version != METADATA_VERSION_SCHEMA) { + if (stored_metadata.version != METADATA_VERSION_SCHEMA && + stored_metadata.version != METADATA_VERSION_DYNAMIC_SCHEMA) { signed_metadata_clear(); return METADATA_MALFORMED; } @@ -884,7 +1055,8 @@ bool signed_metadata_matches_tx(const EthereumSignTx* msg) { return false; } - if (stored_metadata.version == METADATA_VERSION_SCHEMA) { + if (stored_metadata.version == METADATA_VERSION_SCHEMA || + stored_metadata.version == METADATA_VERSION_DYNAMIC_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 @@ -908,7 +1080,10 @@ bool signed_metadata_matches_tx(const EthereumSignTx* msg) { * 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); + metadata_schema_decoded = + stored_metadata.version == METADATA_VERSION_SCHEMA + ? decode_v2_args(&stored_metadata, msg) + : decode_portals_native_order(&stored_metadata, msg); return metadata_schema_decoded; } @@ -1228,7 +1403,8 @@ bool signed_metadata_enforce_schema_decision(bool relied, bool available, bool signed_metadata_enforce(const uint8_t hash[32]) { if (metadata_available && - stored_metadata.version == METADATA_VERSION_SCHEMA) { + (stored_metadata.version == METADATA_VERSION_SCHEMA || + stored_metadata.version == METADATA_VERSION_DYNAMIC_SCHEMA)) { return signed_metadata_enforce_schema_decision( relied_on_metadata, metadata_available, metadata_schema_decoded, stored_metadata.classification); diff --git a/scripts/build/verify-repro.sh b/scripts/build/verify-repro.sh new file mode 100755 index 000000000..98bd581f1 --- /dev/null +++ b/scripts/build/verify-repro.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +# verify-repro.sh — one-command reproducible-build verification. +# +# Builds the given release tag from source in a clean temporary clone using the +# official Docker build, then compares the device-verifiable payload hash +# (sha256 of everything after the 256-byte KPKY metadata header) against the +# official signed release binary. +# +# A fresh build has zeroed signature-index bytes and signature slots in its +# 256-byte header; the release-signing step fills them in and changes nothing +# else. Full-file hashes therefore NEVER match between a build and a signed +# release — only the payload comparison below is meaningful. See +# docs/ReproducibleBuilds.md. +# +# Usage: +# scripts/build/verify-repro.sh v7.15.0 +# scripts/build/verify-repro.sh v7.15.0 --local path/to/firmware.keepkey.bin +# +# --local compares against a signed binary you already have (e.g. before the +# GitHub release assets are published). +# +# Requirements: git, docker, curl (unless --local), ~2 GB disk, ~15 minutes. + +TAG="${1:-}" +if [ -z "$TAG" ]; then + echo "usage: $0 [--local ]" >&2 + exit 2 +fi +shift + +LOCAL_BIN="" +if [ $# -gt 0 ]; then + if [ "$1" != "--local" ]; then + echo "error: unrecognized argument '$1'" >&2 + echo "usage: $0 [--local ]" >&2 + exit 2 + fi + if [ -z "${2:-}" ] || [ ! -f "$2" ]; then + echo "error: --local requires a path to an existing signed binary" >&2 + exit 2 + fi + LOCAL_BIN="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" + shift 2 + if [ $# -gt 0 ]; then + echo "error: unrecognized trailing argument '$1'" >&2 + exit 2 + fi +fi + +REPO_URL="${KEEPKEY_REPO:-https://github.com/keepkey/keepkey-firmware}" + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +check_magic() { + if [ "$(head -c 4 "$1")" != "KPKY" ]; then + echo "error: $1 does not start with KPKY magic — not a firmware image?" >&2 + exit 1 + fi +} + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/kk-verify-repro.XXXXXX")" +echo "work dir: $WORK" + +echo "==> cloning $REPO_URL @ $TAG" +git clone --quiet "$REPO_URL" "$WORK/keepkey-firmware" +cd "$WORK/keepkey-firmware" +git checkout --quiet "$TAG" +git submodule update --init --recursive --quiet + +echo "==> building via scripts/build/docker/device/release.sh (log: $WORK/build.log)" +if ! ./scripts/build/docker/device/release.sh >"$WORK/build.log" 2>&1; then + echo "error: build failed — see $WORK/build.log" >&2 + exit 1 +fi + +BUILT_BIN="$WORK/keepkey-firmware/bin/firmware.keepkey.bin" +if [ ! -f "$BUILT_BIN" ]; then + echo "error: build produced no bin/firmware.keepkey.bin" >&2 + exit 1 +fi + +if [ -n "$LOCAL_BIN" ]; then + OFFICIAL_BIN="$LOCAL_BIN" +else + OFFICIAL_BIN="$WORK/official.firmware.keepkey.bin" + ASSET_URL="$REPO_URL/releases/download/$TAG/firmware.keepkey.bin" + echo "==> downloading official release binary: $ASSET_URL" + if ! curl -fsSL -o "$OFFICIAL_BIN" "$ASSET_URL"; then + echo "error: could not download the release asset (not published yet?)" >&2 + echo "hint: compare against a local signed binary: $0 $TAG --local " >&2 + exit 1 + fi +fi + +check_magic "$BUILT_BIN" +check_magic "$OFFICIAL_BIN" + +payload_hash() { + tail -c +257 "$1" >"$WORK/.payload.tmp" + sha256 "$WORK/.payload.tmp" +} + +BUILT_FULL="$(sha256 "$BUILT_BIN")" +BUILT_PAYLOAD="$(payload_hash "$BUILT_BIN")" +OFFICIAL_FULL="$(sha256 "$OFFICIAL_BIN")" +OFFICIAL_PAYLOAD="$(payload_hash "$OFFICIAL_BIN")" + +echo +echo "built full file : $BUILT_FULL" +echo "built payload : $BUILT_PAYLOAD" +echo "official full file : $OFFICIAL_FULL" +echo "official payload : $OFFICIAL_PAYLOAD" +echo + +if [ "$BUILT_PAYLOAD" = "$OFFICIAL_PAYLOAD" ]; then + echo "PASS: payload hashes match — $TAG is reproducible from source." + rm -rf "$WORK" +else + echo "FAIL: payload hashes differ." >&2 + echo "build tree kept for inspection: $WORK" >&2 + exit 1 +fi diff --git a/unittests/firmware/eip712_stream.cpp b/unittests/firmware/eip712_stream.cpp index 2fe742cf6..8d05b61da 100644 --- a/unittests/firmware/eip712_stream.cpp +++ b/unittests/firmware/eip712_stream.cpp @@ -300,6 +300,31 @@ std::string keccakHex(const std::string &s) { } // namespace +TEST(Eip712Stream, ReviewIdentifiersAreCanonicalAndNeverTruncated) { + EXPECT_TRUE(eip712_identifier_ok("PermitSingle")); + EXPECT_TRUE(eip712_identifier_ok("sigDeadline")); + EXPECT_TRUE(eip712_identifier_ok("_value$2")); + + EXPECT_FALSE(eip712_identifier_ok("")); + EXPECT_FALSE(eip712_identifier_ok("2value")); + EXPECT_FALSE(eip712_identifier_ok("line\nbreak")); + EXPECT_FALSE(eip712_identifier_ok("amount%08x")); + EXPECT_FALSE(eip712_identifier_ok("member-name")); + EXPECT_FALSE(eip712_identifier_ok( + "identifier_that_would_be_truncated")); +} + +TEST(Eip712Stream, TypeHashRejectsDuplicateMemberNames) { + Fixture f; + auto &permit = f.defs["Permit"]; + memset(&permit, 0, sizeof(permit)); + addMember(permit, "value", + mkSized(EthereumTypedDataStructAck_EthereumDataType_UINT, 32)); + addMember(permit, "value", + mkSized(EthereumTypedDataStructAck_EthereumDataType_UINT, 32)); + EXPECT_EQ(typeHashHex(f, "Permit"), ""); +} + TEST(Eip712Stream, TypeHashMatchesTheSpecExample) { // The canonical EIP-712 example. Note Person sorts AFTER Mail's own segment // and is appended, not interleaved. diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index a77fa07b0..3c0ad887c 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -333,8 +333,9 @@ TEST(Ethereum, PrecomputedTypedHashesRequireAdvancedMode) { EXPECT_TRUE(tron_typed_hash_policy_allows(true)); } -TEST(Ethereum, StructuredEip712IsDisabledForPointRelease) { +TEST(Ethereum, LegacyJsonEip712StaysDisabledWhileStructuredStreamIsEnabled) { EXPECT_FALSE(ethereum_structured_eip712_enabled()); + EXPECT_TRUE(ethereum_streamed_eip712_enabled()); } // Two real chain-1 table entries, so the decoder's token lookups resolve. diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp index db609de40..1ecd1398d 100644 --- a/unittests/firmware/signed_metadata.cpp +++ b/unittests/firmware/signed_metadata.cpp @@ -1,8 +1,8 @@ /* * 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, + * The runtime-signer fixture loads a development key through + * 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 @@ -359,6 +359,28 @@ TEST_F(SignedMetadataTest, SignatureVerificationFails) { ExpectMalformed(blob, TEST_KEY_ID); } +TEST(SignedMetadataEnvelope, OnlyReservedCompleteV3ShapeBypassesPolicyGate) { + std::vector candidate(141, 0); + candidate[0] = METADATA_VERSION_CERTIFIED; + + EXPECT_TRUE(signed_metadata_is_certified_envelope( + candidate.data(), candidate.size(), METADATA_KEYID_DELEGATE)); + EXPECT_FALSE(signed_metadata_is_certified_envelope( + candidate.data(), candidate.size(), TEST_KEY_ID)); + EXPECT_FALSE(signed_metadata_is_certified_envelope(candidate.data(), + candidate.size(), 0x180)); + + candidate[0] = METADATA_VERSION_SCHEMA; + EXPECT_FALSE(signed_metadata_is_certified_envelope( + candidate.data(), candidate.size(), METADATA_KEYID_DELEGATE)); + candidate[0] = METADATA_VERSION_CERTIFIED; + candidate.resize(140); + EXPECT_FALSE(signed_metadata_is_certified_envelope( + candidate.data(), candidate.size(), METADATA_KEYID_DELEGATE)); + EXPECT_FALSE(signed_metadata_is_certified_envelope(nullptr, 141, + METADATA_KEYID_DELEGATE)); +} + /* ===================================================================== * * signed_metadata_process — length guards * ===================================================================== */ @@ -1718,6 +1740,189 @@ TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { set_advanced_mode_for_test(false); } +/* ===================================================================== * + * v4 firmware-owned dynamic schemas + * ===================================================================== */ + +const uint8_t PORTALS_ROUTER[20] = {0xbf, 0x5a, 0x7f, 0x36, 0x29, 0xfb, 0x32, + 0x5e, 0x2a, 0x84, 0x53, 0xd5, 0x95, 0xab, + 0x10, 0x34, 0x65, 0xf7, 0x5e, 0x62}; +const uint8_t PORTALS_SELECTOR[4] = {0xa2, 0xe4, 0x2c, 0x65}; +const uint8_t PORTALS_OUTPUT_TOKEN[20] = { + 0x47, 0x0e, 0x8d, 0xe2, 0xeb, 0xae, 0xf5, 0x20, 0x14, 0xa4, + 0x7c, 0xb5, 0xe6, 0xaf, 0x86, 0x88, 0x49, 0x47, 0xf0, 0x8c}; + +void put_word_u32(std::vector& data, size_t offset, uint32_t value) { + ASSERT_LE(offset + 32, data.size()); + memset(data.data() + offset, 0, 32); + data[offset + 28] = (uint8_t)(value >> 24); + data[offset + 29] = (uint8_t)(value >> 16); + data[offset + 30] = (uint8_t)(value >> 8); + data[offset + 31] = (uint8_t)value; +} + +void put_word_address(std::vector& data, size_t offset, + const uint8_t address[20]) { + ASSERT_LE(offset + 32, data.size()); + memset(data.data() + offset, 0, 12); + memcpy(data.data() + offset + 12, address, 20); +} + +std::vector portals_v4_blob( + uint8_t decoder = METADATA_DECODER_PORTALS_NATIVE_ORDER_V1, + const uint8_t* contract = PORTALS_ROUTER) { + std::vector body; + put_u8(body, METADATA_VERSION_DYNAMIC_SCHEMA); + put_be32(body, 1); + put_bytes(body, contract, sizeof(PORTALS_ROUTER)); + put_bytes(body, PORTALS_SELECTOR, sizeof(PORTALS_SELECTOR)); + const char* method = "Portals swap"; + put_be16(body, (uint16_t)strlen(method)); + put_bytes(body, (const uint8_t*)method, strlen(method)); + put_u8(body, decoder); + put_u8(body, METADATA_VERIFIED); + put_be32(body, 0); + put_u8(body, TEST_KEY_ID); + return sign_body(body); +} + +/* Canonical ABI head for portal(((address,uint256,address,uint256,address), + * (address,address,bytes,uint256)[]),address). The synthetic call tails are + * irrelevant to the router's enforced outer order, but their offset table is + * canonical and wholly present in the initial chunk. */ +std::vector portals_calldata() { + std::vector data(1476, 0); + memcpy(data.data(), PORTALS_SELECTOR, sizeof(PORTALS_SELECTOR)); + put_word_u32(data, 4, 0x40); + put_word_address(data, 36, CONTRACT_B); + /* OrderPayload starts at byte 68. inputToken stays address(0) = native. */ + put_word_u32(data, 100, 0x010203); + put_word_address(data, 132, PORTALS_OUTPUT_TOKEN); + put_word_u32(data, 164, 0x0a0b0c); + put_word_address(data, 196, RECIPIENT); + put_word_u32(data, 228, 0xc0); + put_word_u32(data, 260, 4); + put_word_u32(data, 292, 0x80); + put_word_u32(data, 324, 0x140); + put_word_u32(data, 356, 0x200); + put_word_u32(data, 388, 0x2c0); + return data; +} + +void make_portals_msg(EthereumSignTx* msg, const std::vector& data) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = sizeof(PORTALS_ROUTER); + memcpy(msg->to.bytes, PORTALS_ROUTER, sizeof(PORTALS_ROUTER)); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = 1024; + memcpy(msg->data_initial_chunk.bytes, data.data(), 1024); + msg->has_data_length = true; + msg->data_length = (uint32_t)data.size(); + msg->has_chain_id = true; + msg->chain_id = 1; + msg->has_value = true; + msg->value.size = 3; + msg->value.bytes[0] = 0x01; + msg->value.bytes[1] = 0x02; + msg->value.bytes[2] = 0x03; +} + +TEST_F(SignedMetadataTest, V4PortalsDecodesSafetyDefiningOuterOrder) { + std::vector blob = portals_v4_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = portals_calldata(); + make_portals_msg(&msg, data); + + ASSERT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_decoded()); + EXPECT_TRUE(signed_metadata_schema_moves_value()); + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_DYNAMIC_SCHEMA); + EXPECT_EQ(md->decoder_id, METADATA_DECODER_PORTALS_NATIVE_ORDER_V1); + ASSERT_EQ(md->num_args, 3); + EXPECT_STREQ(md->args[0].name, "Output token"); + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(memcmp(md->args[0].value, PORTALS_OUTPUT_TOKEN, 20), 0); + EXPECT_STREQ(md->args[1].name, "Minimum output"); + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value[0], 0); + EXPECT_EQ(md->args[1].value[1], 5); + EXPECT_EQ(memcmp(md->args[1].value + 2, "units", 5), 0); + EXPECT_STREQ(md->args[2].name, "Recipient"); + EXPECT_EQ(memcmp(md->args[2].value, RECIPIENT, 20), 0); +} + +TEST_F(SignedMetadataTest, V4RejectsUnknownDecoder) { + std::vector blob = portals_v4_blob(0x7f); + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, V4PortalsDecoderCannotBeAssignedToAnotherContract) { + std::vector blob = + portals_v4_blob(METADATA_DECODER_PORTALS_NATIVE_ORDER_V1, CONTRACT_A); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + std::vector data = portals_calldata(); + EthereumSignTx msg; + make_portals_msg(&msg, data); + memcpy(msg.to.bytes, CONTRACT_A, 20); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, V4PortalsRejectsValueAndOuterOrderTampering) { + std::vector blob = portals_v4_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + std::vector data = portals_calldata(); + EthereumSignTx msg; + make_portals_msg(&msg, data); + + msg.value.bytes[2] ^= 1; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + msg.data_initial_chunk.bytes[68 + 31] = 1; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + msg.data_initial_chunk.bytes[132] = 1; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + memset(msg.data_initial_chunk.bytes + 164, 0, 32); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + put_word_u32(data, 4, 0x60); + memcpy(msg.data_initial_chunk.bytes, data.data(), 1024); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, V4PortalsRejectsMalformedCallArrayBoundaries) { + std::vector blob = portals_v4_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + std::vector data = portals_calldata(); + EthereumSignTx msg; + make_portals_msg(&msg, data); + + msg.data_length = 1477; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + msg.data_initial_chunk.size = 291; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + memset(msg.data_initial_chunk.bytes + 260, 0, 32); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + msg.data_initial_chunk.bytes[324 + 30] = 0x00; + msg.data_initial_chunk.bytes[324 + 31] = 0x80; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + make_portals_msg(&msg, data); + msg.data_initial_chunk.bytes[292 + 31] = 0x81; + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + /* ===================================================================== * * Clearsign attestor: the issuer/verifier digest contract * diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index bdc8176db..7940cccee 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -241,6 +241,53 @@ TEST(Solana, ParseSystemTransfer) { EXPECT_TRUE(memcmp(tx.instructions[0].to, expected_to, 32) == 0); } +static size_t BuildMemoTx(uint8_t* raw, const uint8_t* memo, size_t memo_len) { + size_t pos = 0; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 2; + memset(raw + pos, 0x11, SOL_PUBKEY_SIZE); + pos += SOL_PUBKEY_SIZE; + memcpy(raw + pos, SOL_MEMO_PROGRAM, SOL_PUBKEY_SIZE); + pos += SOL_PUBKEY_SIZE; + memset(raw + pos, 0xbb, SOL_PUBKEY_SIZE); + pos += SOL_PUBKEY_SIZE; + raw[pos++] = 1; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = (uint8_t)memo_len; + memcpy(raw + pos, memo, memo_len); + return pos + memo_len; +} + +TEST(Solana, MemoRetainsEverySignedByteForReview) { + uint8_t memo_a[80]; + uint8_t memo_b[80]; + memset(memo_a, 'A', sizeof(memo_a)); + memcpy(memo_b, memo_a, sizeof(memo_b)); + memo_b[64] = 'B'; + + uint8_t raw_a[256]; + uint8_t raw_b[256]; + const size_t len_a = BuildMemoTx(raw_a, memo_a, sizeof(memo_a)); + const size_t len_b = BuildMemoTx(raw_b, memo_b, sizeof(memo_b)); + ASSERT_EQ(len_a, len_b); + + SolanaParsedTx tx_a; + SolanaParsedTx tx_b; + ASSERT_EQ(solana_inspectTx(raw_a, len_a, &tx_a), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(solana_inspectTx(raw_b, len_b, &tx_b), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx_a.instructions[0].type, SOL_INSTR_MEMO); + ASSERT_EQ(tx_b.instructions[0].type, SOL_INSTR_MEMO); + ASSERT_EQ(tx_a.instructions[0].data_len, sizeof(memo_a)); + ASSERT_EQ(tx_b.instructions[0].data_len, sizeof(memo_b)); + EXPECT_EQ(0, memcmp(tx_a.instructions[0].data, memo_a, sizeof(memo_a))); + EXPECT_EQ(0, memcmp(tx_b.instructions[0].data, memo_b, sizeof(memo_b))); + EXPECT_NE(0, memcmp(tx_a.instructions[0].data, tx_b.instructions[0].data, + sizeof(memo_a))); +} + TEST(Solana, ParseMultiInstruction) { /* Transaction with 2 system transfers */ uint8_t raw[512];