Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions docs/security/anti-rollback-security-epoch-rfc.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions docs/security/evidence/dice-entropy-reset/README.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions docs/security/pin-kdf-v19-migration.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 61 additions & 17 deletions include/keepkey/emulator/libkkemu.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,35 +86,32 @@ 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);

/**
* 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);
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion include/keepkey/firmware/authenticator.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +42,8 @@ enum AUTH_ERR_TYPE {
LARGESEED,
BADPASS,
UNKERR,
DUPLICATE,
AUTH_CANCELLED,
NUM_AUTHERRS
};

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions include/keepkey/firmware/bip85.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#ifndef BIP85_H
#define BIP85_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

/**
* Derive a child BIP-39 mnemonic via BIP-85.
*
* Path: m/83696968'/39'/0'/<word_count>'/<index>'
*
* @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
Loading
Loading