diff --git a/docs/security/7.15.0-rc21-clearsign-release-control.md b/docs/security/7.15.0-rc21-clearsign-release-control.md new file mode 100644 index 000000000..352a50eee --- /dev/null +++ b/docs/security/7.15.0-rc21-clearsign-release-control.md @@ -0,0 +1,99 @@ +# Firmware 7.15.0 RC21 ClearSign Release Control + +Date: 2026-07-29 + +RC21 is the opt-in proving release for self-service ClearSign. It deliberately +does not pin a KeepKey production signer. The regular firmware contains the +constrained `KKSOLSC1` attestor, but its public-key and signing messages, +runtime signer loading, and runtime metadata verification are usable only while +the user has enabled `AdvancedMode`. + +Loaded signer identities remain RAM-only. They are cleared by session teardown, +`ClearSession`, reboot, and disabling `AdvancedMode`. Metadata from a runtime +signer is annotation-only: EVM and Solana retain the baseline raw/unverified +review after the decoded screens. + +## Human-attestation gate + +The attestor must show every security-relevant declaration before it signs: + +1. program and instruction labels; +2. the complete base58 program ID on its own confirmation; +3. the complete discriminator on its own confirmation; +4. every argument's ordinal, ABI type, and label; and +5. every displayed account index and label. + +Program ID and discriminator may not share one standard notification. A +44-character base58 program ID consumes two body rows, and an 8-byte +discriminator cannot reliably fit in the remaining row. Argument types may not +be omitted: two different ordered type declarations can have the same total +width while assigning the same labels to different byte offsets. + +Before RC21 is tagged, release evidence must include OLED captures from a +schema with a 44-character program ID, an 8-byte discriminator, all four +argument types (`u64 LE`, `u8`, `public key`, and `bytes32 hex`), a 16-character +argument label, and a 16-character account label. Every value must be complete +and readable. CI success alone does not prove this display boundary. + +The candidate's framebuffer captures and exact input manifest are recorded in +[`evidence/7.15.0-rc21-clearsign-attestor`](evidence/7.15.0-rc21-clearsign-attestor/README.md). +Regenerate that evidence if any attestor parser or confirmation code changes. + +## Host compatibility + +The canonical RC21 protocol uses `SolanaSignTx` tags 9, 10, and 11 for +`schema_payload`, `schema_signature`, and `schema_signer_key_id`. Tags 5 through +8 are reserved for the transaction-bound `KKSOLSW1` descriptor and one-request +opaque-signing consent. Removing that reservation or assigning those tags is a +protocol-review event. + +Hosts built against the earlier experimental schema contract used tags 5, 6, +and 7. Protobuf treats those fields as unknown on RC21, so the transaction falls +back to the ordinary unverified review without a transport error. This is safe +but operationally silent. Vault, SDK, and python-keepkey release notes must +therefore state that reusable schemas require the RC21 protocol contract; old +hardware-test fixtures are not compatible evidence. + +## Dependency provenance + +The selected RC21 firmware commit must pin and record the exact reachable +commits for both submodules. The reviewed candidate pins: + +- device-protocol: `4cc8b717517c79ee3ac436161141dd033db286fd`; +- python-keepkey: `c406a1ba9120da410c356dbff7f4d4bd1e1758fa`. + +The PR body, gitlinks, CI checkout, and release provenance must agree before the +tag is created. + +## Production-key provisioning runbook + +The attestor key derives from the active seed/passphrase session. A hidden +wallet therefore has a different attestor identity from the standard wallet. +This is valid for self-service use, but it is not acceptable ambiguity when +selecting KeepKey's future firmware-pinned production identity. + +Provision that identity only as follows: + +1. install a signed firmware release that contains the attestor; +2. enter the standard wallet with an empty passphrase; +3. enable `AdvancedMode` and read the compressed attestor public key; +4. record the complete public key, its SHA-256 fingerprint, device identity, + firmware version/hash, and the signed release provenance; +5. clear the session, reconnect to the standard wallet with an empty + passphrase, and derive the key again; and +6. accept the candidate only if both complete public keys match byte-for-byte. + +Do not provision while a hidden wallet is active. Do not promote a key from +RC21 into firmware merely because it was derived successfully; promotion is a +separate reviewed release decision. + +## Merge and tag gate + +- Both firmware variants build and satisfy the SRAM reserve. +- Full and Bitcoin-only unit suites pass. +- Python integration passes with inherited `PYTHONPATH` preserved. +- Protocol generation succeeds from the exact pinned device-protocol commit. +- Maximum-boundary OLED captures satisfy the human-attestation gate above. +- The PR body names the exact gitlinks in the selected candidate. +- No RC21 artifact contains a pinned production ClearSign public key or a + persistent runtime signer. diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png new file mode 100644 index 000000000..40e3a8a59 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/02-program-id-44chars.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/02-program-id-44chars.png new file mode 100644 index 000000000..f5b6be458 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/02-program-id-44chars.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png new file mode 100644 index 000000000..6a2bbd084 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png new file mode 100644 index 000000000..29040e622 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/05-arg-u8-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/05-arg-u8-16char-label.png new file mode 100644 index 000000000..6a3df7bed Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/05-arg-u8-16char-label.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png new file mode 100644 index 000000000..87a889b67 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/07-arg-bytes32-hex-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/07-arg-bytes32-hex-16char-label.png new file mode 100644 index 000000000..f77b8eaf1 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/07-arg-bytes32-hex-16char-label.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png new file mode 100644 index 000000000..0c5d66280 Binary files /dev/null and b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png differ diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/README.md b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/README.md new file mode 100644 index 000000000..b38cf854f --- /dev/null +++ b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/README.md @@ -0,0 +1,32 @@ +# RC21 ClearSign Attestor OLED Evidence + +These are pixel captures from the firmware emulator's real 256x64 framebuffer, +read through `DebugLinkGetState`; they are not UI mockups. The boundary schema +contains a 44-character base58 program ID, an 8-byte discriminator, every +supported argument type, 20-character program and instruction names, +16-character argument labels, and a 16-character account label. + +The sequence proves that each security-relevant declaration is independently +reviewable before the device returns an attestation signature: + +1. `01-schema-identity.png` — full 20-character program and instruction names; +2. `02-program-id-44chars.png` — complete program ID across two rows; +3. `03-discriminator-8bytes.png` — complete 16-hex-character discriminator; +4. `04-arg-u64-le-16char-label.png` — `u64 LE` and its full label; +5. `05-arg-u8-16char-label.png` — `u8` and its full label; +6. `06-arg-public-key-16char-label.png` — `public key` and its full label; +7. `07-arg-bytes32-hex-16char-label.png` — `bytes32 hex` and its full label; +8. `08-account-16char-label.png` — account index and its full label. + +`manifest.json` records the exact firmware and device-protocol commits and all +boundary inputs. Reproduce the capture against an isolated emulator with: + +```sh +python3 scripts/emulator/capture-clearsign-attestor.py \ + --main 127.0.0.1:12044 \ + --debug 127.0.0.1:12045 \ + --output docs/security/evidence/7.15.0-rc21-clearsign-attestor +``` + +The capture tool wipes and initializes the addressed emulator. Never point it +at hardware or an emulator instance containing state that must be preserved. diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/manifest.json b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/manifest.json new file mode 100644 index 000000000..eea0fd3cf --- /dev/null +++ b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/manifest.json @@ -0,0 +1,51 @@ +{ + "account": { + "index": 7, + "label": "VaultAccount1234", + "label_characters": 16 + }, + "arguments": [ + { + "label": "Amount1234567890", + "label_characters": 16, + "type": "u64 LE" + }, + { + "label": "Flag123456789012", + "label_characters": 16, + "type": "u8" + }, + { + "label": "RecipientPubKey1", + "label_characters": 16, + "type": "public key" + }, + { + "label": "OrderHash1234567", + "label_characters": 16, + "type": "bytes32 hex" + } + ], + "attestation_public_key_bytes": 33, + "attestation_signature_bytes": 64, + "device_protocol_commit": "4cc8b717517c79ee3ac436161141dd033db286fd", + "discriminator_bytes": 8, + "discriminator_hex": "0d9e0ddf5fd51c06", + "firmware_commit": "c49bd9d04f981da103b8b3161dbb5b042367f1ce", + "instruction_name": "Review All Types 123", + "instruction_name_characters": 20, + "program_id": "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2", + "program_id_characters": 44, + "program_name": "Boundary Program 123", + "program_name_characters": 20, + "screens": [ + "01-schema-identity.png", + "02-program-id-44chars.png", + "03-discriminator-8bytes.png", + "04-arg-u64-le-16char-label.png", + "05-arg-u8-16char-label.png", + "06-arg-public-key-16char-label.png", + "07-arg-bytes32-hex-16char-label.png", + "08-account-16char-label.png" + ] +} diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 530a414e3..7d61dcf71 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -88,6 +88,10 @@ void fsm_msgEthereum712TypesValues(Ethereum712TypesValues* msg); void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg); void fsm_msgLoadClearsignSigner(const LoadClearsignSigner* msg); +void fsm_msgClearsignAttestorGetPublicKey( + const ClearsignAttestorGetPublicKey* msg); +void fsm_msgClearsignAttestorSign(const ClearsignAttestorSign* msg); + void fsm_msgNanoGetAddress(NanoGetAddress* msg); void fsm_msgNanoSignTx(NanoSignTx* msg); diff --git a/lib/board/signatures.c b/lib/board/signatures.c index f4be6bf82..9bf2107fb 100644 --- a/lib/board/signatures.c +++ b/lib/board/signatures.c @@ -20,7 +20,9 @@ #include "trezor/crypto/sha2.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/memzero.h" #include "keepkey/board/memory.h" +#include "keepkey/board/memcmp_s.h" #include "keepkey/board/signatures.h" #include "keepkey/board/pubkeys.h" @@ -68,23 +70,51 @@ int signatures_ok(void) { return KEY_EXPIRED; } /* Expired signing key */ + /* F3 hardening: double-compute SHA-256, compare in constant time */ + uint8_t firmware_fingerprint2[32]; sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint); + asm volatile("" ::: "memory"); + sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint2); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], - (uint8_t*)FLASH_META_SIG1, - firmware_fingerprint) != 0) { /* Failure */ + if (memcmp_s(firmware_fingerprint, firmware_fingerprint2, 32) != 0) { + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); return SIG_FAIL; } + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], - (uint8_t*)FLASH_META_SIG2, - firmware_fingerprint) != 0) { /* Failure */ + /* F3 hardening: infective aggregation — accumulate all three ECDSA + * results instead of early-returning on each. Forces attacker to + * corrupt all three verify calls, not just skip one branch. */ + volatile int verify_acc = 0; + volatile int verify_sentinel = 0; + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], + (uint8_t*)FLASH_META_SIG1, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], + (uint8_t*)FLASH_META_SIG2, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], + (uint8_t*)FLASH_META_SIG3, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + + /* All three verifies must have executed and all must have passed */ + if (verify_sentinel != 3) { return SIG_FAIL; } - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], - (uint8_t*)FLASH_META_SIG3, - firmware_fingerprint) != 0) { /* Failure */ + if (verify_acc != 0) { return SIG_FAIL; } diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 5faf631e0..3653ea7b0 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -299,6 +299,8 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.h" +/* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ +#include "fsm_msg_clearsign_attestor.h" #if ZCASH_PRIVACY #include "fsm_msg_zcash.h" #else diff --git a/lib/firmware/fsm_msg_clearsign_attestor.h b/lib/firmware/fsm_msg_clearsign_attestor.h new file mode 100644 index 000000000..806d8da51 --- /dev/null +++ b/lib/firmware/fsm_msg_clearsign_attestor.h @@ -0,0 +1,206 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +/* Clearsign attestor: let a KeepKey issue clear-sign schema attestations from + * its seed. It ships in the regular firmware, but every operation is gated by + * AdvancedMode. This lets builders prove the self-service workflow before a + * future release pins a KeepKey production identity. + * + * The attestor NEVER signs arbitrary bytes. It parses the submitted payload + * with the same validator verifying devices run (solana_parseInstrSchema for + * KKSOLSC1) and refuses anything malformed. A fully compromised host can + * therefore only obtain attestations over well-formed, user-confirmed + * descriptors — never a general secp256k1 signing oracle. That is the single + * most important property of this design; do not add a "raw" mode. + * + * Key custody: the attestation key is derived from the device seed at + * ATTESTOR_PATH (a dedicated hardened path outside every coin space), so PIN + * unlock gates its availability, seed backup is key backup, and wipe destroys + * it. + * + * ponytail: KKSOLSC1 only. EVM v2 metadata blobs are attestable in principle + * but sign a different range (payload minus the 65-byte signature trailer, see + * signed_metadata_process) and their parser is static in signed_metadata.c; + * add a second branch here plus an exported pure parser when EVM schemas need + * device-issued signatures. + */ + +/* The attestation key path: purpose 0x4B4B ("KK"), then 0x4353 ("CS") for + * clearsign, then account 0. All hardened, and far outside any SLIP-44 coin + * range, so an attestation key can never collide with a funds key. */ +#define ATTESTOR_PATH_LEN 3 +static const uint32_t ATTESTOR_PATH[ATTESTOR_PATH_LEN] = { + 0x80000000 | 0x4B4B, + 0x80000000 | 0x4353, + 0x80000000u, +}; + +/* Derive the attestation node. Returns NULL and sends the failure itself. */ +static HDNode* attestor_getNode(void) { + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, ATTESTOR_PATH, + ATTESTOR_PATH_LEN, NULL); + if (!node) return NULL; + hdnode_fill_public_key(node); + return node; +} + +/* Human-readable ABI type names for the attestation review. The type is part + * of the security boundary, not decoration: U64+PUBKEY and OPAQUE32+U64 have + * the same total width but assign labels to different byte offsets. Never ask + * an operator to attest an argument label without also showing its type. */ +static const char* attestor_schemaArgTypeName(SolanaSchemaArgType type) { + switch (type) { + case SOL_SCHEMA_ARG_U64: + return "u64 LE"; + case SOL_SCHEMA_ARG_U8: + return "u8"; + case SOL_SCHEMA_ARG_PUBKEY: + return "public key"; + case SOL_SCHEMA_ARG_OPAQUE32: + return "bytes32 hex"; + } + return "invalid"; /* Parser rejects unknown values; defense in depth. */ +} + +void fsm_msgClearsignAttestorGetPublicKey( + const ClearsignAttestorGetPublicKey* msg) { + (void)msg; + RESP_INIT(ClearsignAttestorPublicKey); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + HDNode* node = attestor_getNode(); + if (!node) return; + + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorPublicKey, resp); + layoutHome(); +} + +void fsm_msgClearsignAttestorSign(const ClearsignAttestorSign* msg) { + RESP_INIT(ClearsignAttestorSignature); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + CHECK_PARAM(msg->has_payload && msg->payload.size > 0, "Missing payload"); + + /* Validate before attesting. The payload must be a descriptor this firmware + * can itself parse — the same code path fsm_msgSolanaSignTx runs — so a + * compromised host cannot use the attestor as a raw signing oracle. */ + SolanaInstrSchema schema; + if (msg->payload.size < 8 || memcmp(msg->payload.bytes, "KKSOLSC1", 8) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, "Unsupported descriptor"); + layoutHome(); + return; + } + if (!solana_parseInstrSchema(msg->payload.bytes, msg->payload.size, + &schema)) { + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid schema"); + layoutHome(); + return; + } + + char program_id[45]; + char disc_hex[2 * SOL_SCHEMA_DISC_MAX + 1]; + solana_pubkeyToStr(schema.program_id, program_id, sizeof(program_id)); + for (uint8_t i = 0; i < schema.disc_len; i++) { + snprintf(disc_hex + 2 * i, sizeof(disc_hex) - 2 * i, "%02x", + schema.disc[i]); + } + + /* Program IDs may consume two body rows, while an 8-byte discriminator plus + * its label consumes another two. They therefore get separate confirmations: + * combining them can silently clip the discriminator, which is precisely the + * field the operator must compare against the contract ABI. */ + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", "%s\n%s", + schema.program_name, schema.instruction_name) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Program ID", "%s", + program_id) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Discriminator", "%s", + disc_hex); + + /* One label per screen. A structurally valid schema can still lie by + * labelling the wrong offset ("Amount" over the order id), so the operator + * has to read every label — and confirm()'s body is three rendered rows with + * no pagination, so a batched list of max-length labels scrolls off. A label + * nobody saw is a label nobody checked. */ + for (uint8_t i = 0; confirmed && i < schema.num_args; i++) { + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Arg %u: %s\n%s", (unsigned)(i + 1), + attestor_schemaArgTypeName(schema.args[i].type), + schema.args[i].label); + } + for (uint8_t i = 0; confirmed && i < schema.num_accounts; i++) { + confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Account #%u shows\n%s", (unsigned)schema.accounts[i].index, + schema.accounts[i].label); + } + memzero(&schema, sizeof(schema)); + if (!confirmed) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + HDNode* node = attestor_getNode(); + if (!node) return; + + /* Plain ECDSA over SHA256(payload): exactly what + * signed_metadata_verify_attestation() checks on the verifying device. */ + uint8_t digest[32]; + sha256_Raw(msg->payload.bytes, msg->payload.size, digest); + + uint8_t sig[64]; + int ret = + ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig, NULL, NULL); + memzero(digest, sizeof(digest)); + if (ret != 0) { + memzero(node, sizeof(*node)); + memzero(sig, sizeof(sig)); + fsm_sendFailure(FailureType_Failure_Other, "Attestation failed"); + layoutHome(); + return; + } + + resp->has_signature = true; + resp->signature.size = sizeof(sig); + memcpy(resp->signature.bytes, sig, sizeof(sig)); + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(sig, sizeof(sig)); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 295cc441c..1d057579b 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -202,3 +202,7 @@ MSG_IN(MessageType_MessageType_EthereumTxMetadata, EthereumTxMetadata, fsm_msgEthereumTxMetadata) MSG_OUT(MessageType_MessageType_EthereumMetadataAck, EthereumMetadataAck, NO_PROCESS_FUNC) MSG_IN(MessageType_MessageType_LoadClearsignSigner, LoadClearsignSigner, fsm_msgLoadClearsignSigner) + MSG_IN(MessageType_MessageType_ClearsignAttestorGetPublicKey, ClearsignAttestorGetPublicKey, fsm_msgClearsignAttestorGetPublicKey) + MSG_OUT(MessageType_MessageType_ClearsignAttestorPublicKey, ClearsignAttestorPublicKey, NO_PROCESS_FUNC) + MSG_IN(MessageType_MessageType_ClearsignAttestorSign, ClearsignAttestorSign, fsm_msgClearsignAttestorSign) + MSG_OUT(MessageType_MessageType_ClearsignAttestorSignature, ClearsignAttestorSignature, NO_PROCESS_FUNC) diff --git a/scripts/emulator/capture-clearsign-attestor.py b/scripts/emulator/capture-clearsign-attestor.py new file mode 100644 index 000000000..2d5f3dfc3 --- /dev/null +++ b/scripts/emulator/capture-clearsign-attestor.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Capture the maximum-boundary ClearSign attestor screens from kkemu. + +This is an emulator evidence tool, not a hardware provisioning tool. It wipes +and initializes the emulator connected at the supplied UDP endpoints. +""" + +import argparse +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +# The pinned python-keepkey uses legacy generated descriptors, while the +# current device protocol is generated on demand below. Both compatibility +# switches must be set before either protobuf module is imported. +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +PYTHON_KEEPKEY = ROOT / "deps" / "python-keepkey" +DEVICE_PROTOCOL = ROOT / "deps" / "device-protocol" +ZOO_SCRIPTS = ROOT / "scripts" / "zoo" + +PROGRAM_ID = "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2" +PROGRAM_BYTES = bytes.fromhex( + "792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df03cbfee1abf" +) +DISCRIMINATOR = bytes.fromhex("0d9e0ddf5fd51c06") +PROGRAM_NAME = "Boundary Program 123" +INSTRUCTION_NAME = "Review All Types 123" +ARGUMENTS = ( + (1, "u64 LE", "Amount1234567890"), + (2, "u8", "Flag123456789012"), + (3, "public key", "RecipientPubKey1"), + (4, "bytes32 hex", "OrderHash1234567"), +) +ACCOUNT_INDEX = 7 +ACCOUNT_LABEL = "VaultAccount1234" +SCREEN_NAMES = ( + "01-schema-identity.png", + "02-program-id-44chars.png", + "03-discriminator-8bytes.png", + "04-arg-u64-le-16char-label.png", + "05-arg-u8-16char-label.png", + "06-arg-public-key-16char-label.png", + "07-arg-bytes32-hex-16char-label.png", + "08-account-16char-label.png", +) + + +def generate_current_protocol(): + generated = tempfile.TemporaryDirectory(prefix="kk-attestor-proto-") + subprocess.run( + [ + "protoc", + "-I", + str(DEVICE_PROTOCOL), + "--python_out=" + generated.name, + str(DEVICE_PROTOCOL / "types.proto"), + str(DEVICE_PROTOCOL / "messages.proto"), + ], + check=True, + ) + sys.path.insert(0, generated.name) + module = importlib.import_module("messages_pb2") + return generated, module + + +def length_prefixed_text(value): + raw = value.encode("ascii") + if not 1 <= len(raw) <= 255: + raise ValueError("schema text length out of bounds") + return bytes([len(raw)]) + raw + + +def boundary_schema(): + payload = bytearray(b"KKSOLSC1") + payload.append(1) + payload.extend(PROGRAM_BYTES) + payload.append(len(DISCRIMINATOR)) + payload.extend(DISCRIMINATOR) + if len(PROGRAM_NAME) != 20 or len(INSTRUCTION_NAME) != 20: + raise ValueError( + "boundary program and instruction names must be 20 characters" + ) + payload.extend(length_prefixed_text(PROGRAM_NAME)) + payload.extend(length_prefixed_text(INSTRUCTION_NAME)) + payload.append(len(ARGUMENTS)) + for arg_type, _display_type, label in ARGUMENTS: + if len(label) != 16: + raise ValueError("boundary argument labels must be 16 characters") + payload.append(arg_type) + payload.extend(length_prefixed_text(label)) + if len(ACCOUNT_LABEL) != 16: + raise ValueError("boundary account label must be 16 characters") + payload.append(1) + payload.append(ACCOUNT_INDEX) + payload.extend(length_prefixed_text(ACCOUNT_LABEL)) + return bytes(payload) + + +def git_revision(path): + return subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], text=True + ).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, help="directory for PNG evidence") + parser.add_argument( + "--main", + default=os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"), + help="kkemu main UDP endpoint", + ) + parser.add_argument( + "--debug", + default=os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"), + help="kkemu debug UDP endpoint", + ) + args = parser.parse_args() + + generated, attestor_proto = generate_current_protocol() + try: + sys.path.insert(0, str(PYTHON_KEEPKEY)) + sys.path.insert(0, str(ZOO_SCRIPTS)) + + from keepkeylib import mapping + from keepkeylib import messages_pb2 as proto + from keepkeylib.client import KeepKeyDebuglinkClient + from keepkeylib.transport_udp import UDPTransport + from screenshot import capture_screenshot + + # The deliberately older pinned host package has no attestor wrappers. + # Register only the new request/response wire classes; framing, + # confirmations, and DebugLink remain the pinned host implementation. + mapping.map_class_to_type[attestor_proto.ClearsignAttestorSign] = 1702 + mapping.map_type_to_class[1703] = attestor_proto.ClearsignAttestorSignature + + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + client = KeepKeyDebuglinkClient(UDPTransport(args.main)) + client.set_debuglink(UDPTransport(args.debug)) + + client.auto_button = True + client.wipe_device() + client.load_device_by_mnemonic( + mnemonic=("all " * 11 + "all").strip(), + pin="", + passphrase_protection=False, + label="RC21 OLED Gate", + language="english", + ) + client.apply_policy("AdvancedMode", 1) + client.auto_button = False + + response = client.call_raw( + attestor_proto.ClearsignAttestorSign(payload=boundary_schema()) + ) + for index, name in enumerate(SCREEN_NAMES): + if not isinstance(response, proto.ButtonRequest): + raise RuntimeError( + "screen %d expected ButtonRequest, got %s" + % (index + 1, type(response).__name__) + ) + time.sleep(0.2) + path = output / name + if not capture_screenshot(client.debug, str(path), scale=3): + raise RuntimeError("failed to capture " + name) + print(path) + client.debug.press_yes() + response = client.call_raw(proto.ButtonAck()) + + if not isinstance(response, attestor_proto.ClearsignAttestorSignature): + raise RuntimeError( + "expected attestor signature, got " + type(response).__name__ + ) + if len(response.signature) != 64 or len(response.public_key) != 33: + raise RuntimeError("attestor returned malformed key or signature") + + manifest = { + "firmware_commit": git_revision(ROOT), + "device_protocol_commit": git_revision(DEVICE_PROTOCOL), + "program_name": PROGRAM_NAME, + "program_name_characters": len(PROGRAM_NAME), + "instruction_name": INSTRUCTION_NAME, + "instruction_name_characters": len(INSTRUCTION_NAME), + "program_id": PROGRAM_ID, + "program_id_characters": len(PROGRAM_ID), + "discriminator_hex": DISCRIMINATOR.hex(), + "discriminator_bytes": len(DISCRIMINATOR), + "arguments": [ + {"type": display_type, "label": label, "label_characters": len(label)} + for _arg_type, display_type, label in ARGUMENTS + ], + "account": { + "index": ACCOUNT_INDEX, + "label": ACCOUNT_LABEL, + "label_characters": len(ACCOUNT_LABEL), + }, + "screens": list(SCREEN_NAMES), + "attestation_signature_bytes": len(response.signature), + "attestation_public_key_bytes": len(response.public_key), + } + with (output / "manifest.json").open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + print("captured %d screens; attestation completed" % len(SCREEN_NAMES)) + finally: + generated.cleanup() + + +if __name__ == "__main__": + main()