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 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/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 000000000..52dbe28bb Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png differ 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 000000000..cc8f5ca95 Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png differ 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 000000000..ea5a39d1e Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/03-after-undo.png differ diff --git a/docs/security/evidence/dice-entropy-reset/04-digest-confirm.png b/docs/security/evidence/dice-entropy-reset/04-digest-confirm.png new file mode 100644 index 000000000..ed476b39a Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/04-digest-confirm.png differ diff --git a/docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png b/docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png new file mode 100644 index 000000000..33155c567 Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png differ 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 000000000..74ce8c14c Binary files /dev/null and b/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png differ 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/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/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/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/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/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/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/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/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 dcf21b9d4..da893616f 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 @@ -19,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/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/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.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..3f26fc2db 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; @@ -480,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); @@ -533,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, @@ -542,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/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/reset.c b/lib/firmware/reset.c index 362b3b49e..7a99a5f51 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,27 +107,58 @@ 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) { if (!change_pin()) { + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("PINs do not match")); layoutHome(); @@ -167,16 +220,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 +316,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 +335,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/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/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 b997e1738..cb01b7aa3 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,7 +1,9 @@ set(sources + authenticator.cpp app_confirm.cpp coins.cpp cosmos.cpp + dice.cpp eos.cpp eip712.cpp ethereum.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/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)); +} 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); +}