From e109404ee359375063a7024c240365d636984552 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:24:05 -0300 Subject: [PATCH 1/4] feat(storage): PIN KDF hardening, seed lock, BIP-85 and recovery fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage: - The PIN key-derivation function is versioned so its cost can be raised without breaking existing wallets, with the migration path documented in docs/security/pin-kdf-v19-migration.md. - A wallet created by bitcoin-only firmware is stamped, and multi-chain firmware refuses to touch it (storage_isBitcoinOnlyLocked) rather than silently operating on a wallet whose owner chose a reduced attack surface. - Storage layout reserves the clear-sign identity block and zeroes it on read and on wipe, so nothing there can outlive a factory reset. - Orchard key derivation and seed fingerprinting are reachable through storage with progress reporting, guarded by the privacy build flag. - docs/security/anti-rollback-security-epoch-rfc.md records the proposed security-epoch scheme; it is a design note, nothing here implements it. BIP-85: - GetBip85Mnemonic derives a child mnemonic and displays it under constant power, with PIN and initialization checks. Always available — it is a seed derivation, not a coin engine. Recovery and authenticator: - The cipher-recovery wordlist permutation now borrows the shared frame arena instead of another multi-kilobyte stack buffer. - wipeAuthData reports failure instead of always claiming success, a cancelled authenticator action is distinguishable from an error, and a cancelled passphrase prompt aborts the request instead of continuing without one. --- .../anti-rollback-security-epoch-rfc.md | 103 ++++++ docs/security/pin-kdf-v19-migration.md | 78 +++++ include/keepkey/firmware/authenticator.h | 5 +- include/keepkey/firmware/bip85.h | 22 ++ include/keepkey/firmware/fsm.h | 2 + include/keepkey/firmware/storage.h | 20 +- lib/firmware/CMakeLists.txt | 1 + lib/firmware/authenticator.c | 105 ++++-- lib/firmware/bip85.c | 105 ++++++ lib/firmware/fsm.c | 2 + lib/firmware/fsm_msg_bip85.h | 142 ++++++++ lib/firmware/fsm_msg_common.h | 12 +- lib/firmware/messagemap.def | 2 + lib/firmware/recovery_cipher.c | 81 ++++- lib/firmware/storage.c | 307 ++++++++++++++++-- lib/firmware/storage.h | 44 ++- lib/firmware/storage_versions.inc | 4 +- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/authenticator.cpp | 111 +++++++ unittests/firmware/storage.cpp | 220 ++++++++++++- 20 files changed, 1267 insertions(+), 100 deletions(-) create mode 100644 docs/security/anti-rollback-security-epoch-rfc.md create mode 100644 docs/security/pin-kdf-v19-migration.md create mode 100644 include/keepkey/firmware/bip85.h create mode 100644 lib/firmware/bip85.c create mode 100644 lib/firmware/fsm_msg_bip85.h create mode 100644 unittests/firmware/authenticator.cpp diff --git a/docs/security/anti-rollback-security-epoch-rfc.md b/docs/security/anti-rollback-security-epoch-rfc.md new file mode 100644 index 000000000..4b9922be6 --- /dev/null +++ b/docs/security/anti-rollback-security-epoch-rfc.md @@ -0,0 +1,103 @@ +# RFC: OTP-backed firmware security epochs + +Status: design required; no production implementation is authorized by this +document. + +## Security invariant + +After a device accepts an official firmware image in security epoch `N`, no +officially signed image with an epoch lower than `N` may be installed or booted. +A power loss must leave the device able to boot either the previous accepted +image or the new accepted image; it must never advance the floor before the new +image has passed all integrity and signature checks. + +Semantic versions are not the monotonic value. Patch and release-candidate +numbers are allowed to move independently; the security epoch advances only +when an older signed image must be permanently revoked. + +## Why ordinary flash is insufficient + +The bootloader can erase and rewrite application flash, and the attacker in +this threat model is deliberately installing an older valid image. A floor +stored beside mutable firmware or normal storage can be restored with the old +image and does not establish monotonicity. + +The STM32F2 OTP region exposes sixteen 32-byte blocks. Current source assigns +manufacturing data to block 0, model data to block 1, and hardware entropy to +block 3. Before choosing any remaining block, manufacturing images and all +shipping board revisions must be audited; absence of a source reference is not +proof that a factory process never programmed it. + +## Proposed representation + +Reserve one audited OTP block as a 256-step unary counter. Epoch `N` is encoded +by programming the first `N` bits from 1 to 0. The decoded epoch is the length +of the contiguous programmed prefix. + +Reject the OTP state if a programmed bit appears after an unprogrammed bit. +This catches torn or non-canonical values instead of interpreting them as a +lower floor. Do not lock the block after each update; the OTP 1-to-0 property is +the monotonic mechanism. + +The signed application metadata needs a dedicated epoch field covered by the +existing firmware signatures. Reusing undocumented `meta_flags` bits is only +acceptable after confirming every bootloader generation parses and signs the +same bytes. A new metadata format with an explicit compatibility version is +preferred. + +## Update state machine + +1. Parse the candidate metadata without trusting it. +2. Verify image bounds, hash, and the complete 3-of-N signature policy. +3. Decode the current OTP floor and reject malformed OTP. +4. Reject `candidate_epoch < floor` before erasing the installed image. +5. Write the candidate while preserving the existing storage-protection + contract. +6. Re-read and verify the flashed image from flash. +7. If `candidate_epoch > floor`, program and verify each required OTP bit. +8. Install the application magic only after image and epoch verification. +9. At every boot, reject an installed image whose epoch is below the OTP floor. + +Unsigned/user-approved firmware must never advance the official floor. The RFC +must decide whether such firmware may boot at all once a floor is active; either +choice needs an explicit user-facing recovery story. + +## Fault-injection requirements + +- Accumulate signature results and validate sentinels as the current verifier + does; do not add a single skippable epoch branch after signature validation. +- Read the OTP floor more than once with independent control-flow checks before + an irreversible write. +- Verify every programmed bit and halt on disagreement. +- Ensure a glitch cannot turn malformed OTP into epoch zero. +- Include the epoch in the host-visible bootloader features and release + evidence so operators can diagnose state without trusting firmware. + +## Compatibility and rollout + +This requires a bootloader campaign. Application-only deployment cannot protect +devices whose installed bootloader ignores epochs. + +1. Inventory bootloader versions in the field and their update paths. +2. Prototype with a non-production test block on sacrificial devices. +3. Ship epoch-aware bootloader code with floor zero and no OTP advancement. +4. Confirm update, downgrade, unsigned-firmware, storage-preservation, and + recovery behavior on each hardware revision. +5. Audit factory OTP contents and permanently reserve the selected block. +6. Only a later release may advance epoch one. + +## Required tests + +- candidate epoch below/equal/above floor; +- malformed non-contiguous OTP patterns; +- exhausted 256-step counter; +- signature failure with a higher claimed epoch; +- unsigned firmware with a higher claimed epoch; +- hash mismatch after flash write; +- power loss before erase, during image write, after image verification, during + OTP programming, and before application magic installation; +- boot of an installed image below the floor; and +- recovery-mode behavior when no eligible application remains. + +The implementation PR must include a negative control showing that removing the +floor comparison permits a signed lower-epoch image. diff --git a/docs/security/pin-kdf-v19-migration.md b/docs/security/pin-kdf-v19-migration.md new file mode 100644 index 000000000..245bbb37b --- /dev/null +++ b/docs/security/pin-kdf-v19-migration.md @@ -0,0 +1,78 @@ +# PIN KDF v19 migration + +Status: draft implementation for review and hardware benchmarking + +Baseline: `BitHighlander/keepkey-firmware` `develop` at +`21d6a9d100b16566a1e48899abbbb7bab9366187` + +## Security goal + +Storage v16 reduced the production PBKDF2 work factor used to wrap the storage +key from 100,000 iterations to 10. A flash image therefore leaves a short PIN +with almost no cryptographic work factor if readout protection is bypassed. + +Storage v19 restores the production PIN work factor to 100,000 iterations. The +emulator and debug configurations use 1,000 iterations so the unit suite stays +practical. The change only covers the user PIN wrapping key; wipe-code and +authdata derivation remain on their existing parameters and need separate, +versioned migrations. + +## Compatibility invariant + +Existing wallets must always be unwrapped with the parameters that originally +wrapped them. The firmware must not rewrite a wallet until a correct PIN has +successfully authenticated the decrypted storage key. + +V19 therefore adds an explicit `pin_kdf_v2` storage flag instead of changing +the meaning of the existing v15/v16 flag: + +| Persistent state | KDF used to verify PIN | Action after correct PIN | +| --- | --- | --- | +| `pin_kdf_v2` | v19 | none | +| v16 transition flag only | v16 | rewrap with v19 and set `pin_kdf_v2` | +| neither flag | v15 | rewrap with v19 and set both transition flags | + +An incorrect PIN never changes the wrapped key or migration flags. New PINs +are wrapped directly with the v19 parameters. + +The v19 flag occupies bit 20 of the existing public-storage flags word. The +serialized byte length is unchanged. A v18 reader deliberately ignores this +bit; a v19 reader restores it. + +## Release ordering + +Do not ship this migration in a production release until the downgrade policy +is enforced. Older firmware does not understand storage version 19 or its KDF +flag. Allowing a device to boot an older signed image after migration risks a +wallet lockout, destructive recovery behavior, or accidental reinterpretation +of the storage record. + +The intended order is: + +1. Agree on and implement the anti-rollback security-epoch design in the + bootloader. +2. Prove the bootloader update and interruption behavior on real devices. +3. Benchmark the 100,000-iteration PIN path on supported KeepKey hardware. +4. Exercise v15, v16, and v18 migrations through wrong PIN, correct PIN, + interrupted commit, reboot, and recovery flows. +5. Enable v19 only in a release whose minimum security epoch rejects firmware + that cannot read it. + +## Required evidence + +- Unit tests prove the production v16-to-v19 rewrap path and the v19 selector. +- A negative control that disables rewrapping makes the regression test fail. +- A wrong PIN leaves the wrapped key and all migration flags unchanged. +- V19 round-trips the new flag; the V18 reader ignores it. +- Full emulator unit suites pass from a clean build. +- Hardware timing includes minimum, median, and maximum unlock latency across + supported board revisions and temperature/power conditions. +- Power-loss testing covers every write boundary during the rewrap commit. +- Downgrade attempts after migration fail closed without modifying storage. + +## Non-goals + +This change does not make short PINs equivalent to high-entropy secrets, add a +secure element, or prevent offline guessing after arbitrary flash extraction. +It restores a material software work factor while the hardware architecture +continues to rely on STM32 readout protection and write protection. diff --git a/include/keepkey/firmware/authenticator.h b/include/keepkey/firmware/authenticator.h index bb9981f68..a2fdf8775 100644 --- a/include/keepkey/firmware/authenticator.h +++ b/include/keepkey/firmware/authenticator.h @@ -26,6 +26,7 @@ #define ACCOUNT_SIZE 12 // allow 11 chars for account string #define AUTHSECRET_SIZE_MAX \ 20 // 128-bit key len is the recommended minimum, this is room for 160-bit +#define AUTHSECRET_SIZE_MIN 16 // reject brute-forceable TOTP secrets #define AUTHDATA_SIZE \ 10 // WARNING: This value must be coordinated with the size of uint8_t // encrypted_sec[] in in lib/firmware/storage.h and the storage version @@ -41,6 +42,8 @@ enum AUTH_ERR_TYPE { LARGESEED, BADPASS, UNKERR, + DUPLICATE, + AUTH_CANCELLED, NUM_AUTHERRS }; @@ -68,7 +71,7 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]); unsigned addAuthAccount(char* accountWithSeed); unsigned getAuthAccount(const char* slotStr, char acc[]); unsigned removeAuthAccount(char* domAcc); -void wipeAuthData(void); +unsigned wipeAuthData(void); #if DEBUG_LINK void getAuthSlot(char* authSlotData); #endif diff --git a/include/keepkey/firmware/bip85.h b/include/keepkey/firmware/bip85.h new file mode 100644 index 000000000..73c197995 --- /dev/null +++ b/include/keepkey/firmware/bip85.h @@ -0,0 +1,22 @@ +#ifndef BIP85_H +#define BIP85_H + +#include +#include +#include + +/** + * Derive a child BIP-39 mnemonic via BIP-85. + * + * Path: m/83696968'/39'/0'/'/' + * + * @param word_count Number of words: 12, 18, or 24. + * @param index Child index (0-based). + * @param mnemonic Output buffer (must be at least 241 bytes). + * @param mnemonic_len Size of the output buffer. + * @return true on success, false on error. + */ +bool bip85_derive_mnemonic(uint32_t word_count, uint32_t index, char *mnemonic, + size_t mnemonic_len); + +#endif diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 5e5e437c9..35a53251e 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -148,4 +148,6 @@ void fsm_msgFlashWrite(FlashWrite* msg); void fsm_msgFlashHash(FlashHash* msg); void fsm_msgSoftReset(SoftReset* msg); +void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic* msg); + #endif diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h index 9fc8c1954..1df5f7fa1 100644 --- a/include/keepkey/firmware/storage.h +++ b/include/keepkey/firmware/storage.h @@ -26,7 +26,19 @@ #include "keepkey/firmware/authenticator.h" #define STORAGE_VERSION \ - 17 /* Must add case fallthrough in storage_fromFlash after increment*/ + 19 /* Must add case fallthrough in storage_fromFlash after increment*/ + +/* A seed CREATED under bitcoin-only firmware is stamped with a version in a + * reserved band (base + the normal version). Multi-chain firmware that knows + * the band refuses to load it and requires an explicit wipe; older multi-chain + * firmware treats it as an unknown version and resets. Either way a seed born + * on bitcoin-only firmware is never usable by multi-chain code. A pre-existing + * multi-chain wallet keeps its normal version and stays portable (it was + * already multi-chain-exposed). Multi-chain versions MUST stay below the band + * forever (static-asserted in storage.c). */ +#define STORAGE_VERSION_BTC_ONLY_BASE 10000 +#define STORAGE_VERSION_BTC_ONLY \ + (STORAGE_VERSION_BTC_ONLY_BASE + STORAGE_VERSION) #define STORAGE_RETRIES 3 #define RANDOM_SALT_LEN 32 @@ -39,6 +51,12 @@ /// \brief Validate storage content and copy data to shadow memory. void storage_init(void); +/// \brief True iff flash holds storage written by bitcoin-only firmware that +/// this (multi-chain) firmware refuses to load. The device must be +/// wiped before it can be used; the seed stays intact in flash so +/// reflashing bitcoin-only firmware recovers the wallet. +bool storage_isBitcoinOnlyLocked(void); + /// \brief Reset configuration UUID with random numbers. void storage_resetUuid(void); diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index dcf21b9d4..7416b9524 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -3,6 +3,7 @@ set(sources app_layout.c authenticator.c binance.c + bip85.c coins.c crypto.c eip712.c diff --git a/lib/firmware/authenticator.c b/lib/firmware/authenticator.c index 851a48ad2..e07b0007c 100644 --- a/lib/firmware/authenticator.c +++ b/lib/firmware/authenticator.c @@ -57,6 +57,16 @@ static bool getAuthData(void) { static void setAuthData(void) { storage_setAuthData(authData); } +static bool authDisplayFieldValid(const char* value, size_t max_len) { + size_t len = strnlen(value, max_len + 1); + if (len == 0 || len > max_len) return false; + for (size_t i = 0; i < len; i++) { + uint8_t ch = (uint8_t)value[i]; + if (ch < 0x20 || ch > 0x7e) return false; + } + return true; +} + #if DEBUG_LINK static unsigned _otpSlot = 0; void getAuthSlot(char* authSlotData) { @@ -77,29 +87,31 @@ void getAuthSlot(char* authSlotData) { } #endif -void wipeAuthData(void) { - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Wipe Authdata", - "Do you want to PERMANENTLY delete all authenticator accounts?\n If " - "not, unplug Keepkey now."); +unsigned wipeAuthData(void) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Wipe Authdata", + "Do you want to PERMANENTLY delete all authenticator accounts?")) + return AUTH_CANCELLED; // wipe storage and reset authdata encryption flag storage_wipeAuthData(); // wipe local copy memzero(authData, sizeof(authData)); localAuthdataUpdate = true; - return; + return NOERR; } unsigned addAuthAccount(char* accountWithSeed) { char *domain, *account, *seedStr; - unsigned slot; - char authSecret[AUTHSECRET_SIZE_MAX]; // 128-bit key len is the recommended - // minimum, this is room for 160-bit + unsigned slot = AUTHDATA_SIZE; + char authSecret[AUTHSECRET_SIZE_MAX] = { + 0}; // 128-bit key len is the recommended minimum, this is room for + // 160-bit size_t authSecretLen; + unsigned result = UNKERR; // accountWithSeed should be of the form "domain:account:seedStr" domain = strtok(accountWithSeed, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { return TOKERR; } @@ -107,7 +119,7 @@ unsigned addAuthAccount(char* accountWithSeed) { if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -120,6 +132,9 @@ unsigned addAuthAccount(char* accountWithSeed) { } authSecretLen = base32_decoded_length(strlen(seedStr)); + if (authSecretLen < AUTHSECRET_SIZE_MIN) { + return BADSECRET; + } if (AUTHSECRET_SIZE_MAX < authSecretLen) { return LARGESEED; } @@ -128,11 +143,14 @@ unsigned addAuthAccount(char* accountWithSeed) { return BADPASS; // fingerprint did not match, passphrase incorrect } - // look for first empty slot - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if (authData[slot].secretSize == 0) { - break; - } + // Reject duplicate identities and remember the first empty slot. Legacy + // duplicates are removed together by removeAuthAccount(). + for (unsigned i = 0; i < AUTHDATA_SIZE; i++) { + if (authData[i].secretSize != 0 && + strncmp(authData[i].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[i].account, account, ACCOUNT_SIZE) == 0) + return DUPLICATE; + if (slot == AUTHDATA_SIZE && authData[i].secretSize == 0) slot = i; } if (slot == AUTHDATA_SIZE) { return NOSLOT; // no empty slots @@ -141,12 +159,21 @@ unsigned addAuthAccount(char* accountWithSeed) { if (NULL == base32_decode((const char*)seedStr, strlen(seedStr), (uint8_t*)authSecret, sizeof(authSecret), BASE32_ALPHABET_RFC4648)) { - return BADSECRET; // bad decode + result = BADSECRET; + goto cleanup; } - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm add account", - "Domain: %.*s\nAccount: %.*s\nSecret: %s", DOMAIN_SIZE, domain, - ACCOUNT_SIZE, account, seedStr); + // Keep the secret on its own screen. A 32-character base32 secret appended + // after domain/account can wrap past the OLED's three body rows, leaving the + // tail signed into storage but invisible to the user. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Auth Account", + "Domain: %.*s\nAccount: %.*s", DOMAIN_SIZE, domain, ACCOUNT_SIZE, + account) || + !confirm(ButtonRequestType_ButtonRequest_Other, "TOTP Secret", "%s", + seedStr)) { + result = AUTH_CANCELLED; + goto cleanup; + } authData[slot].secretSize = authSecretLen; memcpy(authData[slot].authSecret, authSecret, authData[slot].secretSize); @@ -154,8 +181,11 @@ unsigned addAuthAccount(char* accountWithSeed) { strlcpy(authData[slot].account, account, ACCOUNT_SIZE); setAuthData(); + result = NOERR; - return NOERR; // success +cleanup: + memzero(authSecret, sizeof(authSecret)); + return result; } unsigned generateOTP(char* accountWithMsg, char otpStr[]) { @@ -297,18 +327,18 @@ unsigned getAuthAccount(const char* slotStr, char acc[]) { unsigned removeAuthAccount(char* domAcc) { char *domain, *account; - unsigned slot; + bool found = false; // accountWithSeed should be of the form "domain:account" domain = strtok(domAcc, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { return TOKERR; } account = strtok(NULL, ""); // get the account string token if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -316,23 +346,30 @@ unsigned removeAuthAccount(char* domAcc) { return BADPASS; // fingerprint did not match, passphrase incorrect } - // find slot for account - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if ((0 == strncmp(authData[slot].domain, domain, DOMAIN_SIZE - 1)) && - (0 == strncmp(authData[slot].account, account, ACCOUNT_SIZE - 1))) { - break; - } + // Find every matching slot. Older firmware allowed duplicate identities, so + // a confirmed deletion must remove all copies atomically. + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + found = true; } - if (slot == AUTHDATA_SIZE) { + if (!found) { return NOACC; // account not found } - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Delete Account", - "Do you want to PERMANENTLY delete account %.*s:%.*s?", - DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Delete Account", + "Do you want to PERMANENTLY delete account %.*s:%.*s?", + DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account)) + return AUTH_CANCELLED; - memzero((void*)&authData[slot], sizeof(authType)); + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + memzero((void*)&authData[slot], sizeof(authType)); + } setAuthData(); return NOERR; // success } diff --git a/lib/firmware/bip85.c b/lib/firmware/bip85.c new file mode 100644 index 000000000..7d2ef6c81 --- /dev/null +++ b/lib/firmware/bip85.c @@ -0,0 +1,105 @@ +#include "keepkey/firmware/bip85.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/bip32.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/hmac.h" +#include "trezor/crypto/memzero.h" + +#include + +/* + * BIP-85: Deterministic Entropy From BIP32 Keychains + * + * For BIP-39 mnemonic derivation: + * path = m / 83696968' / 39' / 0' / ' / ' + * k = derived_node.private_key (32 bytes) + * hmac = HMAC-SHA512(key="bip-entropy-from-k", msg=k) + * entropy = hmac[0 : entropy_bytes] + * 12 words -> 16 bytes, 18 words -> 24 bytes, 24 words -> 32 bytes + * mnemonic = bip39_from_entropy(entropy) + */ + +/* BIP-85 application number for deriving entropy from a key */ +static const uint8_t BIP85_HMAC_KEY[] = "bip-entropy-from-k"; +#define BIP85_HMAC_KEY_LEN 18 + +bool bip85_derive_mnemonic(uint32_t word_count, uint32_t index, char *mnemonic, + size_t mnemonic_len) { + /* Reject index >= 0x80000000 to avoid hardened-bit collision */ + if (index & 0x80000000) { + return false; + } + + /* Validate word count and compute entropy length */ + int entropy_bytes; + switch (word_count) { + case 12: + entropy_bytes = 16; + break; + case 18: + entropy_bytes = 24; + break; + case 24: + entropy_bytes = 32; + break; + default: + return false; + } + + /* BIP-85 derivation path: m/83696968'/39'/0'/'/' */ + uint32_t address_n[5]; + address_n[0] = 0x80000000 | 83696968; /* purpose (hardened) */ + address_n[1] = 0x80000000 | 39; /* BIP-39 app (hardened) */ + address_n[2] = 0x80000000; /* English language 0 (hardened) */ + address_n[3] = 0x80000000 | word_count; /* word count (hardened) */ + address_n[4] = 0x80000000 | index; /* child index (hardened) */ + + /* Get the master node from storage (respects passphrase) */ + static CONFIDENTIAL HDNode node; + if (!storage_getRootNode(SECP256K1_NAME, true, &node)) { + memzero(&node, sizeof(node)); + return false; + } + + /* Derive to the BIP-85 path */ + for (int i = 0; i < 5; i++) { + if (hdnode_private_ckd(&node, address_n[i]) == 0) { + memzero(&node, sizeof(node)); + return false; + } + } + + /* HMAC-SHA512(key="bip-entropy-from-k", msg=private_key) */ + static CONFIDENTIAL uint8_t hmac_out[64]; + hmac_sha512(BIP85_HMAC_KEY, BIP85_HMAC_KEY_LEN, node.private_key, 32, + hmac_out); + + /* We no longer need the derived node */ + memzero(&node, sizeof(node)); + + /* Truncate HMAC output to the required entropy length */ + static CONFIDENTIAL uint8_t entropy[32]; + memcpy(entropy, hmac_out, entropy_bytes); + memzero(hmac_out, sizeof(hmac_out)); + + /* Convert entropy to BIP-39 mnemonic */ + const char *words = mnemonic_from_data(entropy, entropy_bytes); + memzero(entropy, sizeof(entropy)); + + if (!words) { + return false; + } + + /* Copy to output buffer */ + size_t words_len = strlen(words); + if (words_len >= mnemonic_len) { + mnemonic_clear(); + return false; + } + + memcpy(mnemonic, words, words_len + 1); + mnemonic_clear(); + + return true; +} diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index a230deadb..7adaf0173 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -35,6 +35,7 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/app_layout.h" #include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/bip85.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/cosmos.h" #include "keepkey/firmware/binance.h" @@ -283,6 +284,7 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_nano.h" #include "fsm_msg_crypto.h" #include "fsm_msg_debug.h" +#include "fsm_msg_bip85.h" #include "fsm_msg_eos.h" #include "fsm_msg_cosmos.h" #include "fsm_msg_osmosis.h" diff --git a/lib/firmware/fsm_msg_bip85.h b/lib/firmware/fsm_msg_bip85.h new file mode 100644 index 000000000..48b343303 --- /dev/null +++ b/lib/firmware/fsm_msg_bip85.h @@ -0,0 +1,142 @@ +void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic *msg) { + CHECK_INITIALIZED + + /* Validate word count (required field, always present in nanopb) */ + if (msg->word_count != 12 && msg->word_count != 18 && msg->word_count != 24) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "word_count must be 12, 18, or 24"); + layoutHome(); + return; + } + + /* Reject index >= 0x80000000 (hardened-bit collision) */ + if (msg->index & 0x80000000) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "index must be less than 2147483648"); + layoutHome(); + return; + } + + CHECK_PIN + + /* User confirmation */ + char desc[80]; + snprintf(desc, sizeof(desc), "Derive %lu-word child seed at index %lu?", + (unsigned long)msg->word_count, (unsigned long)msg->index); + + if (!confirm(ButtonRequestType_ButtonRequest_Other, "BIP-85 Derive Seed", + "%s", desc)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 derivation cancelled"); + layoutHome(); + return; + } + + layout_simple_message("Deriving child seed..."); + + /* Derive the mnemonic */ + static CONFIDENTIAL char mnemonic_buf[241]; + if (!bip85_derive_mnemonic(msg->word_count, msg->index, mnemonic_buf, + sizeof(mnemonic_buf))) { + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + fsm_sendFailure(FailureType_Failure_Other, "BIP-85 derivation failed"); + layoutHome(); + return; + } + + /* + * Display mnemonic on device screen only — never send over USB. + * Uses the same paginated display as the backup flow in reset.c. + */ + uint32_t word_count = 0, page_count = 0; + + /* Display scratch shared with the backup flow — see reset.h. Zero the whole + * set at entry per the sharing contract (a prior user may have aborted). */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + strlcpy(mnemonic_scratch_tokened, mnemonic_buf, TOKENED_MNEMONIC_BUF); + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + + const char *tok = strtok(mnemonic_scratch_tokened, " "); + + while (tok) { + snprintf(mnemonic_scratch_word, MAX_WORD_LEN + ADDITIONAL_WORD_PAD, + (word_count & 1) ? "%lu.%s\n" : "%lu.%s", + (unsigned long)(word_count + 1), tok); + + /* Check that we have enough room on display to show word */ + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + + if (calc_str_line(get_body_font(), mnemonic_scratch_display, BODY_WIDTH) > + 3) { + page_count++; + + if (MAX_PAGES <= page_count) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + fsm_sendFailure(FailureType_Failure_Other, + "Too many pages of mnemonic words"); + layoutHome(); + return; + } + + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + } + + strlcpy(mnemonic_scratch_formatted[page_count], mnemonic_scratch_display, + FORMATTED_MNEMONIC_BUF); + + tok = strtok(NULL, " "); + word_count++; + } + + /* Switch from 0-indexing to 1-indexing */ + page_count++; + + display_constant_power(true); + + /* Show each page of the mnemonic on screen */ + for (uint32_t current_page = 0; current_page < page_count; current_page++) { + char title[MEDIUM_STR_BUF]; + + if (page_count > 1) { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed %" PRIu32 "/%" PRIu32, + current_page + 1, page_count); + } else { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed"); + } + + if (!confirm_constant_power(ButtonRequestType_ButtonRequest_ConfirmWord, + title, "%s", + mnemonic_scratch_formatted[current_page])) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + display_constant_power(false); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 display cancelled"); + layoutHome(); + return; + } + } + + display_constant_power(false); + + /* Wipe all sensitive buffers */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + /* Send success — mnemonic is NOT sent over the wire */ + fsm_sendSuccess("BIP-85 seed displayed on device"); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 558f6673f..d1ba0eed9 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -166,13 +166,14 @@ static bool isValidModelNumber(const char* model) { return false; } -void checkPassphrase(void) { +static bool checkPassphrase(void) { if (!passphrase_protect()) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "authenticator needs passphrase"); layoutHome(); - return; + return false; } + return true; } void fsm_msgPing(Ping* msg) { @@ -198,6 +199,8 @@ void fsm_msgPing(Ping* msg) { "Authenticator secret seed too large", "passphrase incorrect for authdata", "Auth secret unknown error", + "Authenticator account already exists", + "Authenticator action cancelled", }; typedef enum _AUTH_MSG_TYPE { @@ -238,7 +241,7 @@ void fsm_msgPing(Ping* msg) { 0}; // allow room for domain + ":" + account CHECK_PIN - checkPassphrase(); + if (!checkPassphrase()) return; switch (authMsg) { case INITAUTH: @@ -277,8 +280,7 @@ void fsm_msgPing(Ping* msg) { break; case WIPEADATA: - wipeAuthData(); - errcode = NOERR; + errcode = wipeAuthData(); resp->has_message = false; break; diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 7f749e91a..12b50bf18 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -73,6 +73,8 @@ MSG_IN(MessageType_MessageType_MayachainSignTx, MayachainSignTx, fsm_msgMayachainSignTx) MSG_IN(MessageType_MessageType_MayachainMsgAck, MayachainMsgAck, fsm_msgMayachainMsgAck) + MSG_IN(MessageType_MessageType_GetBip85Mnemonic, GetBip85Mnemonic, fsm_msgGetBip85Mnemonic) + /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_Failure, Failure, NO_PROCESS_FUNC) diff --git a/lib/firmware/recovery_cipher.c b/lib/firmware/recovery_cipher.c index 0db3d1616..acfade212 100644 --- a/lib/firmware/recovery_cipher.c +++ b/lib/firmware/recovery_cipher.c @@ -53,6 +53,13 @@ static CONFIDENTIAL char mnemonic[MNEMONIC_BUF]; static char english_alphabet[ENGLISH_ALPHABET_BUF] = "abcdefghijklmnopqrstuvwxyz"; static CONFIDENTIAL char cipher[ENGLISH_ALPHABET_BUF]; +/* Accumulators for the word currently being entered. File-scope so + * recovery_delete_character() can keep them in sync with backspaces — + * otherwise stale bytes make a re-entered word fail validation and wipe a + * real recovery. last_completed_word backs the "previous word" indicator. */ +static CONFIDENTIAL char coded_word[12]; +static CONFIDENTIAL char decoded_word[12]; +static CONFIDENTIAL char last_completed_word[12]; #if DEBUG_LINK static char auto_completed_word[CURRENT_WORD_BUF]; @@ -74,6 +81,9 @@ void recovery_cipher_abort(void) { word_count = 0; memzero(mnemonic, sizeof(mnemonic)); memzero(cipher, sizeof(cipher)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); } /// Formats the passed word to show position in mnemonic as well as characters @@ -181,7 +191,11 @@ bool attempt_auto_complete(char* partial_word) { return false; } - static uint16_t CONFIDENTIAL permute[2049]; + /* 4 KB permutation table lives in the shared frame arena: too big for the + * stack, wasteful as its own static. Transient within this call (memzero'd + * on every exit), and this function never encodes a USB response while the + * table is live — see the FrameArena contract in messages.c. */ + uint16_t* permute = frame_arena_scratch2049(); for (int i = 0; i < 2049; i++) { permute[i] = i; } @@ -215,18 +229,18 @@ bool attempt_auto_complete(char* partial_word) { } if (precise_match) { - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } /* Autocomplete if we can */ if (match == 1) { strlcpy(partial_word, words[permute[found]], CURRENT_WORD_BUF); - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return false; } @@ -376,8 +390,16 @@ void next_character(void) { format_current_word(word_pos, current_word, auto_completed, &formatted_word); memzero(current_word, sizeof(current_word)); + /* Format previous word indicator (e.g. "(1.alcohol)" when entering word 2) */ + static char prev_info[32]; + prev_info[0] = '\0'; + if (word_pos > 0 && last_completed_word[0]) { + snprintf(prev_info, sizeof(prev_info), "(%" PRIu32 ".%s)", word_pos, + last_completed_word); + } + /* Show cipher and partial word */ - layout_cipher(formatted_word, cipher); + layout_cipher(formatted_word, cipher, prev_info); memzero(formatted_word, sizeof(formatted_word)); } @@ -420,14 +442,13 @@ void recovery_character(const char* character) { // Count of words we think the user has entered without using the cipher: static int uncyphered_word_count = 0; static bool definitely_using_cipher = false; - static CONFIDENTIAL char coded_word[12]; - static CONFIDENTIAL char decoded_word[12]; if (!mnemonic[0]) { uncyphered_word_count = 0; definitely_using_cipher = false; memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); } char decoded_character[2] = " "; @@ -462,6 +483,30 @@ void recovery_character(const char* character) { } } } else { + /* Per-word BIP39 validation: reject immediately if the decoded word + * doesn't match any entry in the wordlist. decoded_word is kept in sync + * with backspaces by recovery_delete_character(), so a corrected word is + * validated on its real (post-edit) value. */ + if (strlen(decoded_word) > 0) { + static CONFIDENTIAL char check_word[CURRENT_WORD_BUF]; + strlcpy(check_word, decoded_word, sizeof(check_word)); + bool valid = attempt_auto_complete(check_word); + if (enforce_wordlist && !valid) { + memzero(check_word, sizeof(check_word)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); + recovery_cipher_abort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Word not found in BIP39 wordlist"); + layout_warning_static("Word not in wordlist"); + return; + } + /* Record the just-completed (auto-expanded) word for the "previous + * word" indicator — only at a real word boundary, never mid-word. */ + strlcpy(last_completed_word, check_word, sizeof(last_completed_word)); + memzero(check_word, sizeof(check_word)); + } + memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); @@ -512,6 +557,22 @@ void recovery_delete_character(void) { mnemonic[len - 1] = '\0'; } + /* Resync the current-word accumulators with the edited mnemonic so a + * corrected word is validated on its real value (stale bytes here would + * fail validation and trigger a storage_reset on a real recovery). + * decoded_word is the typed prefix of the current word; coded_word is its + * reverse-cipher form (session cipher is fixed, so it is reconstructable). */ + char cur[CURRENT_WORD_BUF]; + get_current_word(cur); + strlcpy(decoded_word, cur, sizeof(decoded_word)); + memzero(cur, sizeof(cur)); + size_t wlen = strlen(decoded_word); + for (size_t i = 0; i < wlen && i + 1 < sizeof(coded_word); i++) { + char d = decoded_word[i]; + coded_word[i] = (d >= 'a' && d <= 'z') ? cipher[d - 'a'] : d; + } + coded_word[wlen < sizeof(coded_word) ? wlen : sizeof(coded_word) - 1] = '\0'; + next_character(); } @@ -575,7 +636,11 @@ void recovery_cipher_finalize(void) { } memzero(temp_word, sizeof(temp_word)); - if (!auto_completed && !enforce_wordlist) { + /* Cipher recovery decodes to BIP-39 words, so every word must + * auto-complete regardless of enforce_wordlist. Failing only when + * enforce_wordlist was set left the default (host-omitted) path storing a + * mistyped/garbage phrase as the seed and reporting success. */ + if (!auto_completed) { if (!dry_run) { storage_reset(); } diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index c1cd77d12..08b52dd5e 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -44,7 +44,9 @@ #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/passphrase_sm.h" #include "keepkey/firmware/policy.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/u2f.h" +#include "keepkey/firmware/zcash.h" #include "keepkey/rand/rng.h" #include "keepkey/transport/interface.h" #include "trezor/crypto/aes/aes.h" @@ -60,22 +62,26 @@ #include /* -The PIN_ITER defines below changed between storage version 15 and 16 to -eliminate the unacceptable multi-second wait while the pin was being stretched -for a dubious claim to better security. The defines help during upgrades from -v15 to v16 -*/ + * PIN wrapping-key parameters are part of the persistent storage format. + * Never change an existing set in place: old wallets must first unwrap with + * their original parameters, then rewrap after a correct PIN. V19 restores a + * meaningful offline-work factor after V16 reduced it to ten iterations. + */ #if defined(EMULATOR) || defined(DEBUG_ON) #define PIN_ITER_COUNT_v15 1000 #define PIN_ITER_CHUNK_v15 10 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 1000 +#define PIN_ITER_CHUNK_v19 10 #else #define PIN_ITER_COUNT_v15 100000 #define PIN_ITER_CHUNK_v15 1000 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 100000 +#define PIN_ITER_CHUNK_v19 1000 #endif #define U2F_KEY_PATH 0x80553246 @@ -90,6 +96,28 @@ _Static_assert(sizeof(ConfigFlash) <= FLASH_STORAGE_LEN, "ConfigFlash struct is too large for storage partition"); static ConfigFlash CONFIDENTIAL shadow_config; +/* This firmware found storage in flash it must refuse to load or overwrite + * until the user explicitly wipes: a bitcoin-only wallet seen by multi-chain + * firmware, or (on bitcoin-only firmware) a newer in-band wallet than this + * build understands. Set from the SUS_BitcoinOnlyLocked path in either build. + */ +static bool btc_only_locked = false; + +bool storage_isBitcoinOnlyLocked(void) { return btc_only_locked; } + +// Stamp a newly-created seed into the reserved bitcoin-only version band so +// multi-chain firmware refuses it (see storage_fromFlash). Called only from +// seed-creation paths, so a pre-existing multi-chain wallet migrated under +// bitcoin-only firmware keeps its normal, portable version. No-op (but still +// referenced, so no -Wunused) in multi-chain builds. +#if BITCOIN_ONLY +static void storage_stampBitcoinOnlySeed(void) { + shadow_config.storage.version = STORAGE_VERSION_BTC_ONLY; +} +#else +static void storage_stampBitcoinOnlySeed(void) {} +#endif + #if DEBUG_LINK // These won't survive resets like the stuff in flash would, but thats a // reasonable compromise given how testing works. @@ -184,8 +212,14 @@ enum StorageVersion { StorageVersion_NONE, #define STORAGE_VERSION_ENTRY(VAL) StorageVersion_##VAL, #include "storage_versions.inc" + StorageVersion_BTC_ONLY, // reserved band, never in storage_versions.inc }; +// The normal storage version must stay below the bitcoin-only band, or a +// bitcoin-only wallet would become loadable by multi-chain firmware. +_Static_assert(STORAGE_VERSION < STORAGE_VERSION_BTC_ONLY_BASE, + "storage version must stay below the bitcoin-only band"); + static enum StorageVersion version_from_int(int version) { #define STORAGE_VERSION_LAST(VAL) \ _Static_assert(VAL == STORAGE_VERSION, \ @@ -193,6 +227,12 @@ static enum StorageVersion version_from_int(int version) { "storage_versions.inc"); #include "storage_versions.inc" + // Any version in the reserved bitcoin-only band maps here regardless of + // build; storage_fromFlash decides load-vs-refuse from the exact value, so + // an in-band firmware downgrade refuses rather than silently wiping a newer + // bitcoin-only wallet. + if (version >= STORAGE_VERSION_BTC_ONLY_BASE) return StorageVersion_BTC_ONLY; + switch (version) { #define STORAGE_VERSION_ENTRY(VAL) \ case VAL: \ @@ -280,20 +320,27 @@ void storage_writeHDNode(char* ptr, size_t len, const HDNodeType* node) { } void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message) { size_t pin_len = strlen(pin); if (sca_hardened && pin_len > 0) { uint8_t salt[HW_ENTROPY_LEN + RANDOM_SALT_LEN]; - int iterCount, iterChunk; - - if (v15_16_trans) { // can use new counts - iterCount = PIN_ITER_COUNT_v16; - iterChunk = PIN_ITER_CHUNK_v16; - } else { // need to use storage version 15 counts to derive wrap key - iterCount = PIN_ITER_COUNT_v15; - iterChunk = PIN_ITER_CHUNK_v15; + int iterCount = PIN_ITER_COUNT_v19; + int iterChunk = PIN_ITER_CHUNK_v19; + + switch (pin_kdf_version) { + case PIN_KDF_V15: + iterCount = PIN_ITER_COUNT_v15; + iterChunk = PIN_ITER_CHUNK_v15; + break; + case PIN_KDF_V16: + iterCount = PIN_ITER_COUNT_v16; + iterChunk = PIN_ITER_CHUNK_v16; + break; + case PIN_KDF_V19: + break; } memset(salt, 0, sizeof(salt)); @@ -362,7 +409,7 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]) { pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { /* This function tests whether the PIN is correct. It will return @@ -377,7 +424,13 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], required to update the flash with a storage_commit(). */ uint8_t wrapping_key[64]; - storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, *v15_16_trans, + pin_kdf_version_t pin_kdf_version = PIN_KDF_V15; + if (*pin_kdf_v2) { + pin_kdf_version = PIN_KDF_V19; + } else if (*v15_16_trans) { + pin_kdf_version = PIN_KDF_V16; + } + storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, pin_kdf_version, random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test @@ -396,16 +449,16 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], if (memcmp_s(fp, fingerprint, 32) == 0) ret = PIN_GOOD; if (ret == PIN_GOOD) { - if (!*sca_hardened || !*v15_16_trans) { + if (!*sca_hardened || !*v15_16_trans || !*pin_kdf_v2) { // PIN is correct but: // 1. wrapping key needs to be regenerated using stretched key // 2. storage key needs a rewrap with new wrapping key and algorithm storage_deriveWrappingKey(pin, wrapping_key, true /* sca_hardened */, - true /* v15_16_trans */, random_salt, - _("Verifying PIN")); + PIN_KDF_V19, random_salt, _("Verifying PIN")); storage_wrapStorageKey(wrapping_key, key, wrapped_key); *sca_hardened = true; *v15_16_trans = true; + *pin_kdf_v2 = true; ret = PIN_REWRAP; } } @@ -422,8 +475,8 @@ pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { uint8_t wrapping_key[64]; - storage_deriveWrappingKey(wipe_code, wrapping_key, true, true, random_salt, - _("Verifying PIN")); + storage_deriveWrappingKey(wipe_code, wrapping_key, true, PIN_KDF_V16, + random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test storage_unwrapStorageKey(wrapping_key, wrapped_key, key); @@ -559,8 +612,7 @@ void storage_secMigrate(SessionState* ss, Storage* storage, bool encrypt) { void storage_deriveAuthdataKey(const char* passphrase, uint8_t authdataKey[64]) { storage_deriveWrappingKey(passphrase, authdataKey, - /*sca_hardened*/ true, - /*v15_16_trans*/ true, + /*sca_hardened*/ true, PIN_KDF_V16, shadow_config.storage.pub.random_salt, "deriving authdata key"); return; @@ -993,6 +1045,7 @@ void storage_readStorageV16Plaintext(Storage* storage, const char* ptr, storage->pub.sca_hardened = flags & (1u << 15); storage->pub.has_wipe_code = flags & (1u << 16); storage->pub.v15_16_trans = flags & (1u << 17); + storage->pub.pin_kdf_v2 = false; storage->pub.policies_count = POLICY_COUNT; @@ -1080,6 +1133,41 @@ void storage_readStorageV17(Storage* storage, const char* ptr, size_t len) { memcpy(storage->encrypted_sec, ptr + 1501, sizeof(storage->encrypted_sec)); } +// V18 appended a clear-sign identity block immediately after encrypted_sec. +// RC18 retains the byte layout for compatibility but retires those records: +// public storage has no authenticated integrity, so they are zeroed on both +// read and write and are never consulted as trust anchors. +// One identity serializes to CLEARSIGN_IDENTITY_SERIALIZED_LEN bytes: +// +0 present(u8) +1 key_id(u8) +2 pubkey[33] +35 alias[32] +67 icon_w(u8) +// +68 icon_h(u8) +69 icon_len(u16 le) +71 icon[CLEARSIGN_ICON_MAX] = 71+384 +#define CLEARSIGN_IDENTITY_BLOCK_OFF (1501 + V17_ENCSEC_SIZE) // 2525 +#define CLEARSIGN_IDENTITY_SERIALIZED_LEN (71 + CLEARSIGN_ICON_MAX) // 455 + +void storage_writeStorageV18(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV17(ptr, len, storage); + memzero(ptr + CLEARSIGN_IDENTITY_BLOCK_OFF, + PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN); +} + +void storage_readStorageV18(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV17(storage, ptr, len); + memzero(storage->pub.clearsign_identities, + sizeof(storage->pub.clearsign_identities)); +} + +void storage_writeStorageV19(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV18(ptr, len, storage); + uint32_t flags = read_u32_le(ptr + 4); + flags |= storage->pub.pin_kdf_v2 ? (1u << 20) : 0; + write_u32_le(ptr + 4, flags); +} + +void storage_readStorageV19(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV18(storage, ptr, len); + uint32_t flags = read_u32_le(ptr + 4); + storage->pub.pin_kdf_v2 = flags & (1u << 20); +} + void storage_readCacheV1(Cache* cache, const char* ptr, size_t len) { if (len < 65 + 10) return; cache->root_seed_cache_status = read_u8(ptr); @@ -1148,12 +1236,37 @@ void storage_writeV17(char* flash, size_t len, const ConfigFlash* src) { storage_writeStorageV17(flash + 44, 852, &src->storage); } +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV18(&dst->storage, flash + 44, 852); +} + +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV18(flash + 44, 852, &src->storage); +} + +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV19(&dst->storage, flash + 44, 852); +} + +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV19(flash + 44, 852, &src->storage); +} + StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, const char* flash) { memzero(dst, sizeof(*dst)); // Load config values from active config node. - enum StorageVersion version = version_from_int(read_u32_le(flash + 44)); + uint32_t raw_version = read_u32_le(flash + 44); + enum StorageVersion version = version_from_int(raw_version); switch (version) { case StorageVersion_1: @@ -1199,9 +1312,60 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; case StorageVersion_17: + // Migrate up: the V17 reader leaves clearsign_identities zeroed (the + // memzero(dst) at the top => present=false), so no data loss. Stamping + // STORAGE_VERSION (18) makes this SUS_Updated, and the re-commit writes + // the V18 layout (empty identities block). storage_readV17(dst, flash, STORAGE_SECTOR_LEN); dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_18: + storage_readV18(dst, flash, STORAGE_SECTOR_LEN); + dst->storage.version = STORAGE_VERSION; + return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_19: + storage_readV19(dst, flash, STORAGE_SECTOR_LEN); + dst->storage.version = STORAGE_VERSION; + return dst->storage.version == version ? SUS_Valid : SUS_Updated; + + case StorageVersion_BTC_ONLY: +#if BITCOIN_ONLY + { + // Our own bitcoin-only wallet. The stored wire version is the multi-chain + // storage version plus the band base, so recover the underlying layout + // version and load it through the normal migration chain. Exact-matching + // STORAGE_VERSION_BTC_ONLY here would lock every existing bitcoin-only + // wallet out of its own firmware on the next STORAGE_VERSION bump. + uint32_t underlying = raw_version - STORAGE_VERSION_BTC_ONLY_BASE; + if (underlying > (uint32_t)STORAGE_VERSION) { + // A newer bitcoin-only wallet than this firmware understands: refuse + // rather than wipe, so a firmware downgrade never destroys it. + return SUS_BitcoinOnlyLocked; + } + // Read via the reader matching the underlying version (same mapping as + // the multi-chain path above), then keep the band stamp so multi-chain + // firmware still refuses it. + if (underlying <= 15) { + storage_readV11(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 16) { + storage_readV16(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 17) { + storage_readV17(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 18) { + storage_readV18(dst, flash, STORAGE_SECTOR_LEN); + } else { + storage_readV19(dst, flash, STORAGE_SECTOR_LEN); + } + dst->storage.version = STORAGE_VERSION_BTC_ONLY; + return (underlying == (uint32_t)STORAGE_VERSION) ? SUS_Valid + : SUS_Updated; + } +#else + // Written by bitcoin-only firmware: refuse to load. The wallet stays + // intact in flash (reflash bitcoin-only firmware to recover it); using + // multi-chain firmware requires an explicit wipe. + return SUS_BitcoinOnlyLocked; +#endif case StorageVersion_NONE: return SUS_Invalid; @@ -1340,6 +1504,13 @@ void storage_init(void) { // that it's available on next boot without conversion. storage_commit(); break; + case SUS_BitcoinOnlyLocked: + // Bitcoin-only wallet in flash: act as an uninitialized, locked device. + // Do NOT commit -- flash stays untouched so reflashing bitcoin-only + // firmware recovers the wallet; leaving requires an explicit wipe. + btc_only_locked = true; + storage_reset(); + break; } if (!storage_hasPin()) { @@ -1368,6 +1539,9 @@ void storage_resetUuid_impl(ConfigFlash* cfg) { void storage_reset(void) { storage_reset_impl(&session, &shadow_config); } void storage_reset_impl(SessionState* ss, ConfigFlash* cfg) { + bip32_cache_clear(); + bip39_cache_clear(); + memset(&cfg->storage, 0, sizeof(cfg->storage)); storage_resetPolicies(&cfg->storage); @@ -1386,6 +1560,9 @@ void storage_wipe(void) { flash_erase_word(FLASH_STORAGE1); flash_erase_word(FLASH_STORAGE2); flash_erase_word(FLASH_STORAGE3); + + // The bitcoin-only wallet (if any) is gone; the device may be used freely. + btc_only_locked = false; } void storage_clearKeys(void) { @@ -1401,6 +1578,9 @@ void storage_clearKeys(void) { } void session_clear(bool clear_pin) { + /* Runtime ClearSign trust belongs to the unlocked device session. Any path + * that tears that session down must also revoke its RAM-only signer slots. */ + signed_metadata_clear_signers(); if (PIN_REWRAP == session_clear_impl(&session, &shadow_config.storage, clear_pin)) { storage_commit(); @@ -1423,6 +1603,9 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, */ pintest_t ret = PIN_WRONG; + bip32_cache_clear(); + bip39_cache_clear(); + ss->seedCached = false; memset(&ss->seed, 0, sizeof(ss->seed)); @@ -1430,11 +1613,11 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, memset(&ss->passphrase, 0, sizeof(ss->passphrase)); if (!storage_hasPin_impl(storage)) { - ret = storage_isPinCorrect_impl("", storage->pub.wrapped_storage_key, - storage->pub.storage_key_fingerprint, - &storage->pub.sca_hardened, - &storage->pub.v15_16_trans, ss->storageKey, - shadow_config.storage.pub.random_salt); + ret = storage_isPinCorrect_impl( + "", storage->pub.wrapped_storage_key, + storage->pub.storage_key_fingerprint, &storage->pub.sca_hardened, + &storage->pub.v15_16_trans, &storage->pub.pin_kdf_v2, ss->storageKey, + shadow_config.storage.pub.random_salt); if (ret == PIN_WRONG) { ss->pinCached = false; @@ -1460,9 +1643,18 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, } void storage_commit(void) { + // Never overwrite a bitcoin-only wallet from multi-chain firmware; the + // only way out is storage_wipe() (which clears the lock). This is the + // backstop behind the per-handler checks. + if (btc_only_locked) return; + // Temporary storage for marshalling secrets in & out of flash. - // Size of v17 storage layout (2525 bytes) + size of meta (44 bytes) + 1 - static char flash_temp[2570]; + // V19 storage layout = V18 (same byte length) with a versioned PIN-KDF flag. + // V18 = V17 (2525 bytes) + retired identity block + // (PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN = 2*455 = + // 910) = 3435; + meta (44) = 3479. Rounded up to a multiple of 4 (the CRC + // below iterates uint32_t words) => 3480 (1 byte of slack). + static char flash_temp[3480]; memzero(flash_temp, sizeof(flash_temp)); @@ -1472,7 +1664,7 @@ void storage_commit(void) { // commit what was in storage->encrypted_sec } - storage_writeV17(flash_temp, sizeof(flash_temp), &shadow_config); + storage_writeV19(flash_temp, sizeof(flash_temp), &shadow_config); memcpy(&shadow_config, STORAGE_MAGIC_STR, STORAGE_MAGIC_LEN); @@ -1624,6 +1816,10 @@ void storage_loadDevice(LoadDevice* msg) { memset(&session.seed, 0, sizeof(session.seed)); } + if (msg->has_node || msg->has_mnemonic) { + storage_stampBitcoinOnlySeed(); + } + if (msg->has_language) { storage_setLanguage(msg->language); } @@ -1685,7 +1881,8 @@ bool storage_isPinCorrect(const char* pin) { pin, shadow_config.storage.pub.wrapped_storage_key, shadow_config.storage.pub.storage_key_fingerprint, &shadow_config.storage.pub.sca_hardened, - &shadow_config.storage.pub.v15_16_trans, session.storageKey, + &shadow_config.storage.pub.v15_16_trans, + &shadow_config.storage.pub.pin_kdf_v2, session.storageKey, shadow_config.storage.pub.random_salt); switch (ret) { @@ -1731,7 +1928,7 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { // Derive the wrapping key for the new pin uint8_t wrapping_key[64]; storage_deriveWrappingKey(pin, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, + PIN_KDF_V19, storage->pub.random_salt, _("Encrypting Secrets")); // Derive a new storageKey. @@ -1742,6 +1939,7 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { storage->pub.wrapped_storage_key); storage->pub.sca_hardened = true; storage->pub.v15_16_trans = true; + storage->pub.pin_kdf_v2 = true; // Fingerprint the storageKey. storage_keyFingerprint(ss->storageKey, storage->pub.storage_key_fingerprint); @@ -1791,7 +1989,7 @@ void storage_setWipeCode_impl(SessionState* ss, Storage* storage, // Derive the wrapping key for the new wipe code uint8_t wrapping_key[64]; storage_deriveWrappingKey(wipe_code, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, + PIN_KDF_V16, storage->pub.random_salt, _("Updating Wipe Code")); // Derive a new wipe code key . @@ -1866,6 +2064,43 @@ const uint8_t* storage_getSeed(const ConfigFlash* cfg, bool usePassphrase) { return NULL; } +/* ── Zcash storage-scoped wrappers ─────────────────────────────────── + * + * ZIP-32 Orchard derives keys directly from the raw 64-byte BIP-39 seed + * (not the BIP-32 master node). Rather than expose a generic + * "give me the seed" function, storage owns the seed access and only + * returns derived material — Orchard keys or the 32-byte fingerprint. + * The seed pointer never leaves this translation unit. + */ + +#if ZCASH_PRIVACY +static void storage_zcash_orchard_progress(uint32_t completed, uint32_t total, + void* context) { + (void)context; + if (total == 0) return; + animating_progress_handler(_("Deriving Zcash"), + (int)((completed * 1000u) / total)); +} + +bool storage_zcashOrchardKeys(uint32_t account, bool usePassphrase, + ZcashOrchardKeys* keys_out) { + if (!keys_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + animating_progress_handler(_("Deriving Zcash"), 0); + return zcash_derive_orchard_keys_with_progress( + seed, 64, account, keys_out, storage_zcash_orchard_progress, NULL); +} + +bool storage_zcashSeedFingerprint(bool usePassphrase, + uint8_t fingerprint_out[32]) { + if (!fingerprint_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + return zcash_calculate_seed_fingerprint(seed, 64, fingerprint_out); +} +#endif + bool storage_getRootNode(const char* curve, bool usePassphrase, HDNode* node) { // if storage has node, decrypt and use it if (shadow_config.storage.pub.has_node && @@ -1994,6 +2229,7 @@ void storage_setMnemonicFromWords(const char (*words)[12], shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); @@ -2011,6 +2247,7 @@ void storage_setMnemonic(const char* m) { #endif shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); diff --git a/lib/firmware/storage.h b/lib/firmware/storage.h index 10b84217b..c814c530d 100644 --- a/lib/firmware/storage.h +++ b/lib/firmware/storage.h @@ -32,6 +32,31 @@ #define V16_ENCSEC_SIZE 512 // for reading old encrypted sec size #define V17_ENCSEC_SIZE 1024 +/* Retired V18 clear-sign identity record. The fixed-size fields remain in the + * in-memory/storage layout for backward compatibility, but RC18 never trusts, + * returns, or writes their contents: this public section lacks authenticated + * integrity against physical flash modification. + * pubkey : 33-byte compressed secp256k1 (matches signed_metadata slots) + * alias : METADATA_ALIAS_MAX_LEN(31)+1, printable [A-Za-z0-9 _-] + * icon : 1bpp mono row-major bitmap, <= CLEARSIGN_ICON_MAX bytes, + * icon_len==0 => text-only identity (no logo) + * Serialized size is fixed (CLEARSIGN_IDENTITY_SERIALIZED_LEN) — appended after + * encrypted_sec in the V18 storage layout; never reorder existing fields. */ +#define CLEARSIGN_ICON_MAX 384 +#define CLEARSIGN_IDENTITY_ALIAS_SIZE 32 /* METADATA_ALIAS_MAX_LEN(31) + 1 */ +#define PERSISTENT_IDENTITY_COUNT 2 +typedef struct _ClearsignIdentity { + bool present; + uint8_t key_id; // the signer slot (1..METADATA_MAX_KEYS-1) this identity + // reloads into; the per-tx blob's key_id selects it + uint8_t pubkey[33]; + char alias[CLEARSIGN_IDENTITY_ALIAS_SIZE]; + uint8_t icon_w; + uint8_t icon_h; + uint16_t icon_len; + uint8_t icon[CLEARSIGN_ICON_MAX]; +} ClearsignIdentity; + typedef struct _authBlockType { authType authData[AUTHDATA_SIZE]; // 450 uint8_t reserved[512 - sizeof(authType) * AUTHDATA_SIZE]; // 62 @@ -66,10 +91,13 @@ typedef struct _Storage { bool no_backup; bool sca_hardened; bool v15_16_trans; + bool pin_kdf_v2; bool authdata_initialized; bool authdata_encrypted; uint8_t random_salt[32]; uint8_t authdata_fingerprint[32]; + /* V18 legacy clear-sign records. Always scrubbed on read and write. */ + ClearsignIdentity clearsign_identities[PERSISTENT_IDENTITY_COUNT]; } pub; bool has_sec; @@ -112,13 +140,20 @@ typedef enum { PIN_REWRAP // PIN correct but storage key rewrapped, requires storage update } pintest_t; +typedef enum { + PIN_KDF_V15, + PIN_KDF_V16, + PIN_KDF_V19, +} pin_kdf_version_t; + #define MAX_MNEMONIC_LEN 240 void storage_loadNode(HDNode* dst, const HDNodeType* src); /// Derive the wrapping key from the user's pin. void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message); @@ -145,7 +180,7 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]); pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]); pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, @@ -185,6 +220,7 @@ typedef enum { SUS_Invalid, SUS_Valid, SUS_Updated, + SUS_BitcoinOnlyLocked, // written by bitcoin-only firmware; refuse to load } StorageUpdateStatus; /// \brief Copy configuration from storage partition in flash memory to shadow @@ -203,8 +239,12 @@ void storage_readV2(SessionState* ss, ConfigFlash* dst, const char* flash, size_t len); void storage_readV11(ConfigFlash* dst, const char* flash, size_t len); void storage_readV16(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len); void storage_writeV11(char* flash, size_t len, const ConfigFlash* src); void storage_writeV16(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src); void storage_readMeta(Metadata* meta, const char* ptr, size_t len); void storage_readPolicyV1(PolicyType* policy, const char* ptr, size_t len); diff --git a/lib/firmware/storage_versions.inc b/lib/firmware/storage_versions.inc index a2e6eda30..c622336cd 100644 --- a/lib/firmware/storage_versions.inc +++ b/lib/firmware/storage_versions.inc @@ -22,7 +22,9 @@ STORAGE_VERSION_ENTRY(13) STORAGE_VERSION_ENTRY(14) STORAGE_VERSION_ENTRY(15) STORAGE_VERSION_ENTRY(16) -STORAGE_VERSION_LAST(17) +STORAGE_VERSION_ENTRY(17) +STORAGE_VERSION_ENTRY(18) +STORAGE_VERSION_LAST(19) #undef STORAGE_VERSION_ENTRY diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b997e1738..5e828e1ae 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + authenticator.cpp app_confirm.cpp coins.cpp cosmos.cpp diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp new file mode 100644 index 000000000..e5615cb61 --- /dev/null +++ b/unittests/firmware/authenticator.cpp @@ -0,0 +1,111 @@ +extern "C" { +#include + +#include "trezor/crypto/sha2.h" +#include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/storage.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +// Shared emulator confirmation driver from thorchain.cpp. +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static void ensure_auth_storage_initialized(void) { + static bool initialized = false; + if (!initialized) { + setup(); + storage_init(); + initialized = true; + } +} + +TEST(Authenticator, WipeCancellationFailsClosed) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Authenticator, AddAndRemoveCancellationFailsClosed) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, addAuthAccount(cancelled_add)); + EXPECT_EQ(0, kkconfirm_drain()); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + EXPECT_EQ(NOACC, getAuthAccount("0", account)); + + char accepted_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(accepted_add)); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_remove[] = "example:alice"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, removeAuthAccount(cancelled_remove)); + EXPECT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOERR, getAuthAccount("0", account)); + EXPECT_STREQ("example:alice", account); + + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Authenticator, RejectsAmbiguousDisplayFieldsBeforeMutation) { + char long_domain[] = "domain-is-too-long:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_domain)); + + char control_domain[] = "bad\ndomain:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(control_domain)); + + char long_account[] = "example:account-is-too-long:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_account)); + + char remove_long[] = "example:account-is-too-long"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_long)); + + char remove_control[] = "example:bad\naccount"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_control)); +} + +TEST(Authenticator, RejectsWeakAndDuplicateSecrets) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char weak[] = "example:weak:MY"; + EXPECT_EQ(BADSECRET, addAuthAccount(weak)); + + // The final invalid block fails after earlier blocks have decoded; the + // implementation must still take its cleanup path. + char partially_decoded[] = "example:invalid:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PX!"; + EXPECT_EQ(BADSECRET, addAuthAccount(partially_decoded)); + + char first[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(first)); + EXPECT_EQ(0, kkconfirm_drain()); + + char duplicate[] = "example:alice:KRSXG5DSNFXGOIDBKRSXG5DSNFXGOIDB"; + EXPECT_EQ(DUPLICATE, addAuthAccount(duplicate)); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + EXPECT_EQ(NOACC, getAuthAccount("1", account)); + + char remove[] = "example:alice"; + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, removeAuthAccount(remove)); + EXPECT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOACC, getAuthAccount("0", account)); +} diff --git a/unittests/firmware/storage.cpp b/unittests/firmware/storage.cpp index b04af1ebe..8886dbbcc 100644 --- a/unittests/firmware/storage.cpp +++ b/unittests/firmware/storage.cpp @@ -196,7 +196,10 @@ TEST(Storage, ReadStorageV1) { // Decrypt upgraded storage. uint8_t wrapping_key[64]; storage_deriveWrappingKey("123456789", wrapping_key, dst.pub.sca_hardened, - dst.pub.v15_16_trans, + dst.pub.pin_kdf_v2 + ? PIN_KDF_V19 + : (dst.pub.v15_16_trans ? PIN_KDF_V16 + : PIN_KDF_V15), dst.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, dst.pub.wrapped_storage_key, session.storageKey); @@ -466,7 +469,9 @@ TEST(Storage, StorageUpgrade_Normal) { uint8_t wrapping_key[64]; storage_deriveWrappingKey( "123456789", wrapping_key, shadow.storage.pub.sca_hardened, - shadow.storage.pub.v15_16_trans, + shadow.storage.pub.pin_kdf_v2 + ? PIN_KDF_V19 + : (shadow.storage.pub.v15_16_trans ? PIN_KDF_V16 : PIN_KDF_V15), shadow.storage.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, shadow.storage.pub.wrapped_storage_key, session.storageKey); @@ -497,6 +502,70 @@ TEST(Storage, StorageUpgrade_Normal) { EXPECT_EQ(shadow.storage.pub.policies[1].enabled, true); } +#if !BITCOIN_ONLY +// A seed created under bitcoin-only firmware is stamped in a reserved version +// band. Multi-chain firmware must REFUSE it (SUS_BitcoinOnlyLocked), not load +// it and not silently reset it here -- the seed stays intact in flash until an +// explicit wipe. This is the core anti-downgrade guarantee. +TEST(Storage, BitcoinOnlyBandRefused) { + // storage_fromFlash always reads STORAGE_SECTOR_LEN from `flash` (in the + // firmware it points to a full flash sector), so the buffer must be a full + // sector or the version-17 read below runs off the end. + static char flash[STORAGE_SECTOR_LEN]; + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); // STORAGE_MAGIC_STR + uint32_t v = STORAGE_VERSION_BTC_ONLY; + flash[44] = (char)(v & 0xff); + flash[45] = (char)((v >> 8) & 0xff); + flash[46] = (char)((v >> 16) & 0xff); + flash[47] = (char)((v >> 24) & 0xff); + + SessionState session; + memset(&session, 0, sizeof(session)); + ConfigFlash shadow; + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A normal (below-band) version is still handled as before. + flash[44] = 17; + flash[45] = flash[46] = flash[47] = 0; + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + +#if BITCOIN_ONLY +// On bitcoin-only firmware, an in-band wallet stamped at an OLDER underlying +// version (which is exactly what an existing wallet looks like after a +// STORAGE_VERSION bump) must still load and migrate — never be refused, which +// would lock the user out of their own wallet. A NEWER in-band version is +// refused (downgrade guard), never wiped. +TEST(Storage, BitcoinOnlyBandMigrates) { + static char flash[STORAGE_SECTOR_LEN]; + SessionState session; + ConfigFlash shadow; + + // Older in-band version (underlying < STORAGE_VERSION): migrate, not refuse. + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); + uint32_t older = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION - 1); + memcpy(flash + 44, &older, + 4); // test host is little-endian, matches read_u32_le + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // Our own current in-band version: loads (not refused). + uint32_t current = STORAGE_VERSION_BTC_ONLY; + memcpy(flash + 44, ¤t, 4); + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A newer in-band version than this firmware understands: refuse. + uint32_t newer = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION + 1); + memcpy(flash + 44, &newer, 4); + memset(&session, 0, sizeof(session)); + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + TEST(Storage, StorageRoundTrip) { ConfigFlash start; memset(&start, 0xAB, sizeof(start)); @@ -530,7 +599,7 @@ TEST(Storage, StorageRoundTrip) { uint8_t wrapping_key[64]; storage_deriveWrappingKey("", wrapping_key, start.storage.pub.sca_hardened, - start.storage.pub.v15_16_trans, + PIN_KDF_V15, start.storage.pub.random_salt, ""); storage_unwrapStorageKey(wrapping_key, start.storage.pub.wrapped_storage_key, session.storageKey); @@ -556,6 +625,7 @@ TEST(Storage, StorageRoundTrip) { printf("\n"); #endif + // clang-format off const uint8_t expected_flash[] = { 0x73, 0x74, 0x6f, 0x72, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, @@ -653,7 +723,7 @@ TEST(Storage, StorageRoundTrip) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, + 0x00, 0x00, 0x00, 0x00, 0x00, STORAGE_VERSION, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, 0x50, 0xcb, 0x12, 0x84, 0xfa, 0x5f, 0xbf, 0xcb, 0x09, 0xca, 0x00, 0xf1, 0x37, 0xe4, 0x8f, 0x5e, 0xf9, 0x81, 0x57, 0x26, 0xb6, 0x7b, 0x8e, 0x03, 0x44, 0x9a, 0x2a, 0x7c, 0xf4, 0x3c, 0x79, 0x87, 0x5d, 0x26, 0xae, 0x9b, 0x4b, 0xb4, 0xd2, 0xc4, 0x67, 0x97, 0xe7, 0x6b, 0x6c, 0x4c, 0xbe, 0x68, @@ -719,6 +789,7 @@ TEST(Storage, StorageRoundTrip) { 0x7c, 0x20, 0x50, 0x7c, 0x85, 0xc1, 0x44, 0xaa, 0xfb, 0xf8, 0xeb, 0x20, 0x16, 0x8d, 0x72, 0x8c, 0xd2, 0xbe, 0xc2, 0xea, 0x44, 0xed, 0x7b, 0x94, 0x21, 0x00, }; + // clang-format on // If storage isn't correct, let's get an idea of where the failure is for (int i=0; ipresent = true; + a->key_id = 1; + memset(a->pubkey, 0x42, sizeof(a->pubkey)); + strcpy(a->alias, "CI Test"); + a->icon_w = 32; + a->icon_h = 32; + a->icon_len = 2; + a->icon[0] = 0x01; + a->icon[1] = 0xFF; + + std::vector flash(3480, 0); + storage_writeV18((char*)&flash[0], flash.size(), &start); + const size_t identity_block_off = 44 + 1501 + V17_ENCSEC_SIZE; + const size_t identity_block_len = + PERSISTENT_IDENTITY_COUNT * (71 + CLEARSIGN_ICON_MAX); + for (size_t i = 0; i < identity_block_len; i++) { + ASSERT_EQ(0, flash[identity_block_off + i]) << "byte " << i; + } + + // Simulate attacker-controlled legacy flash. Deserialization must scrub the + // full in-memory block rather than parse or expose any of it. + memset(&flash[identity_block_off], 0xA5, identity_block_len); + ConfigFlash end; + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + const uint8_t* retired = + reinterpret_cast(end.storage.pub.clearsign_identities); + for (size_t i = 0; i < sizeof(end.storage.pub.clearsign_identities); i++) { + ASSERT_EQ(0, retired[i]) << "byte " << i; + } + for (int k = 0; k < PERSISTENT_IDENTITY_COUNT; k++) { + const ClearsignIdentity* r = &end.storage.pub.clearsign_identities[k]; + ASSERT_FALSE(r->present) << "present " << k; + } +} + +TEST(Storage, PinKdfV2FlagIsVersionedInV19) { + ConfigFlash start; + memset(&start, 0, sizeof(start)); + memcpy(start.meta.magic, "stor", 4); + start.storage.version = STORAGE_VERSION; + start.storage.pub.pin_kdf_v2 = true; + + std::vector flash(3480, 0); + storage_writeV19((char*)&flash[0], flash.size(), &start); + + ConfigFlash end; + memset(&end, 0, sizeof(end)); + storage_readV19(&end, (const char*)&flash[0], flash.size()); + EXPECT_TRUE(end.storage.pub.pin_kdf_v2); + + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + EXPECT_FALSE(end.storage.pub.pin_kdf_v2); +} From 320f0eb5d731aba575ef086b1ec5e9688f3d4c23 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:58:56 -0300 Subject: [PATCH 2/4] feat(rng): auditable entropy source, on-device dice, and no entropy display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNG: - The RNG source selection is explicit and reportable, and a host can pull bulk samples for a health audit: a per-boot 64 KB budget replaces the press-per-kilobyte prompt that made auditing real hardware impossible. Scope is stated honestly in the code and docs — bulk output supports HEALTH testing (stuck/biased output, repeated buffers, transport caching, gross correlation), not a bound on the entropy of the generator's internal state. - On an uninitialized device, one press unlocks the bulk audit; on an initialized device the confirm still gates entropy after the budget is spent. - The emulator gets a real CSPRNG (lib/emulator/random.c, BCryptGenRandom on Windows) instead of libc random(), which CI now enforces. Dice: - ResetDevice can request on-device dice entropy: short press advances 1-6, long press confirms, undo is available, and the roll digest is confirmed before the seed is mixed. Pre-initialization only. - The abort path disarms EntropyAck, so an aborted reset can no longer leave the device accepting host-supplied entropy for the seed. - Evidence frames under docs/security/evidence/dice-entropy-reset, reproducible with scripts/emulator/capture-dice-flow.py. Internal entropy is no longer displayed or returned: it is seed pre-image material. display_random stays in the wire schema for host compatibility and is ignored. --- .../01-dice-screen-initial.png | Bin 0 -> 750 bytes .../02-after-three-rolls.png | Bin 0 -> 709 bytes .../dice-entropy-reset/03-after-undo.png | Bin 0 -> 692 bytes .../dice-entropy-reset/04-digest-confirm.png | Bin 0 -> 544 bytes .../05-postmix-internal-entropy.png | Bin 0 -> 719 bytes .../06-backup-explainer.png | Bin 0 -> 897 bytes .../evidence/dice-entropy-reset/README.md | 37 ++ include/keepkey/emulator/libkkemu.h | 78 +++- include/keepkey/firmware/dice_input.h | 49 +++ include/keepkey/firmware/reset.h | 19 +- lib/board/udp.c | 6 + lib/emulator/CMakeLists.txt | 4 +- lib/emulator/libkkemu.c | 337 +++++++++++++-- lib/emulator/random.c | 69 ++++ lib/emulator/setup.c | 43 +- lib/emulator/udp.c | 10 +- lib/firmware/CMakeLists.txt | 1 + lib/firmware/dice_input.c | 387 ++++++++++++++++++ lib/firmware/fsm_msg_common.h | 129 +++++- lib/firmware/reset.c | 136 ++++-- lib/rand/rng.c | 73 +++- scripts/emulator/capture-dice-flow.py | 101 +++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/dice.cpp | 64 +++ 24 files changed, 1395 insertions(+), 149 deletions(-) create mode 100644 docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png create mode 100644 docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png create mode 100644 docs/security/evidence/dice-entropy-reset/03-after-undo.png create mode 100644 docs/security/evidence/dice-entropy-reset/04-digest-confirm.png create mode 100644 docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png create mode 100644 docs/security/evidence/dice-entropy-reset/06-backup-explainer.png create mode 100644 docs/security/evidence/dice-entropy-reset/README.md create mode 100644 include/keepkey/firmware/dice_input.h create mode 100644 lib/emulator/random.c create mode 100644 lib/firmware/dice_input.c create mode 100644 scripts/emulator/capture-dice-flow.py create mode 100644 unittests/firmware/dice.cpp diff --git a/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png b/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..52dbe28bbe2bde530bdd74eb3da3c3839d1c6ceb GIT binary patch literal 750 zcmVVn|Z*C7jofYQ-(3{%>P-n&203f|*DL1l_;%t^f^LYS}-m_HZ zO2{RB<1MhZOmQLr1b_e#00O{a07h^zfJK4;pa2luRJY^{NPi1C07rP1PanBC;LQPn z0w6+cHyz9%c1SIuet`*Ry0ElYOA^oRX?6f?R!Utke*v&rDRnIw0$@9z2Y{Am zu&AAqWn}_*)u(TTk$_F*MU1k*; zc_J48OZzdPa)217YT024!=o1g+^y%o&H-YVswKmaH1s2YyLJ6fU~SS%XP;HW5P*>g z;7Ijz0Q#pRU?0GK1lUzh-WJH@`8la3*p#q826KQ~Wdak#Ct?%Tbmc9;2EqOq$pLFi zcXh5P0U!VbfB+Bx0zd!=00AHX1b_e#03He8GshPR1Ayw2MLwuA0CJ0TUtFdEQ1_iJK<|uzaR9H&0c1`@OYNWT gzw9k=vix@dKj#LjP%e$J&;S4c07*qoM6N<$f-;LVO8@`> literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png b/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png new file mode 100644 index 0000000000000000000000000000000000000000..cc8f5ca95241640a791c4f4d9e1907974d8fa990 GIT binary patch literal 709 zcmV;$0y_PPP)pY}}~$Q862HLV`bY!WGP&!)C+h>J^DT_^=gWjj({DowPq4&4Bl14a*u zjGS_h)L{gG-v=rKK$O}J0|5L!P#FNC)OMHwAS3{k%VYqY1G%RIFkVb?0s!MRx%2|i z91H-uD>%zjMI!*s!2qBvz*(LuS^?;hZIx;LV*%)qZIx;LqXEF&PF}uce2;>7Ewg9&0oYEL{aaKn-E&K<`9uEdca4i|4)^gabZHWseMX#!Ah^*R9g@1u(V ztnaY^PG><2;H*cH$x{KCZGXf6Gxe|QS742y8u+PPfg5SbeC#~BWYf4yM9DoCG0F9Ze zdNzQl>UIDbhpHS|9@9CU^byeO?y+8Jn%=W|2`B+4004z>=mlVPRsjGC;V=dO*?ipG z$;{tq?g2nHA2)Y0;fucoK*Asu$!8M;!w_IPy#NvhsYpJXAQ*-Kr_c>R1SMU2258>? z1OR|NaLCO2hd|7p06J#ZrU4Lz*|pmM?15M)4+=XqL@T-DP73@V-J_ZJ7?033h=Z~zX#0XP5$-~b$e19&HZJ<1-8?7%;lQyXqs++$i= z1^`Hs@+~ynE6BG`BJQJn8vwQ;0Q-KcT>T7ys5e+6i~)QCBJa3<41lr^hCux06L1~C r=jMQ@R{{JxCFaL3t^%FqzXSLK$mXh0b!;`W00000NkvXXu0mjf2kasK literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/03-after-undo.png b/docs/security/evidence/dice-entropy-reset/03-after-undo.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5a39d1e67decb506b650ae0a98ba68dc13e872 GIT binary patch literal 692 zcmV;l0!#ggP)m zCnS@BiWXup1Hk74nF4@iYcK)8=L4AnfMsiNEdXBmWdL~PuLiKSLku{j^8pmcR7Yb7 zfaah9#4;!=&5i-k95jGf24$t$K>+$>R{-RJaUuYHvMT`cz&I5E#CF!k%59F6`_EOW z#V7!X?W~27af>8H%7y`~m2nRofCF#<4!{A705FP+0W4BD00khZ#}37!1Si)N0QT^N zr>y~R4k#3Wq{Mn-U^=l~YE9S|7;vV0ZN)x-o_^AcL+7AH?Ogz5XQ8j3LjdZn9tWWE zez>D3>TV0583iqXmPL`tT>+TM+X$!oEyutXftv!T1aDAN$v@Si(9=_5z-a*L<);Ct zG;0E=8r%(lTHoe+7g`iev^)zyb@tN4jG|4dT`UJsIn-y%RsI^qB7cq0YHjDt%y3JYul51T6qFuLHCjiQxg-i{sIN@HH1|Z#0Xz+X#sVuS|q(qrNVW0FbDjc*F_CiI}Lb%f|t%l^H%b00-az z9DoCG01m(bH~g-j__2oqvs1teKy*of zJ-94@M$81*Yzh1|0~ow9U{eK*>(w4G6kwk{U{(yhJO%m-JZ%1G4@UFPL-zR>MNt$* zQAD_Z;1NA^< zv^eZr6+kzK!*P_ughARB=aU484jKx8=6y79rvR&w+q>!0OxtMx!tCB)a&u+hJ-P6SyBANU#b?-x%RT=_WVE56!yDbay()mY zTiy*``|a3|U(8i^&=&#B9T}@kTs!^2Ys}vZVDcbFk(hBI=(mKKLfY8%xoap8GeGQ^ z0pw?fmc0e&32jQbma1CSVI~KQSpqi9l!>G|t#b+>3l8avEcuoc#T5OMsu50j{5E4~QQz ixOx`aC!PYuNBjWx^;6{_ferZp0000Tf6)R{>u|u-go*-3WY+U@CK6m+pMUiIv^-|a#*(o zVjbN6pOT*RAW5gbL+O1U0q}gZMlsAiRGG!%49hYt;Ia#4KCjH4-yu&eesTg14Rz8@ zC-Kt=kOfY;1P)tckfRyBN7IA}h!wLsoz&6r+oU}jS~>$kCz6mvfZhg^2#A3XlXy0s zzGj^OUi^+_ry?%XUhvBV%=Un!_jv+P5(Ljd+@$psaENeB0Wg|^e|id}DPZ*ML*CaE zMO?2?D6Ed8%rw|t9x_cbR2(+|)e1J;Lnr%zGRAJ>|1JS2V_R4}#_r=k9nExDLk?gB z_*SFc1}~?8-!lTFz0w{qt7GBs2mN`+Q=sq>egH7}-*tI{@z($V002ovPDHLkV1ghN BN#Fng literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png b/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png new file mode 100644 index 0000000000000000000000000000000000000000..74ce8c14ce969c1a1bd07ca1df77ba6838140917 GIT binary patch literal 897 zcmV-{1AhF8P)Nkl|CN2;~2Nnw#8hVR+TY(Q0BRSyx*?I;9nqXklSt;VWRl)1Ev7=5I5={A~u9O9t9C z!$0-E_G^7?8daN&fXJcs!mL`KJbt_(ctg>*y01_ssszkm{lBoneji#(0w%1h`Y@qKusUSYhHAgv7vKBVgJd;7*PId6;{0 zJONkj0e1rjJz4_(DbNN20W4DomUlb_BB6oa`w2)dJ^~JS+M~%>1bm%bCXzAij%u=alX~6-T!mypPVM=M_jybWAK*mAb`a#9uK+5 zokL-+7eCM&XeMT)55$aOQ|a@s(t2OD!l*9|*ebR}hSlFQvCQ&A<`|pFoHN`JT`$CP z0<0XT`lVG*8mVnV<8cDWuo^Z9R;k`@BS5-D;3NSsy&8C11dws|90A5?G6~`Uia^|C zDrX3A9_}Q-9BJ>X2D1cg1i%Xq2@VMjA8oo$*&drOeT}9wn_E$Hjl}*!2HKo08;83H z_&ct-&n$1R#x=?HO3@b<78V|XK4bDI1OtAtsVD;i`a8 z{xzAN#c8?-y(V#&WUMQ>!VW4)s0cMDwz>5@0x+{KGkK{8Vy`h)r|ex0D@l`!&nG}8 z#HamQk>OaxW7peHw+Vns(j@v1MnFF6IAI9^t{2qD1i+@lsAUP5ml^9U!h&=a8P;EI z`k}Ws5Rus)(xMI5yoMi1tGX~_@UH~~)dz7jF$wybrJAl{=`$8N<0iT0JOYq&TjcE}AUI%- zJPd&m0VpFwkcmu8Y!Hw{0Ht1=ncXH}Joi0*#{`5`#IOc10@_O*YeOd>`$C5|GM@*D z!m5j(3&D9T4o!?k2cp>x0tzN00mLB!UWY^8U9kte3gbOLmFK?cn5dN&I40yQd>j7& XT5qzjlOj}X00000NkvXXu0mjfut}4q literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/README.md b/docs/security/evidence/dice-entropy-reset/README.md new file mode 100644 index 000000000..88dbed9f5 --- /dev/null +++ b/docs/security/evidence/dice-entropy-reset/README.md @@ -0,0 +1,37 @@ +# On-device dice entropy in the ResetDevice flow + +Emulator captures of `ResetDevice(dice_entropy=true, display_random=true, +strength=256)` driven by `scripts/emulator/capture-dice-flow.py` via +DebugLinkDecision.input injection. + +The screen runs with `display_constant_power(true)` (PIN-matrix precedent: +dice rolls are seed material, and OLED supply current correlates with lit +pixels). The display driver fills x<128 with the inverse of x>=128, which is +why the left half of every capture shows a readable inverse copy — the user +faces the right half. + +- `01-dice-screen-initial.png` — entry screen: `ROLL 1/99` counter, seven + selector cells (digits 1–6 + `<` undo), active cell rendered inverse-video + (white box, black glyph), `PRESS next HOLD ok` hint. Inactive digits are + legible on hardware (white on 0x22 gray) but collapse to solid white in the + 1bpp DebugLink threshold; the inverse half documents them. +- `02-after-three-rolls.png` — after injecting `123`: counter `ROLL 4/99`, + status `Entered 3 (3)`. +- `03-after-undo.png` — after injecting `u`: counter back to `ROLL 3/99`, + status `Removed #3`. +- `04-digest-confirm.png` — completion screen: `99 rolls recorded. Digest: + 6CFC611198F53A73` = the first 8 bytes of SHA-256 of the ASCII roll string, + independently recomputed host-side from the injected chunks (append/undo + rules simulated) and matching exactly. +- `05-postmix-internal-entropy.png` — the standard Internal Entropy screen + now shows the POST-dice-mix value: the displayed commitment is + `SHA256(rng32 || rolls)`, produced before EntropyRequest is sent, so + `sha256(displayed || external)` still reproduces the mnemonic (asserted by + `test_msg_resetdevice.py::test_reset_device_dice`). +- `06-backup-explainer.png` — flow continues into the unchanged backup path. + +Emulator captures do not satisfy Gate-3 on their own: an on-device pass of +the entry screen (short-press advance, 800 ms hold commit, undo, digest +match against physically entered rolls) is still owed before release. The +hardware press/release/debounce path (`dice_on_press`/`dice_on_release`) +does not execute in the emulator at all. diff --git a/include/keepkey/emulator/libkkemu.h b/include/keepkey/emulator/libkkemu.h index ec75ff957..6e5b8f336 100644 --- a/include/keepkey/emulator/libkkemu.h +++ b/include/keepkey/emulator/libkkemu.h @@ -3,7 +3,16 @@ * * The host process provides a pre-allocated 1MB flash buffer. * All I/O goes through ring buffers (no UDP sockets). - * Single-threaded: call kkemu_poll() from your event loop. + * + * Two drive modes: + * - Host-driven (default): call kkemu_poll() from your event loop. Purely + * single-threaded — used by the FFI/python test harnesses. + * - Thread-driven: call kkemu_start() once after kkemu_init() and let a + * dedicated dylib thread own the event loop. Required for screen-first + * confirm gating (confirm_helper can block in C without freezing the host + * event loop). The host then never calls kkemu_poll(); it interacts only + * through the lock-free rings (kkemu_write/read/pop_frame) and brackets + * flash snapshots with kkemu_lock()/kkemu_unlock(). */ #ifndef LIBKKEMU_H #define LIBKKEMU_H @@ -77,20 +86,16 @@ int kkemu_read(uint8_t* buf, size_t len, int iface); int kkemu_poll(void); /** - * Get the OLED framebuffer (256x64, 1-bit per pixel = 2048 bytes). + * Snapshot the current OLED framebuffer (256x64, 1-bit, 2048 bytes) into + * internal scratch and return a pointer to it (valid until the next call). * - * This returns a pointer to internal scratch storage containing a snapshot - * of the current display in packed SSD1306 page format. + * WARNING: host-driven mode ONLY. Reads the live canvas with no synchronization + * against the poll thread — do NOT call it once kkemu_start() is running. In + * thread-driven mode use kkemu_pop_frame() (the lock-free SPSC ring) instead. + * Returns NULL if the emulator is not initialized. * * @param width Receives 256. * @param height Receives 64. - * @return Pointer to framebuffer data. The pointer remains valid only until - * the next call to kkemu_get_display(), which overwrites the same - * scratch buffer. Calling kkemu_poll() may update the emulator's - * display state, but it does not refresh previously returned data - * in place; call kkemu_get_display() again after kkemu_poll() to - * obtain an updated framebuffer snapshot. Returns NULL if emulator - * is not initialized. */ const uint8_t* kkemu_get_display(int* width, int* height); @@ -98,14 +103,15 @@ const uint8_t* kkemu_get_display(int* width, int* height); * Pop the next captured framebuffer from the display capture ring. * * Every display_refresh() inside the firmware (including those that fire - * inside confirm_helper's busy loop within a single kkemu_poll() call) - * snapshots the canvas into a ring buffer. Adjacent identical frames - * are deduplicated. This lets the host see intermediate screen states - * (confirm dialogs, cipher prompts, recovery screens) that would - * otherwise be invisible — they exist only inside synchronous C calls. + * inside confirm_helper's busy loop) snapshots the canvas into a lock-free + * SPSC ring. Adjacent identical frames are deduplicated. This is the canonical + * way to observe intermediate screen states (confirm dialogs, cipher prompts, + * recovery screens) — and the only display path that is safe to call while the + * poll thread runs. * * @param out_packed Buffer of at least 2048 bytes (256x64, 1-bit packed - * SSD1306 page format — same as kkemu_get_display). + * SSD1306 page format: byte index = x + (y/8)*256, + * bit within byte = y%8). * @return 1 if a frame was popped, 0 if the ring is empty. */ int kkemu_pop_frame(uint8_t* out_packed); @@ -115,6 +121,44 @@ int kkemu_pop_frame(uint8_t* out_packed); */ int kkemu_is_running(void); +/** + * Start the dedicated poll thread (thread-driven mode). + * + * After this returns 0, a dylib-internal thread owns the firmware event loop + * and the host MUST NOT call kkemu_poll() anymore. Idempotent. Requires + * kkemu_init() to have succeeded. + * + * @return 0 on success (or already started), -1 on error. + */ +int kkemu_start(void); + +/** + * Stop + join the poll thread. Injects a Cancel first so a confirm_helper + * parked waiting for a button decision unblocks and the thread can exit. + * Idempotent; a no-op if the thread was never started. kkemu_shutdown() + * calls this automatically. + */ +void kkemu_stop(void); + +/** + * Bracket a host-side read of the flash buffer (e.g. before encrypting and + * persisting it) so it can't tear a concurrent storage_commit() on the poll + * thread. No-op in host-driven mode. Must be balanced with kkemu_unlock(). + * + * kkemu_lock() BLOCKS and must not be used from a host loop that also has to + * stay alive to deliver a confirm decision — use kkemu_trylock() there. + */ +void kkemu_lock(void); +void kkemu_unlock(void); + +/** + * Non-blocking acquire of the firmware lock. Returns 1 if acquired (balance + * with kkemu_unlock()), 0 if currently held by the poll thread (e.g. during a + * pending confirm) — yield the host event loop and retry. Returns 1 as a no-op + * when the poll thread isn't running. + */ +int kkemu_trylock(void); + #ifdef __cplusplus } #endif diff --git a/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h new file mode 100644 index 000000000..3ef80c7aa --- /dev/null +++ b/include/keepkey/firmware/dice_input.h @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +#ifndef KEEPKEY_FIRMWARE_DICE_INPUT_H +#define KEEPKEY_FIRMWARE_DICE_INPUT_H + +#include +#include + +/* d6 carries log2(6) = 2.585 bits per roll; targets follow the Coldcard + * convention of 50 rolls per 128-bit seed and 99 per 256-bit. */ +#define DICE_MAX_ROLLS 99 + +/// Number of rolls required for a given seed strength (128/192/256). +uint32_t dice_rolls_for_strength(uint32_t strength_bits); + +/// Collect `target` dice rolls on the device with the single button: +/// short press advances the 1-6/UNDO selector, holding the button commits +/// the selection. Announces itself with ButtonRequest_DiceRoll and accepts +/// input only after the host's ButtonAck. Under DEBUG_LINK, characters +/// '1'-'6' and 'u' (undo) arriving in DebugLinkDecision.input are treated +/// as committed selections. +/// +/// Fills `rolls` with `target` ASCII digits '1'-'6' (no terminator is +/// appended past target; the caller owns zeroization). Returns false if the +/// host cancelled (Cancel/Initialize). +bool dice_input_collect(char *rolls, uint32_t target); + +/// entropy = SHA256(entropy[32] || rolls[count]); the caller displays or +/// commits only the post-mix value. +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count); + +#endif diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index a41cb101d..87af323fa 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -33,12 +33,23 @@ MAX_WORDS*(MAX_WORD_LEN + ADDITIONAL_WORD_PAD) + 1 #define MNEMONIC_BY_SCREEN_BUF WORDS_PER_SCREEN*(MAX_WORD_LEN + 1) + 1 -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter); +/* Paginated-mnemonic display scratch, shared between the backup flow here and + * the BIP-85 display flow (fsm_msg_bip85.h) — one ~2.8 KB set instead of two. + * Both flows are modal and single-threaded: each formats and displays inside + * its own handler call. Every user MUST memzero the set at entry AND on every + * exit path. Defined in reset.c (.confidential). */ +extern char mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +extern char mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy); void reset_entropy(const uint8_t* ext_entropy, uint32_t len); uint32_t reset_get_int_entropy(uint8_t* entropy); const char* reset_get_word(void); +uint32_t reset_get_dice_digest(uint8_t* digest); #endif diff --git a/lib/board/udp.c b/lib/board/udp.c index ba35d15ec..1240c2155 100644 --- a/lib/board/udp.c +++ b/lib/board/udp.c @@ -21,6 +21,7 @@ #include "keepkey/board/usb.h" #include "keepkey/board/timer.h" +#include "keepkey/board/layout.h" #include "keepkey/emulator/emulator.h" #include @@ -62,6 +63,11 @@ void usbPoll(void) { // msg_read_tiny(msg.message, sizeof(msg.message)); } } + + // Keep a queued progress animation moving while we block on host I/O (e.g. + // Zcash proof generation on the host), matching device usbPoll(). No-op + // unless a trickle animation is active. + layout_animate_poll(); } bool usb_tx(const uint8_t* msg, uint32_t len) { diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index 68cef0295..7d7eef5f8 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -3,7 +3,8 @@ if(${KK_EMULATOR}) set(sources oled.c udp.c - setup.c) + setup.c + random.c) @@ -19,6 +20,7 @@ if(${KK_EMULATOR}) oled.c udp.c setup.c + random.c ringbuf.c libkkemu.c) diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index be45bf608..e09d14ad6 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -24,11 +24,58 @@ #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include +#else #include +#include +#include +#endif /* Defined in firmware — we just need the declaration */ extern void fsm_init(void); +/* ── Poll thread (Approach B: reactive confirm) ────────────────────────── + * + * Optional: the host calls kkemu_start() to run the firmware event loop on a + * dedicated thread inside the dylib. This lets confirm_helper's blocking C + * busy-loop wait for a button decision IN C without freezing the host's event + * loop — so the vault can render the real OLED confirm frame, HOLD it, and + * deliver the DebugLinkDecision only when the user clicks (screen-first gating, + * like a physical device). + * + * Only the poll thread ever drives firmware execution (kkemu_poll_body). The + * host interacts solely through the lock-free SPSC rings (kkemu_write/read, + * kkemu_pop_frame). g_fw_lock serializes the poll body against host-side flash + * snapshots (kkemu_lock/unlock) so storage_commit can't tear a saveFlash read. + * + * When the thread is NOT started (g_poll_running == 0) the dylib stays purely + * single-threaded and host-driven via kkemu_poll() — exactly as the FFI test + * suite and python-keepkey tests use it. The lock helpers no-op in that mode. + */ +#ifdef _WIN32 +static CRITICAL_SECTION g_fw_lock; +static HANDLE g_poll_thread = NULL; +#define FW_LOCK() EnterCriticalSection(&g_fw_lock) +#define FW_UNLOCK() LeaveCriticalSection(&g_fw_lock) +#else +static pthread_mutex_t g_fw_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t g_poll_thread; +#define FW_LOCK() pthread_mutex_lock(&g_fw_lock) +#define FW_UNLOCK() pthread_mutex_unlock(&g_fw_lock) +#endif + +/* Cross-thread poll-running flag. _Atomic (not volatile — volatile is not a + * synchronization primitive in C): the poll thread reads it each loop while + * start/stop write it from the host thread. acquire/release publishes the + * surrounding firmware/ring state alongside the flag. */ +#include +static _Atomic int g_poll_running = 0; +#define POLL_RUNNING() atomic_load_explicit(&g_poll_running, memory_order_acquire) +#define POLL_SET(v) atomic_store_explicit(&g_poll_running, (v), memory_order_release) + /* ── Ring buffers (replace UDP sockets) ─────────────────────────────── */ static RingBuf rb_main_in; /* host → firmware (main interface) */ @@ -45,18 +92,27 @@ static int libkkemu_initialized = 0; * The host drains via kkemu_pop_frame(). Adjacent identical frames are * skipped so an idle firmware doesn't spam the ring. * - * Sized for ~4 seconds at 16ms refresh; if the host falls behind the - * oldest frames are dropped (write advances past read). + * Sized for ~4 seconds at 16ms refresh. Cross-thread in thread-driven mode: + * the poll thread is the sole producer, the host (kkemu_pop_frame) the sole + * consumer — a lock-free SPSC ring with the same atomic discipline as the HID + * rings (ringbuf.c). When the ring is full the producer drops the NEW frame + * (it must NOT overwrite a slot the consumer may be mid-copy on, and it must + * NOT write the consumer-owned read index). */ #define FRAME_PACKED_SIZE 2048 #define FRAME_RING_SIZE 64 +/* Host poll cadence (the vault's setInterval is ~16ms). kkemu_poll() ticks the + * firmware ms-timer this many times per call so animations advance at ~real + * speed without relying on the (host-runtime-unreliable) SIGALRM timer. */ +#define KKEMU_POLL_INTERVAL_MS 16 + static uint8_t frame_ring[FRAME_RING_SIZE][FRAME_PACKED_SIZE]; -static uint8_t last_packed[FRAME_PACKED_SIZE]; -static int last_packed_valid = 0; -static uint32_t frame_write_idx = - 0; /* monotonic, mod FRAME_RING_SIZE for slot */ -static uint32_t frame_read_idx = 0; /* monotonic */ +static uint8_t last_packed[FRAME_PACKED_SIZE]; /* producer-only (poll thread) */ +static int last_packed_valid = 0; /* producer-only */ +static uint8_t capture_scratch[FRAME_PACKED_SIZE]; /* producer-only pack buffer */ +static _Atomic uint32_t frame_write_idx = 0; /* written by producer ONLY */ +static _Atomic uint32_t frame_read_idx = 0; /* written by consumer ONLY */ /* * Scratch returned by kkemu_get_display(). File-scope (not function-static) @@ -107,28 +163,38 @@ size_t libkkemu_socketWrite(int iface, const void* buffer, size_t size) { static void libkkemu_capture_frame(const uint8_t* canvas_buf) { if (!canvas_buf) return; - uint8_t* slot = frame_ring[frame_write_idx % FRAME_RING_SIZE]; - memset(slot, 0, FRAME_PACKED_SIZE); + /* Pack into a producer-private scratch — NOT a ring slot. When the ring is + * full the next write slot still holds an unread frame the consumer may be + * copying, so we must decide to publish/drop before touching it. */ + memset(capture_scratch, 0, FRAME_PACKED_SIZE); for (int x = 0; x < 256; x++) { for (int y = 0; y < 64; y++) { if (canvas_buf[y * 256 + x] > 0) { - slot[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + capture_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); } } } - /* Dedup: skip if identical to last captured */ - if (last_packed_valid && memcmp(slot, last_packed, FRAME_PACKED_SIZE) == 0) { + /* Dedup against the last captured frame (producer-only state). */ + if (last_packed_valid && + memcmp(capture_scratch, last_packed, FRAME_PACKED_SIZE) == 0) { return; } - memcpy(last_packed, slot, FRAME_PACKED_SIZE); - last_packed_valid = 1; - frame_write_idx++; - /* Drop oldest if host fell behind */ - if (frame_write_idx - frame_read_idx > FRAME_RING_SIZE) { - frame_read_idx = frame_write_idx - FRAME_RING_SIZE; - } + /* SPSC publish, drop-on-full (same discipline as ringbuf.c). The producer + * writes only frame_write_idx; the consumer writes only frame_read_idx. When + * not full, write%SIZE != read%SIZE (their distance is in [1, SIZE-1]), so + * producer and consumer never touch the same slot. last_packed is updated + * ONLY on a real publish, so a frame dropped while full can still be captured + * on a later tick. */ + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_relaxed); + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_acquire); + if (w - r >= FRAME_RING_SIZE) return; /* full → drop the new frame */ + + memcpy(frame_ring[w % FRAME_RING_SIZE], capture_scratch, FRAME_PACKED_SIZE); + memcpy(last_packed, capture_scratch, FRAME_PACKED_SIZE); + last_packed_valid = 1; + atomic_store_explicit(&frame_write_idx, w + 1, memory_order_release); } /* ── Public API ─────────────────────────────────────────────────────── */ @@ -151,12 +217,21 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { * Production hosts of libkkemu should treat a logged failure as a * security warning and refuse to load secrets. */ +#ifdef _WIN32 + if (!VirtualLock(flash_buf, flash_len)) { + fprintf(stderr, + "[libkkemu] VirtualLock(%zu bytes) failed (err %lu) — flash buffer " + "may be paged to disk; do not load production secrets\n", + flash_len, (unsigned long)GetLastError()); + } +#else if (mlock(flash_buf, flash_len) != 0) { fprintf(stderr, "[libkkemu] mlock(%zu bytes) failed: %s — flash buffer may be " "swapped to disk; do not load production secrets\n", flash_len, strerror(errno)); } +#endif /* Initialize ring buffers (replaces UDP socket init) */ libkkemu_socketInit(); @@ -193,6 +268,11 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { void kkemu_shutdown(void) { if (!libkkemu_initialized) return; + /* Stop + join the poll thread FIRST so nothing drives firmware execution + * while we commit storage and zero the rings below (idempotent if the host + * never started the thread). */ + kkemu_stop(); + /* Flush any pending storage to the flash buffer */ storage_commit(); @@ -220,6 +300,7 @@ void kkemu_shutdown(void) { memzero(&rb_debug_out, sizeof(rb_debug_out)); memzero(frame_ring, sizeof(frame_ring)); memzero(last_packed, sizeof(last_packed)); + memzero(capture_scratch, sizeof(capture_scratch)); memzero(display_packed_scratch, sizeof(display_packed_scratch)); last_packed_valid = 0; frame_write_idx = 0; @@ -231,7 +312,11 @@ void kkemu_shutdown(void) { * want to inspect / persist post-mortem state. Documented contract. */ if (emulator_flash_base) { +#ifdef _WIN32 + VirtualUnlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#else munlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#endif emulator_flash_base = NULL; } @@ -254,35 +339,198 @@ int kkemu_read(uint8_t* buf, size_t len, int iface) { return ringbuf_pop(rb, buf, KKEMU_PACKET_SIZE) ? KKEMU_PACKET_SIZE : 0; } -int kkemu_poll(void) { - if (!libkkemu_initialized) return -1; +/* + * One iteration of the firmware event loop. Same as exec() in main.cpp: + * usbPoll() — reads input, dispatches through FSM + * animate() — updates screen animations + * display_refresh() — renders framebuffer + * + * usbPoll() internally calls emulatorSocketRead() which we've replaced with + * libkkemu_socketRead() via the ring buffers. + * + * Drive the firmware millisecond timer from the poll on EVERY platform. The + * dylib is caller-driven; relying on the SIGALRM/ualarm timer (which the + * standalone kkemu binary uses) is unreliable inside the host runtime — Bun + * does not deliver the firmware's SIGALRM, so animate_flag never flips and + * every animation (boot logo, screensaver) stays frozen → a blank OLED at + * rest. Tick ~one poll-interval of milliseconds so the periodic animation + * runnable fires and animations + delay_ms() advance at roughly real speed. + */ +static void kkemu_poll_body(void) { + for (int t = 0; t < KKEMU_POLL_INTERVAL_MS; t++) timerisr_usr(); - /* - * This is the same as exec() in main.cpp: - * usbPoll() — reads input, dispatches through FSM - * animate() — updates screen animations - * display_refresh() — renders framebuffer - * - * usbPoll() internally calls emulatorSocketRead() which we've - * replaced with libkkemu_socketRead() via the ring buffers. - */ usbPoll(); animate(); display_refresh(); +} +int kkemu_poll(void) { + if (!libkkemu_initialized) return -1; + /* When the poll thread owns execution, the host must not also poll — + * that would be two threads driving the single-threaded firmware core. + * Treat a stray host poll as a no-op rather than a data race. */ + if (POLL_RUNNING()) return 0; + kkemu_poll_body(); return 0; } +static void kkemu_sleep_ms(int ms) { +#ifdef _WIN32 + Sleep((DWORD)ms); +#else + struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +#endif +} + +/* The poll thread holds g_fw_lock across each body call, releasing it during + * the inter-poll sleep. While confirm_helper busy-waits for a decision the body + * does not return, so the lock stays held for the whole confirm — but that must + * NOT block the host: the decision is delivered through the lock-free rings, and + * the host acquires the lock for flash snapshots via kkemu_trylock() (which + * never blocks the host event loop). The host must never take g_fw_lock with a + * blocking call while a confirm may be pending, or it would deadlock against the + * very loop that needs the host alive to deliver the decision. */ +static void kkemu_poll_loop(void) { + while (POLL_RUNNING()) { + FW_LOCK(); + if (POLL_RUNNING()) kkemu_poll_body(); + FW_UNLOCK(); + kkemu_sleep_ms(KKEMU_POLL_INTERVAL_MS); + } +} + +#ifdef _WIN32 +static DWORD WINAPI kkemu_poll_thread_fn(LPVOID arg) { + (void)arg; + kkemu_poll_loop(); + return 0; +} +#else +static void* kkemu_poll_thread_fn(void* arg) { + (void)arg; + kkemu_poll_loop(); + return NULL; +} +#endif + +/* Push a Cancel (MessageType 20) into the main input ring so a confirm_helper + * blocked on the poll thread reads it, returns false, and lets the thread exit + * its loop — otherwise kkemu_stop() would join a thread parked forever waiting + * for a button decision that will never arrive. + * + * This injected Cancel is the ONLY firmware-side wakeup for a parked confirm + * (confirm_helper has no idle timeout in EMULATOR builds), and kkemu_stop() + * then joins the thread with no deadline — so a SILENTLY dropped Cancel would + * freeze the (single-threaded) host forever, beyond any watchdog's reach. The + * push can only fail if rb_main_in is full; the parked confirm drains one input + * frame per spin, so a slot frees within ~a poll tick. Retry briefly, and shout + * loudly if it somehow never takes rather than dropping it. */ +static void kkemu_inject_cancel(void) { + uint8_t frame[KKEMU_PACKET_SIZE]; + memset(frame, 0, sizeof(frame)); + frame[0] = 0x3F; /* '?' HID report marker */ + frame[1] = 0x23; /* '#' */ + frame[2] = 0x23; /* '#' */ + frame[3] = 0x00; /* MessageType_Cancel high */ + frame[4] = 0x14; /* MessageType_Cancel low (20) */ + /* payload length 0 (bytes 5-8 already zero) */ + for (int i = 0; i < 200; i++) { + if (ringbuf_push(&rb_main_in, frame, sizeof(frame))) return; + kkemu_sleep_ms(1); + } + fprintf(stderr, + "[libkkemu] FATAL: could not inject Cancel to wake a parked confirm " + "before join — rb_main_in stayed full for ~200ms; the poll thread may " + "not exit\n"); +} + +int kkemu_start(void) { + if (!libkkemu_initialized) return -1; + if (POLL_RUNNING()) return 0; /* idempotent */ + +#ifdef _WIN32 + InitializeCriticalSection(&g_fw_lock); + POLL_SET(1); + g_poll_thread = CreateThread(NULL, 0, kkemu_poll_thread_fn, NULL, 0, NULL); + if (!g_poll_thread) { + POLL_SET(0); + DeleteCriticalSection(&g_fw_lock); + return -1; + } +#else + POLL_SET(1); + if (pthread_create(&g_poll_thread, NULL, kkemu_poll_thread_fn, NULL) != 0) { + POLL_SET(0); + return -1; + } +#endif + return 0; +} + +void kkemu_stop(void) { + if (!POLL_RUNNING()) return; + + POLL_SET(0); + /* Unblock any confirm_helper currently parked on the thread, then join. */ + kkemu_inject_cancel(); +#ifdef _WIN32 + if (g_poll_thread) { + WaitForSingleObject(g_poll_thread, INFINITE); + CloseHandle(g_poll_thread); + g_poll_thread = NULL; + } + DeleteCriticalSection(&g_fw_lock); +#else + pthread_join(g_poll_thread, NULL); +#endif +} + +/* Host-side guard for reading the shared flash buffer (saveFlash) without + * tearing a concurrent storage_commit on the poll thread. No-op when the + * thread isn't running (single-threaded test path needs no lock, and on + * Windows the CRITICAL_SECTION only exists between start and stop). + * + * WARNING: kkemu_lock() BLOCKS, and the poll thread can hold g_fw_lock for the + * whole duration of a pending confirm. The host must therefore NOT call + * kkemu_lock() from a thread/loop that also has to stay alive to deliver the + * confirm decision (it would deadlock). Use kkemu_trylock() + an event-loop + * yield there instead. kkemu_lock() is retained for paths with no pending + * confirm. */ +void kkemu_lock(void) { + if (POLL_RUNNING()) FW_LOCK(); +} + +void kkemu_unlock(void) { + if (POLL_RUNNING()) FW_UNLOCK(); +} + +/* Non-blocking acquire. Returns 1 if the firmware lock is now held by the + * caller (balance with kkemu_unlock()), 0 if it is currently held by the poll + * thread (e.g. mid-confirm) — the caller should yield its event loop and retry, + * which keeps the loop alive to deliver the decision that releases the lock. + * No-op success (returns 1, nothing to unlock) when the thread isn't running. */ +int kkemu_trylock(void) { + if (!POLL_RUNNING()) return 1; +#ifdef _WIN32 + return TryEnterCriticalSection(&g_fw_lock) ? 1 : 0; +#else + return pthread_mutex_trylock(&g_fw_lock) == 0 ? 1 : 0; +#endif +} + +/* + * Snapshot the current OLED canvas into packed SSD1306 format (byte index = + * x + (y/8)*256, bit = y%8). Host-driven convenience used by the python + * screenshot harness, which drives the firmware single-threaded via kkemu_poll. + * + * WARNING: NOT thread-safe. It reads the live firmware canvas directly with no + * synchronization against the poll thread, so it is only safe in HOST-DRIVEN + * mode (no kkemu_start). In thread-driven mode the canonical, race-free way to + * observe the display is the SPSC capture ring via kkemu_pop_frame(); do not + * wire kkemu_get_display into a threaded host. + */ const uint8_t* kkemu_get_display(int* width, int* height) { - /* - * Pack the firmware's 8-bpp grayscale canvas (256×64 = 16384 bytes) into - * the 1-bit packed layout vault expects (2048 bytes). Same format - * DebugLinkGetState.layout uses: byte index = x + (y/8)*256, - * bit within byte = y%8 (LSB = top row of the 8-pixel column). - * - * Output goes into the file-scope `display_packed_scratch` so - * kkemu_shutdown() can zero it on teardown alongside the frame ring. - */ if (!libkkemu_initialized) { if (width) *width = 0; if (height) *height = 0; @@ -312,10 +560,13 @@ const uint8_t* kkemu_get_display(int* width, int* height) { int kkemu_pop_frame(uint8_t* out_packed) { if (!libkkemu_initialized || !out_packed) return 0; - if (frame_read_idx == frame_write_idx) return 0; - const uint8_t* slot = frame_ring[frame_read_idx % FRAME_RING_SIZE]; - memcpy(out_packed, slot, FRAME_PACKED_SIZE); - frame_read_idx++; + /* SPSC consume: read frame_read_idx (we own it) and frame_write_idx (acquire, + * to see the producer's slot write). Empty when the indices are equal. */ + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_relaxed); + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_acquire); + if (r == w) return 0; + memcpy(out_packed, frame_ring[r % FRAME_RING_SIZE], FRAME_PACKED_SIZE); + atomic_store_explicit(&frame_read_idx, r + 1, memory_order_release); return 1; } diff --git a/lib/emulator/random.c b/lib/emulator/random.c new file mode 100644 index 000000000..f05fdb1fd --- /dev/null +++ b/lib/emulator/random.c @@ -0,0 +1,69 @@ +/* + * This file is part of the TREZOR project, https://trezor.io/ + * + * Copyright (C) 2017 Saleem Rashid + * + * 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. + */ + +#include "keepkey/emulator/emulator.h" +#include "keepkey/emulator/setup.h" + +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include + +static int urandom = -1; + +static void setup_urandom(void) { + if (urandom >= 0) return; + + urandom = open("/dev/urandom", O_RDONLY); + if (urandom < 0) { + perror("Failed to open /dev/urandom"); + exit(1); + } +} +#endif + +void setup_urandom_only(void) { +#ifndef _WIN32 + setup_urandom(); +#endif +} + +void emulatorRandom(void* buffer, size_t size) { +#ifdef _WIN32 + /* Windows has no /dev/urandom — use the system CSPRNG. */ + if (BCryptGenRandom(NULL, (PUCHAR)buffer, (ULONG)size, + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + fprintf(stderr, "BCryptGenRandom failed\n"); + exit(1); + } +#else + setup_urandom(); + unsigned char* out = (unsigned char*)buffer; + size_t remaining = size; + while (remaining > 0) { + ssize_t n = read(urandom, out, remaining); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { + perror("Failed to read /dev/urandom"); + exit(1); + } + out += (size_t)n; + remaining -= (size_t)n; + } +#endif +} diff --git a/lib/emulator/setup.c b/lib/emulator/setup.c index 1b4d657d4..58ad9933a 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -19,49 +19,36 @@ #include "keepkey/board/memory.h" #include "keepkey/board/timer.h" -#include "keepkey/rand/rng.h" +#include "keepkey/emulator/setup.h" -#include -#include #include #include #include +#ifndef _WIN32 +#include #include #include +#endif #define EMULATOR_FLASH_FILE "emulator.img" -uint32_t __stack_chk_guard; - -static int urandom = -1; +/* __stack_chk_guard is defined once in lib/board/keepkey_board.c (as + * uintptr_t). It used to be redefined here as uint32_t, which is (a) the wrong + * size on 64-bit hosts and (b) a duplicate strong symbol. Apple's ld silently + * merged the two; GNU/MinGW ld rejects it ("multiple definition"), which + * blocked the Linux .so and Windows .dll builds. Removed — the board copy is + * canonical. */ -static void setup_urandom(void); +#ifndef _WIN32 static void setup_flash(void); void setup(void) { - setup_urandom(); + setup_urandom_only(); setup_flash(); } +#endif -/* For libkkemu: init RNG only (flash buffer provided by host) */ -void setup_urandom_only(void) { setup_urandom(); } - -void emulatorRandom(void* buffer, size_t size) { - ssize_t n = read(urandom, buffer, size); - if (n < 0 || ((size_t)n) != size) { - perror("Failed to read /dev/urandom"); - exit(1); - } -} - -static void setup_urandom(void) { - urandom = open("/dev/urandom", O_RDONLY); - if (urandom < 0) { - perror("Failed to open /dev/urandom"); - exit(1); - } -} - +#ifndef _WIN32 static void setup_flash(void) { int fd = open(EMULATOR_FLASH_FILE, O_RDWR | O_SYNC | O_CREAT, 0644); if (fd < 0) { @@ -92,3 +79,5 @@ static void setup_flash(void) { memset(emulator_flash_base, 0xff, FLASH_TOTAL_SIZE); } } +#endif /* !_WIN32 — setup_flash is standalone-UDP only; the dylib/DLL host \ + owns flash */ diff --git a/lib/emulator/udp.c b/lib/emulator/udp.c index 671c6437b..265886c63 100644 --- a/lib/emulator/udp.c +++ b/lib/emulator/udp.c @@ -17,17 +17,22 @@ * along with this library. If not, see . */ -#include #include #include #include #include -#include #ifndef KEEPKEY_UDP_PORT #define KEEPKEY_UDP_PORT 11044 #endif +#ifndef KKEMU_DYLIB +/* Sockets are only used by the standalone UDP binary. In dylib/DLL mode all + * I/O goes through ring buffers (below), so skip the BSD socket headers and + * helpers entirely — they don't exist on MinGW/Windows. */ +#include +#include + struct usb_socket { int fd; struct sockaddr_in from; @@ -95,6 +100,7 @@ static size_t socket_read(struct usb_socket* sock, void* buffer, size_t size) { return n; } +#endif /* !KKEMU_DYLIB — socket helpers are standalone-UDP only */ #ifdef KKEMU_DYLIB /* diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index 7416b9524..da893616f 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -20,6 +20,7 @@ set(sources ethereum_contracts/zxtransERC20.c ethereum_contracts/zxswap.c ethereum_tokens.c + dice_input.c fsm.c home_sm.c mayachain.c diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c new file mode 100644 index 000000000..996ef540b --- /dev/null +++ b/lib/firmware/dice_input.c @@ -0,0 +1,387 @@ +/* + * 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 . + */ + +#include "keepkey/firmware/dice_input.h" + +#include "keepkey/board/draw.h" +#include "keepkey/board/font.h" +#include "keepkey/board/keepkey_button.h" +#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/layout.h" +#include "keepkey/board/messages.h" +#include "keepkey/board/supervise.h" +#include "keepkey/board/timer.h" +#include "keepkey/transport/interface.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +/* Selector positions 0-5 are digits '1'-'6'; 6 is UNDO. */ +#define DICE_POSITIONS 7 +#define DICE_UNDO_POS 6 + +/* Holding this long commits the selection; edges closer together than the + * debounce window are contact bounce. Distinct from CONFIRM_TIMEOUT_MS on + * purpose: a 1200ms hold per roll makes 99 rolls a slog. */ +#define DICE_HOLD_MS 800 +#define DICE_DEBOUNCE_MS 30 + +/* The screen runs with display_constant_power(true): the display driver + * fills x<128 with the INVERSE of x>=128 at refresh time so total lit + * pixels stay constant (OLED power side-channel defense — same reason the + * PIN matrix lives on the right half). All drawing must stay in x>=128. */ +#define DICE_LEFT 130 +#define DICE_CELL_SIZE 15 +#define DICE_CELL_GAP 2 +#define DICE_GRID_Y 14 +#define DICE_STATUS_Y 33 +#define DICE_BAR_X DICE_LEFT +#define DICE_BAR_Y 48 +#define DICE_BAR_W (7 * DICE_CELL_SIZE + 6 * DICE_CELL_GAP) +#define DICE_BAR_H 6 + +extern bool reset_msg_stack; + +/* Button state shared with the ISR. Every classification decision (short vs + * hold) is made exactly once per press cycle and guarded by dice_committed, + * so a press can never produce both an advance and a commit. The UI loop + * reads and drains these under masked interrupts. */ +static volatile bool dice_accept; /* host has ButtonAck'd the screen */ +static volatile bool dice_pressed; +static volatile bool dice_committed; /* this press cycle already classified */ +static volatile uint32_t dice_press_start; +static volatile uint32_t dice_release_time; +static volatile bool dice_have_release; +static volatile uint8_t dice_short_events; +static volatile uint8_t dice_hold_events; + +#ifndef EMULATOR +static void dice_on_press(void *context) { + (void)context; + uint32_t now = getSysTime(); + /* Mirror confirm_sm: input is dead until the host acks the request, so a + * press begun before the ack cannot accrue hold time toward a commit. */ + if (!dice_accept || dice_pressed) { + return; + } + dice_pressed = true; + if (dice_have_release && now - dice_release_time < DICE_DEBOUNCE_MS) { + /* Release-edge bounce: the release that just queued an event was not a + * real one. Retract it and continue the original press cycle — the UI + * loop is barred from consuming events until the line has settled for + * DICE_DEBOUNCE_MS, so it cannot have acted on it yet. */ + if (!dice_committed && dice_short_events > 0) { + dice_short_events--; + } + return; + } + dice_press_start = now; + dice_committed = false; +} + +static void dice_on_release(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (!dice_accept || !dice_pressed) { + return; + } + dice_pressed = false; + dice_release_time = now; + dice_have_release = true; + if (dice_committed) { + return; /* the UI loop already committed this hold while it was held */ + } + uint32_t held = now - dice_press_start; + if (held >= DICE_HOLD_MS) { + /* A hold completed inside the UI-loop poll gap still counts. */ + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } else if (held >= DICE_DEBOUNCE_MS && dice_short_events < 8) { + dice_short_events++; + } +} +#endif + +uint32_t dice_rolls_for_strength(uint32_t strength_bits) { + switch (strength_bits) { + case 128: + return 50; + case 192: + return 75; + default: + return 99; /* 256 */ + } +} + +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count) { + SHA256_CTX ctx; + sha256_Init(&ctx); + sha256_Update(&ctx, entropy, 32); + sha256_Update(&ctx, (const uint8_t *)rolls, count); + sha256_Final(&ctx, entropy); + memzero(&ctx, sizeof(ctx)); +} + +static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, + const char *status, uint16_t hold_permil) { + Canvas *canvas = layout_get_canvas(); + char line[32]; + + layout_clear(); + display_constant_power(true); + + DrawableParams p = {.color = 0xFF, .x = DICE_LEFT, .y = 0}; + /* Clamped: the final commit redraws before the loop re-tests its + * condition, which would otherwise render an impossible "ROLL 100/99". */ + snprintf(line, sizeof(line), "ROLL %lu/%lu", + (unsigned long)(count < target ? count + 1 : target), + (unsigned long)target); + draw_string(canvas, get_title_font(), line, &p, 0, 10); + + for (uint8_t i = 0; i < DICE_POSITIONS; i++) { + uint16_t cx = DICE_LEFT + i * (DICE_CELL_SIZE + DICE_CELL_GAP); + bool active = (i == position); + /* Inverse video marks the active cell: white box, ink-black glyph. + * Gray levels collapse to white in the 1bpp DebugLink capture, so the + * machine-checkable signal must be geometry, not shade. */ + draw_box_simple(canvas, active ? 0xFF : 0x22, cx, DICE_GRID_Y, + DICE_CELL_SIZE, DICE_CELL_SIZE); + uint8_t ink = active ? 0x00 : 0xFF; + if (i < DICE_UNDO_POS) { + /* pin_font '1' is 4px wide where '2'-'6' are 8px (font.c) — center + * each on its own metric rather than on the common case. */ + uint16_t glyph_w = (i == 0) ? 4 : 8; + draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, + cx + (DICE_CELL_SIZE - glyph_w) / 2, DICE_GRID_Y + 2); + } else { + draw_char_simple(canvas, get_title_font(), '<', ink, cx + 5, + DICE_GRID_Y + 3); + } + } + + p.color = 0xFF; + p.x = DICE_LEFT; + p.y = DICE_STATUS_Y; + draw_string(canvas, get_body_font(), status, &p, DICE_BAR_W, 10); + + if (hold_permil > 0) { + draw_box_simple(canvas, 0xCC, DICE_BAR_X, DICE_BAR_Y, DICE_BAR_W, + DICE_BAR_H); + draw_box_simple(canvas, 0x00, DICE_BAR_X + 1, DICE_BAR_Y + 1, + DICE_BAR_W - 2, DICE_BAR_H - 2); + uint16_t fill = + (uint16_t)(((uint32_t)(DICE_BAR_W - 2) * hold_permil) / 1000); + if (fill > 0) { + draw_box_simple(canvas, 0xFF, DICE_BAR_X + 1, DICE_BAR_Y + 1, fill, + DICE_BAR_H - 2); + } + } + + display_refresh(); +} + +bool dice_input_collect(char *rolls, uint32_t target) { + uint32_t count = 0; + uint8_t position = 0; + bool ret = false; + bool redraw = true; + uint16_t last_bar_permil = 0; + char status[48]; + static CONFIDENTIAL uint8_t msg_tiny_buf[MSG_TINY_BFR_SZ]; + +#if DEBUG_LINK + _Static_assert(sizeof(DebugLinkDecision) <= MSG_TINY_BFR_SZ, + "DebugLinkDecision must fit the tiny message buffer"); +#endif + + if (target > DICE_MAX_ROLLS) { + return false; + } + + reset_msg_stack = false; + + dice_accept = false; + dice_pressed = false; + dice_committed = false; + dice_press_start = 0; + dice_release_time = 0; + dice_have_release = false; + dice_short_events = 0; + dice_hold_events = 0; + + call_leaving_handler(); + + snprintf(status, sizeof(status), _("PRESS next HOLD ok")); + +#ifndef EMULATOR + keepkey_button_set_on_press_handler(&dice_on_press, NULL); + keepkey_button_set_on_release_handler(&dice_on_release, NULL); +#endif + + ButtonRequest br; + memset(&br, 0, sizeof(br)); + br.has_code = true; + br.code = ButtonRequestType_ButtonRequest_DiceRoll; + msg_write(MessageType_MessageType_ButtonRequest, &br); + + while (count < target) { + bool pressed; + uint32_t held = 0; + uint8_t shorts = 0; + uint8_t holds; + + /* One critical section performs the whole read-classify-drain step, so + * the in-flight hold below cannot also be classified by the release ISR + * (and vice versa): whoever gets there first sets dice_committed. */ +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + { + uint32_t now = getSysTime(); + pressed = dice_pressed; + if (pressed) { + held = now - dice_press_start; + if (!dice_committed && held >= DICE_HOLD_MS) { + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } + } + /* Queued short presses stay queued until a debounce window has passed + * since the release that produced them, giving dice_on_press the + * chance to retract a bounce-generated one before it is acted on. + * Deliberately NOT conditioned on the button being up: a retraction + * can only happen inside that window, so once it closes the count is + * final. Waiting for the button to be released instead would let a + * tap-then-hold commit the digit the tap was meant to move off of. */ + if (dice_have_release && now - dice_release_time >= DICE_DEBOUNCE_MS) { + shorts = dice_short_events; + dice_short_events = 0; + } + holds = dice_hold_events; + dice_hold_events = 0; + } +#ifndef EMULATOR + svc_enable_interrupts(); +#endif + + uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); + switch (tiny_msg) { + case MessageType_MessageType_ButtonAck: + dice_accept = true; /* arms the button ISRs and debug injection */ + break; + + case MessageType_MessageType_Cancel: + case MessageType_MessageType_Initialize: + if (tiny_msg == MessageType_MessageType_Initialize) { + reset_msg_stack = true; + } + goto dice_exit; + +#if DEBUG_LINK + case MessageType_MessageType_DebugLinkDecision: { + const DebugLinkDecision *dld = (const DebugLinkDecision *)msg_tiny_buf; + if (dice_accept && dld->has_input) { + for (const char *c = dld->input; *c != '\0' && count < target; c++) { + if (*c >= '1' && *c <= '6') { + rolls[count++] = *c; + snprintf(status, sizeof(status), _("Entered %c (%lu)"), *c, + (unsigned long)count); + } else if (*c == 'u' && count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } + } + redraw = true; + } + break; + } + + case MessageType_MessageType_DebugLinkGetState: + call_msg_debug_link_get_state_handler( + (DebugLinkGetState *)msg_tiny_buf); + break; +#endif + + default: + break; + } + + if (shorts > 0) { + position = (uint8_t)((position + shorts) % DICE_POSITIONS); + redraw = true; + } + + /* Commits arrive either from the in-flight check above or from a release + * that completed inside the poll gap; both funnel through here, and + * dice_committed guarantees at most one per press. */ + while (holds-- > 0 && count < target) { + if (position < DICE_UNDO_POS) { + rolls[count++] = (char)('1' + position); + snprintf(status, sizeof(status), _("Entered %c (%lu)"), + (char)('1' + position), (unsigned long)count); + } else if (count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } else { + snprintf(status, sizeof(status), _("Nothing to undo")); + } + redraw = true; + } + + uint16_t bar_permil = 0; + if (pressed && held < DICE_HOLD_MS) { + bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + } else if (pressed) { + bar_permil = 1000; /* held past the threshold: keep the bar full */ + } + + /* Quantize the bar so idle passes stay refresh-free. */ + bar_permil = (uint16_t)(bar_permil - (bar_permil % 50)); + if (redraw || bar_permil != last_bar_permil) { + dice_draw_screen(count, target, position, status, bar_permil); + last_bar_permil = bar_permil; + redraw = false; + } + + animate(); + display_refresh(); + } + + ret = true; + +dice_exit: + dice_accept = false; +#ifndef EMULATOR + keepkey_button_set_on_press_handler(NULL, NULL); + keepkey_button_set_on_release_handler(NULL, NULL); +#endif + memzero(status, sizeof(status)); + memzero(msg_tiny_buf, sizeof(msg_tiny_buf)); + return ret; +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index d1ba0eed9..3f26fc2db 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -482,22 +482,126 @@ void fsm_msgFirmwareUpload(FirmwareUpload* msg) { "Not in bootloader mode"); } +/* Bytes of entropy a host may collect per boot without a button press. + * + * Auditing the RNG (bias tests, birthday/collision scans) needs bulk + * samples, and a press per kilobyte made that impossible on real hardware + * -- so nobody ever checked. The returned bytes are drawn fresh and + * discarded; they are never reused as key material, and the STM32 RNG is a + * free-running noise source rather than a seeded DRBG, so observing output + * reveals nothing about past or future draws. + * + * What the press did still buy is a cap on bias characterization: random32() + * returns RNG_DR raw with no whitening, and unlimited raw output lets a + * hostile host measure that bias precisely. A per-boot budget keeps that + * cap against a remote malicious host (which cannot replug) while leaving + * an audit plenty of room. Once spent, the confirm comes back; replug to + * refresh. */ +#define ENTROPY_FREE_BUDGET (64 * 1024) + +/* 64 KB is NOT "plenty of room" for the health test this enables. + * + * Scope first, because it is easy to overclaim: bulk output supports RNG HEALTH + * testing, not entropy measurement. No amount of output analysis can bound the + * entropy of an RNG's internal state -- a good expander seeded with 40 bits + * emits a stream that passes every test below, by construction. What this + * catches is stuck/biased output, repeated buffers, transport caching, gross + * correlation, and a broken test harness. That is worth having and was + * previously impossible on hardware; it is not proof of unpredictability. + * + * The size is set by the POSITIVE control, not by a detection threshold. A + * zero-collision result proves nothing on its own -- a detector that never + * fires also returns zero -- so the scan must also be run at a width where + * collisions are EXPECTED and their count checked against theory. 32-bit + * collisions over N blocks expect N^2/2^33: at 64 KB that is 0.03 (the control + * cannot run at all), at 1 MB it is 8, at 8 MB it is 512, tight enough that a + * broken or no-op detector is obvious. 8 MB is the first size at which the + * result means anything. + * + * For reference, since it invites misreading: a 64-bit scan over N=2^20 expects + * one collision at a 39-bit support, but P(0 collisions) is then e^-1 = 37%. + * Zero collisions excludes only <=37.4 bits at 95% confidence, and says nothing + * whatsoever about a low-entropy state behind a strong PRNG. + * + * The per-boot cap was also asymmetric in the wrong direction: it never stopped + * a patient remote attacker (host malware simply waits for the natural replugs + * that happen anyway and accumulates 64 KB at a time over days), while it fully + * priced out the honest auditor, who needs one contiguous run and otherwise + * faces 128 manual replugs. + * + * So the bulk path is gated on a single explicit press instead of a byte count, + * and only before initialization: + * + * - one confirm per boot unlocks unmetered draws. A remote host cannot forge + * it, which is the property the byte cap was only approximating. + * - uninitialized only. No seed exists, so there is no key material to + * correlate against; the 32 bytes that DO become a seed are drawn later, in + * reset.c, from noise that has not happened yet, and are SHA-256'd with + * host-supplied entropy before use. + * + * Initialized devices are untouched: ENTROPY_FREE_BUDGET, then a press every + * time, exactly as before. The unlock re-locks the instant ResetDevice + * completes, because storage_isInitialized() is re-read on every call. + * + * A wiped device returns to uninitialized and can be audited again. That is + * intended -- it still holds no seed, and re-auditing before re-seeding is + * precisely the supported flow. */ + +/* Whether the budget above may be spent without a press. + * + * GetEntropy has no PIN or initialization gate -- the button press WAS the + * human gate. Dropping it unconditionally would let someone holding a locked + * device harvest raw RNG output silently, and replug to repeat, so restrict + * the press-free path to states where there is either nothing to protect or + * a user demonstrably present: + * + * - uninitialized: no seed exists yet. This is the case that matters -- + * auditing the RNG *before* trusting it to generate a seed. + * - no PIN configured: nothing is locked, so the press guards nothing that + * physical possession does not already defeat. + * - PIN already entered this session: the user is right there. + * + * An initialized, PIN-protected, locked device is the stolen / evil-maid + * case and falls back to the confirm exactly as before. */ +static bool entropy_press_free_allowed(void) { + if (!storage_isInitialized()) return true; + if (!storage_hasPin()) return true; + return session_isPinCached(); +} + void fsm_msgGetEntropy(GetEntropy* msg) { - if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, "Generate Entropy", - "Do you want to generate and return entropy using the hardware " - "RNG?")) { - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); - layoutHome(); - return; - } + static uint32_t free_budget = ENTROPY_FREE_BUDGET; + /* Set by one confirm on an uninitialized device; unlocks unmetered draws for + * the rest of the boot. Re-checked against storage_isInitialized() on every + * call, so completing ResetDevice re-locks it without needing a replug. */ + static bool bulk_audit_unlocked = false; - RESP_INIT(Entropy); uint32_t len = msg->size; if (len > ENTROPY_BUF) { len = ENTROPY_BUF; } + if (bulk_audit_unlocked && !storage_isInitialized()) { + /* Already authorized for bulk audit this boot. */ + } else if (len <= free_budget && entropy_press_free_allowed()) { + free_budget -= len; + } else if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, + "Generate Entropy", + "Do you want to generate and return entropy using the " + "hardware RNG?")) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); + layoutHome(); + return; + } else if (!storage_isInitialized()) { + /* The press just paid for a bulk RNG audit on a seedless device — don't + * charge for it again. An initialized device deliberately falls through: + * it keeps confirming every draw once its budget is spent. */ + bulk_audit_unlocked = true; + } + + RESP_INIT(Entropy); + resp->entropy.size = len; random_buffer(resp->entropy.bytes, len); msg_write(MessageType_MessageType_Entropy, resp); @@ -535,8 +639,10 @@ void fsm_msgLoadDevice(LoadDevice* msg) { void fsm_msgResetDevice(ResetDevice* msg) { CHECK_NOT_INITIALIZED - reset_init(msg->has_display_random && msg->display_random, - msg->has_strength ? msg->strength : 128, + // display_random remains in the wire schema for host compatibility, but is + // intentionally ignored: internal entropy is seed pre-image material and + // must never be rendered or returned by production firmware. + reset_init(msg->has_strength ? msg->strength : 128, msg->has_passphrase_protection && msg->passphrase_protection, msg->has_pin_protection && msg->pin_protection, msg->has_language ? msg->language : 0, @@ -544,7 +650,8 @@ void fsm_msgResetDevice(ResetDevice* msg) { msg->has_no_backup ? msg->no_backup : false, msg->has_auto_lock_delay_ms ? msg->auto_lock_delay_ms : STORAGE_DEFAULT_SCREENSAVER_TIMEOUT, - msg->has_u2f_counter ? msg->u2f_counter : 0); + msg->has_u2f_counter ? msg->u2f_counter : 0, + msg->has_dice_entropy && msg->dice_entropy); } void fsm_msgEntropyAck(EntropyAck* msg) { diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 362b3b49e..91923edec 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -21,6 +21,7 @@ #include "keepkey/board/keepkey_board.h" #include "keepkey/board/messages.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/dice_input.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/pin_sm.h" @@ -43,10 +44,38 @@ static bool awaiting_entropy = false; static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static bool no_backup; -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter) { +/* SHA-256 of the ASCII roll string, shown to the user and exposed over + * DebugLink. A digest of secret input is not the input, but it is a + * verification oracle for a 99-symbol space, so it is treated as + * confidential and cleared as soon as the reset that produced it ends. */ +static uint8_t CONFIDENTIAL dice_digest[32]; +static bool has_dice_digest = false; + +static void dice_digest_clear(void) { + memzero(dice_digest, sizeof(dice_digest)); + has_dice_digest = false; +} + +/* Shared paginated-mnemonic display scratch — see reset.h for the contract + * (also used by the BIP-85 flow; each user zeroes at entry and exit). */ +char CONFIDENTIAL mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy) { + /* Disarm any half-finished reset before doing anything else. Nothing else + * clears this flag on an abort (fsm_msgCancel has no reset abort), and + * CHECK_NOT_INITIALIZED still admits ResetDevice while a previous one is + * mid-flight, so a stale armed flag would let a later EntropyAck run + * reset_entropy against whatever int_entropy this invocation leaves + * behind -- including the zeroed buffer an aborted dice step produces, + * which would make the seed a pure function of host-supplied bytes. */ + awaiting_entropy = false; + if (_strength != 128 && _strength != 192 && _strength != 256) { fsm_sendFailure( FailureType_Failure_SyntaxError, @@ -58,13 +87,6 @@ void reset_init(bool display_random, uint32_t _strength, strength = _strength; no_backup = _no_backup; - if (display_random && no_backup) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("Can't show internal entropy when backup is skipped")); - layoutHome(); - return; - } - if (no_backup) { // Double confirm, since this is a feature for advanced users only, and // there is risk of loss of funds if this mode is used incorrectly @@ -85,23 +107,53 @@ void reset_init(bool display_random, uint32_t _strength, random_buffer(int_entropy, 32); - if (display_random) { - static char CONFIDENTIAL ent_str[4][17]; - data2hex(int_entropy, 8, ent_str[0]); - data2hex(int_entropy + 8, 8, ent_str[1]); - data2hex(int_entropy + 16, 8, ent_str[2]); - data2hex(int_entropy + 24, 8, ent_str[3]); - - if (!confirm(ButtonRequestType_ButtonRequest_ResetDevice, - _("Internal Entropy"), "%s %s %s %s", ent_str[0], ent_str[1], - ent_str[2], ent_str[3])) { - memzero(ent_str, sizeof(ent_str)); + /* Dice fold in before EntropyRequest, so the host contribution arrives + * strictly after the device has committed to its own. + * + * They are deliberately NOT displayed. An earlier version of this code + * showed the mixed internal entropy on the OLED and called it a + * verifiable commitment; that was wrong. A host that supplies + * ext_entropy and reads that screen once computes + * SHA256(shown || ext_entropy) -- the seed pre-image -- and dice change + * nothing about it, because the displayed value is already post-mix. The + * roll digest below is safe by contrast: it is a hash of the user's own + * input, not of seed material. */ + dice_digest_clear(); + if (dice_entropy) { + static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; + static char CONFIDENTIAL digest_hex[17]; + uint32_t rolls_needed = dice_rolls_for_strength(strength); + + if (!dice_input_collect(dice_rolls, rolls_needed)) { + memzero(dice_rolls, sizeof(dice_rolls)); + memzero(int_entropy, sizeof(int_entropy)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + layoutHome(); + return; + } + + sha256_Raw((const uint8_t*)dice_rolls, rolls_needed, dice_digest); + has_dice_digest = true; + + data2hex(dice_digest, 8, digest_hex); + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), + _("%lu rolls recorded.\nDigest: %s"), + (unsigned long)rolls_needed, digest_hex); + memzero(digest_hex, sizeof(digest_hex)); + if (!confirmed) { + memzero(dice_rolls, sizeof(dice_rolls)); + memzero(int_entropy, sizeof(int_entropy)); + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Reset cancelled")); layoutHome(); return; } - memzero(ent_str, sizeof(ent_str)); + + dice_mix(int_entropy, dice_rolls, rolls_needed); + memzero(dice_rolls, sizeof(dice_rolls)); } if (pin_protection) { @@ -167,16 +219,23 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { } /* - * Format mnemonic for user review + * Format mnemonic for user review. Display scratch is the set shared with + * the BIP-85 flow (see reset.h) — zero it at entry: the format loop below + * depends on empty page strings, and a prior user may have aborted. */ uint32_t word_count = 0, page_count = 0; - static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_BUF]; static char CONFIDENTIAL mnemonic_by_screen[MAX_PAGES][MNEMONIC_BY_SCREEN_BUF]; - static char CONFIDENTIAL - formatted_mnemonic[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL mnemonic_display[FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL formatted_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + char* tokened_mnemonic = mnemonic_scratch_tokened; + char (*formatted_mnemonic)[FORMATTED_MNEMONIC_BUF] = + mnemonic_scratch_formatted; + char* mnemonic_display = mnemonic_scratch_display; + char* formatted_word = mnemonic_scratch_word; + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); strlcpy(tokened_mnemonic, temp_mnemonic, TOKENED_MNEMONIC_BUF); @@ -256,12 +315,15 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { fsm_sendSuccess(_("Device reset")); exit: + /* The digest only describes the reset that produced it; leaving it live + * would keep serving it over DebugLink for the rest of the boot. */ + dice_digest_clear(); memzero(&ctx, sizeof(ctx)); - memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); - memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); - memzero(mnemonic_display, sizeof(mnemonic_display)); - memzero(formatted_word, sizeof(formatted_word)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); layoutHome(); } @@ -272,4 +334,12 @@ uint32_t reset_get_int_entropy(uint8_t* entropy) { } const char* reset_get_word(void) { return current_words; } + +uint32_t reset_get_dice_digest(uint8_t* digest) { + if (!has_dice_digest) { + return 0; + } + memcpy(digest, dice_digest, 32); + return 32; +} #endif diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 57c8c75b9..71948958c 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -21,12 +21,42 @@ #include "trezor/crypto/rand.h" +#ifdef EMULATOR +#include "keepkey/emulator/emulator.h" +#endif + #ifndef EMULATOR #include #include #include #endif +/* random32() has two implementations selected by a build flag: the STM32 + * hardware RNG, and -- under EMULATOR -- the host OS CSPRNG. Neither is a + * weak PRNG today, and the emulator branch deliberately aborts rather than + * degrading to libc random(). + * + * This assertion guards the *selection*, not either implementation. The + * July 2026 COLDCARD incident was not a broken RNG: a board config left + * the hardware-RNG macro defined-but-zero, the supporting library tested + * only whether that macro was *defined* rather than enabled, and seed + * generation silently used the wrong source for five years (~1,367 BTC + * drained across 4,585 addresses). Nothing about the output looked wrong + * -- the substituted generator passed every statistical test, it was just + * seeded with ~40 bits -- so no amount of host-side entropy testing could + * have caught it. Only the build configuration was wrong. + * + * The lesson is that "which RNG did we actually compile in" deserves a + * check the build cannot silently get wrong. __arm__ comes from the + * compiler's own target definition rather than from any board config or + * CMake option, so a mistaken -DEMULATOR cannot satisfy both conditions: + * firmware targeting the STM32 can only ever compile the RNG_DR path. + * Hosted emulator builds (x86_64 / __aarch64__) are unaffected. */ +#if defined(EMULATOR) && defined(__arm__) +#error \ + "EMULATOR selects the host-CSPRNG random32(); ARM firmware must use the STM32 hardware RNG" +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ @@ -78,24 +108,45 @@ uint32_t random32(void) { last = new; return new; #else - return random(); + /* Emulator cryptography must use the host OS CSPRNG. emulatorRandom() is + * backed by /dev/urandom on POSIX and BCryptGenRandom on Windows and aborts + * the process on failure; never fall back to libc random(). */ + uint32_t v = 0; + emulatorRandom(&v, sizeof(v)); + return v; #endif } +#if defined(EMULATOR) && !defined(__APPLE__) +/* trezor-crypto declares random_buffer() as a weak symbol so platforms can + * supply their own. GNU/MinGW ld will NOT extract a weak definition from a + * static archive to satisfy a strong reference (fsm.c/reset.c/storage.c), + * which breaks the Linux .so and Windows .dll links. Provide a strong + * definition here — identical to trezor-crypto's, built on our random32(). + * macOS ld64 resolves the weak one fine, so it's left untouched there. */ +void random_buffer(uint8_t* buf, size_t len) { + uint32_t r = 0; + for (size_t i = 0; i < len; i++) { + if (i % 4 == 0) r = random32(); + buf[i] = (r >> ((i % 4) * 8)) & 0xff; + } +} +#endif + // I miss C++ templates sooo bad. -#define RANDOM_PERMUTE(BUFF, COUNT) \ - do { \ - for (size_t i = (COUNT)-1; i >= 1; i--) { \ - size_t j = random_uniform(i + 1); \ - typeof(*(BUFF)) t = (BUFF)[j]; \ - (BUFF)[j] = (BUFF)[i]; \ - (BUFF)[i] = t; \ - } \ +#define RANDOM_PERMUTE(BUFF, COUNT) \ + do { \ + for (size_t i = (COUNT) - 1; i >= 1; i--) { \ + size_t j = random_uniform(i + 1); \ + typeof(*(BUFF)) t = (BUFF)[j]; \ + (BUFF)[j] = (BUFF)[i]; \ + (BUFF)[i] = t; \ + } \ } while (0) -void random_permute_char(char *str, size_t len) { RANDOM_PERMUTE(str, len); } +void random_permute_char(char* str, size_t len) { RANDOM_PERMUTE(str, len); } -void random_permute_u16(uint16_t *buf, size_t count) { +void random_permute_u16(uint16_t* buf, size_t count) { RANDOM_PERMUTE(buf, count); } diff --git a/scripts/emulator/capture-dice-flow.py b/scripts/emulator/capture-dice-flow.py new file mode 100644 index 000000000..47821a819 --- /dev/null +++ b/scripts/emulator/capture-dice-flow.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Capture the on-device dice-entry screens from kkemu. + +Evidence tool for the dice_entropy ResetDevice flow: drives a full reset with +device-side dice collection via DebugLinkDecision.input injection and saves +the OLED at each interesting state. +""" + +import hashlib +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "deps" / "python-keepkey")) + +from keepkeylib.client import KeepKeyDebuglinkClient, _write_png +from keepkeylib.transport_udp import UDPTransport +from keepkeylib import messages_pb2 as proto + +OUT = Path(sys.argv[1]).resolve() +OUT.mkdir(parents=True, exist_ok=True) + +client = KeepKeyDebuglinkClient( + UDPTransport(os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"))) +client.set_debuglink( + UDPTransport(os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"))) + + +def snap(name): + time.sleep(0.3) + layout = client.debug.read_layout() + rows = [] + for y in range(64): + row = bytearray(256) + for x in range(256): + b = layout[x + (y // 8) * 256] + if isinstance(b, str): + b = ord(b) + if (b >> (y % 8)) & 1: + row[x] = 255 + rows.append(bytes(row)) + path = OUT / name + with open(path, "wb") as f: + f.write(_write_png(str(path), 256, 64, rows)) + print(path) + + +client.auto_button = True +client.wipe_device() +client.auto_button = False + +ret = client.call_raw(proto.ResetDevice( + display_random=True, strength=256, passphrase_protection=False, + pin_protection=False, language='english', label='dice evidence', + dice_entropy=True)) +assert isinstance(ret, proto.ButtonRequest), ret + +client.transport.write(proto.ButtonAck()) +time.sleep(0.3) +snap("01-dice-screen-initial.png") + +client.debug.press_input("123") +snap("02-after-three-rolls.png") + +client.debug.press_input("u") +snap("03-after-undo.png") + +rolls = "123456" * 17 # 102, extras past 99 dropped; net = 2 + 99 capped +client.debug.press_input(rolls[:40]) +time.sleep(0.2) +client.debug.press_input(rolls[40:80]) +time.sleep(0.2) +client.debug.press_input(rolls[80:]) +resp = client.transport.read_blocking() +assert isinstance(resp, proto.ButtonRequest), resp +snap("04-digest-confirm.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.ButtonRequest), ret # post-mix entropy display +snap("05-postmix-internal-entropy.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.EntropyRequest), ret +ret = client.call_raw(proto.EntropyAck(entropy=b'E' * 32)) + +assert isinstance(ret, proto.ButtonRequest), ret +snap("06-backup-explainer.png") +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +while isinstance(ret, proto.ButtonRequest): + client.debug.press_yes() + ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.Success), ret +print("flow complete:", ret.message) diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 5e828e1ae..cb01b7aa3 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -3,6 +3,7 @@ set(sources app_confirm.cpp coins.cpp cosmos.cpp + dice.cpp eos.cpp eip712.cpp ethereum.cpp diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp new file mode 100644 index 000000000..93654062e --- /dev/null +++ b/unittests/firmware/dice.cpp @@ -0,0 +1,64 @@ +extern "C" { +#include "keepkey/firmware/dice_input.h" +} + +#include "gtest/gtest.h" + +#include +#include + +static std::string hexlify(const uint8_t *bytes, size_t len) { + static const char *alph = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += alph[bytes[i] >> 4]; + out += alph[bytes[i] & 0xF]; + } + return out; +} + +TEST(Dice, RollsForStrength) { + // d6 = 2.585 bits/roll; Coldcard-convention targets. + EXPECT_EQ(dice_rolls_for_strength(128), 50u); + EXPECT_EQ(dice_rolls_for_strength(192), 75u); + EXPECT_EQ(dice_rolls_for_strength(256), 99u); +} + +TEST(Dice, MixZeroEntropyVector) { + // SHA256(0x00*32 || "123456") + uint8_t entropy[32]; + memset(entropy, 0, sizeof(entropy)); + dice_mix(entropy, "123456", 6); + EXPECT_EQ(hexlify(entropy, 32), + "16ba88244e0230b0fc84868b703a0e32c344be1b0284f2e67e59715f123748d6"); +} + +TEST(Dice, MixNonZeroEntropyVector) { + // SHA256(0x00..0x1f || "654321165243") + uint8_t entropy[32]; + for (int i = 0; i < 32; i++) entropy[i] = (uint8_t)i; + dice_mix(entropy, "654321165243", 12); + EXPECT_EQ(hexlify(entropy, 32), + "d1ab5a0b7f106313b6ba44d6863c5d1b90397d9e4a0f87a0a6baa25bad00ae97"); +} + +TEST(Dice, MixDependsOnRolls) { + uint8_t a[32], b[32]; + memset(a, 0xAB, sizeof(a)); + memset(b, 0xAB, sizeof(b)); + dice_mix(a, "111111", 6); + dice_mix(b, "111112", 6); + EXPECT_NE(0, memcmp(a, b, 32)); +} + +TEST(Dice, MixUsesExactCount) { + // Only `count` bytes of the roll buffer may contribute. + uint8_t a[32], b[32]; + memset(a, 0, sizeof(a)); + memset(b, 0, sizeof(b)); + const char rolls_a[8] = {'1', '2', '3', '4', '5', '6', '1', '2'}; + const char rolls_b[8] = {'1', '2', '3', '4', '5', '6', '6', '5'}; + dice_mix(a, rolls_a, 6); + dice_mix(b, rolls_b, 6); + EXPECT_EQ(0, memcmp(a, b, 32)); +} From 09c417f7527ef07a0bbe5b43ec17a223d44360b1 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 15:33:26 -0300 Subject: [PATCH 3/4] fix: clear dice digest on PIN cancellation --- lib/firmware/reset.c | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 91923edec..7a99a5f51 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -158,6 +158,7 @@ void reset_init(uint32_t _strength, bool passphrase_protection, if (pin_protection) { if (!change_pin()) { + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("PINs do not match")); layoutHome(); From 39555bd06d991cf2a15b1de9f7e33201e8ac851d Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 19:08:16 -0300 Subject: [PATCH 4/4] ci: assert the emulator RNG source alongside the change that provides it Restores the RC18 invariant that fails the build if lib/rand/rng.c falls back to libc random(). It was previously declared in the foundation slice, three PRs before the emulatorRandom() implementation landed, so it only ever fired on code its own PR could not fix. Here it guards a change that is present in the same commit range, which is the whole point of a build-config invariant: the July 2026 COLDCARD loss came from a build selecting the wrong RNG silently, and a gate that cries wolf is one reviewers learn to skip. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3fc8d337..e02527bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,11 @@ jobs: echo "::error::Unauthenticated persistent clearsign trust is retired" exit 1 fi + if git grep -n -E 'return[[:space:]]+random\(\)' -- \ + lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi if git grep -n -F 'option(KK_ZCASH_PRIVACY' -- CMakeLists.txt; then echo "::error::Zcash privacy must not become a third release choice" exit 1