diff --git a/deps/device-protocol b/deps/device-protocol index 674777f6d..cf308fd5e 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 674777f6d4dd16e2b8c4c2df10608976375ee879 +Subproject commit cf308fd5e76f3f2e02ba38347ceccaa382b08505 diff --git a/deps/python-keepkey b/deps/python-keepkey index 1f2eecd22..a8e41261c 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 1f2eecd227f73996b1d60af3af1b2972430527ad +Subproject commit a8e41261cd15e63dec41bdad04309768179740cb 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/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h new file mode 100644 index 000000000..3ef80c7aa --- /dev/null +++ b/include/keepkey/firmware/dice_input.h @@ -0,0 +1,49 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#ifndef KEEPKEY_FIRMWARE_DICE_INPUT_H +#define KEEPKEY_FIRMWARE_DICE_INPUT_H + +#include +#include + +/* d6 carries log2(6) = 2.585 bits per roll; targets follow the Coldcard + * convention of 50 rolls per 128-bit seed and 99 per 256-bit. */ +#define DICE_MAX_ROLLS 99 + +/// Number of rolls required for a given seed strength (128/192/256). +uint32_t dice_rolls_for_strength(uint32_t strength_bits); + +/// Collect `target` dice rolls on the device with the single button: +/// short press advances the 1-6/UNDO selector, holding the button commits +/// the selection. Announces itself with ButtonRequest_DiceRoll and accepts +/// input only after the host's ButtonAck. Under DEBUG_LINK, characters +/// '1'-'6' and 'u' (undo) arriving in DebugLinkDecision.input are treated +/// as committed selections. +/// +/// Fills `rolls` with `target` ASCII digits '1'-'6' (no terminator is +/// appended past target; the caller owns zeroization). Returns false if the +/// host cancelled (Cancel/Initialize). +bool dice_input_collect(char *rolls, uint32_t target); + +/// entropy = SHA256(entropy[32] || rolls[count]); the caller displays or +/// commits only the post-mix value. +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count); + +#endif diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index da5ae10f6..3eed3dbf8 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -46,9 +46,11 @@ extern char mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; 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); + 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/transport/messages.options b/include/keepkey/transport/messages.options index 7ac6ff0a9..71944508a 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -120,6 +120,11 @@ DebugLinkState.recovery_cipher max_size:27 DebugLinkState.recovery_auto_completed_word max_size:12 DebugLinkState.firmware_hash max_size:32 DebugLinkState.storage_hash max_size:32 +DebugLinkState.dice_digest max_size:32 + +# Sized so the decoded struct stays within MSG_TINY_BFR_SZ (64B): the tiny +# message path pb_decodes DebugLinkDecision straight into that buffer. +DebugLinkDecision.input max_size:41 DebugLinkFlashDumpResponse.data max_size:1024 diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index a1b11000d..7540ca629 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -5,6 +5,7 @@ set(sources bip85.c coins.c crypto.c + dice_input.c fsm.c home_sm.c passphrase_sm.c diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c new file mode 100644 index 000000000..996ef540b --- /dev/null +++ b/lib/firmware/dice_input.c @@ -0,0 +1,387 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#include "keepkey/firmware/dice_input.h" + +#include "keepkey/board/draw.h" +#include "keepkey/board/font.h" +#include "keepkey/board/keepkey_button.h" +#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/layout.h" +#include "keepkey/board/messages.h" +#include "keepkey/board/supervise.h" +#include "keepkey/board/timer.h" +#include "keepkey/transport/interface.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +/* Selector positions 0-5 are digits '1'-'6'; 6 is UNDO. */ +#define DICE_POSITIONS 7 +#define DICE_UNDO_POS 6 + +/* Holding this long commits the selection; edges closer together than the + * debounce window are contact bounce. Distinct from CONFIRM_TIMEOUT_MS on + * purpose: a 1200ms hold per roll makes 99 rolls a slog. */ +#define DICE_HOLD_MS 800 +#define DICE_DEBOUNCE_MS 30 + +/* The screen runs with display_constant_power(true): the display driver + * fills x<128 with the INVERSE of x>=128 at refresh time so total lit + * pixels stay constant (OLED power side-channel defense — same reason the + * PIN matrix lives on the right half). All drawing must stay in x>=128. */ +#define DICE_LEFT 130 +#define DICE_CELL_SIZE 15 +#define DICE_CELL_GAP 2 +#define DICE_GRID_Y 14 +#define DICE_STATUS_Y 33 +#define DICE_BAR_X DICE_LEFT +#define DICE_BAR_Y 48 +#define DICE_BAR_W (7 * DICE_CELL_SIZE + 6 * DICE_CELL_GAP) +#define DICE_BAR_H 6 + +extern bool reset_msg_stack; + +/* Button state shared with the ISR. Every classification decision (short vs + * hold) is made exactly once per press cycle and guarded by dice_committed, + * so a press can never produce both an advance and a commit. The UI loop + * reads and drains these under masked interrupts. */ +static volatile bool dice_accept; /* host has ButtonAck'd the screen */ +static volatile bool dice_pressed; +static volatile bool dice_committed; /* this press cycle already classified */ +static volatile uint32_t dice_press_start; +static volatile uint32_t dice_release_time; +static volatile bool dice_have_release; +static volatile uint8_t dice_short_events; +static volatile uint8_t dice_hold_events; + +#ifndef EMULATOR +static void dice_on_press(void *context) { + (void)context; + uint32_t now = getSysTime(); + /* Mirror confirm_sm: input is dead until the host acks the request, so a + * press begun before the ack cannot accrue hold time toward a commit. */ + if (!dice_accept || dice_pressed) { + return; + } + dice_pressed = true; + if (dice_have_release && now - dice_release_time < DICE_DEBOUNCE_MS) { + /* Release-edge bounce: the release that just queued an event was not a + * real one. Retract it and continue the original press cycle — the UI + * loop is barred from consuming events until the line has settled for + * DICE_DEBOUNCE_MS, so it cannot have acted on it yet. */ + if (!dice_committed && dice_short_events > 0) { + dice_short_events--; + } + return; + } + dice_press_start = now; + dice_committed = false; +} + +static void dice_on_release(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (!dice_accept || !dice_pressed) { + return; + } + dice_pressed = false; + dice_release_time = now; + dice_have_release = true; + if (dice_committed) { + return; /* the UI loop already committed this hold while it was held */ + } + uint32_t held = now - dice_press_start; + if (held >= DICE_HOLD_MS) { + /* A hold completed inside the UI-loop poll gap still counts. */ + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } else if (held >= DICE_DEBOUNCE_MS && dice_short_events < 8) { + dice_short_events++; + } +} +#endif + +uint32_t dice_rolls_for_strength(uint32_t strength_bits) { + switch (strength_bits) { + case 128: + return 50; + case 192: + return 75; + default: + return 99; /* 256 */ + } +} + +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count) { + SHA256_CTX ctx; + sha256_Init(&ctx); + sha256_Update(&ctx, entropy, 32); + sha256_Update(&ctx, (const uint8_t *)rolls, count); + sha256_Final(&ctx, entropy); + memzero(&ctx, sizeof(ctx)); +} + +static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, + const char *status, uint16_t hold_permil) { + Canvas *canvas = layout_get_canvas(); + char line[32]; + + layout_clear(); + display_constant_power(true); + + DrawableParams p = {.color = 0xFF, .x = DICE_LEFT, .y = 0}; + /* Clamped: the final commit redraws before the loop re-tests its + * condition, which would otherwise render an impossible "ROLL 100/99". */ + snprintf(line, sizeof(line), "ROLL %lu/%lu", + (unsigned long)(count < target ? count + 1 : target), + (unsigned long)target); + draw_string(canvas, get_title_font(), line, &p, 0, 10); + + for (uint8_t i = 0; i < DICE_POSITIONS; i++) { + uint16_t cx = DICE_LEFT + i * (DICE_CELL_SIZE + DICE_CELL_GAP); + bool active = (i == position); + /* Inverse video marks the active cell: white box, ink-black glyph. + * Gray levels collapse to white in the 1bpp DebugLink capture, so the + * machine-checkable signal must be geometry, not shade. */ + draw_box_simple(canvas, active ? 0xFF : 0x22, cx, DICE_GRID_Y, + DICE_CELL_SIZE, DICE_CELL_SIZE); + uint8_t ink = active ? 0x00 : 0xFF; + if (i < DICE_UNDO_POS) { + /* pin_font '1' is 4px wide where '2'-'6' are 8px (font.c) — center + * each on its own metric rather than on the common case. */ + uint16_t glyph_w = (i == 0) ? 4 : 8; + draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, + cx + (DICE_CELL_SIZE - glyph_w) / 2, DICE_GRID_Y + 2); + } else { + draw_char_simple(canvas, get_title_font(), '<', ink, cx + 5, + DICE_GRID_Y + 3); + } + } + + p.color = 0xFF; + p.x = DICE_LEFT; + p.y = DICE_STATUS_Y; + draw_string(canvas, get_body_font(), status, &p, DICE_BAR_W, 10); + + if (hold_permil > 0) { + draw_box_simple(canvas, 0xCC, DICE_BAR_X, DICE_BAR_Y, DICE_BAR_W, + DICE_BAR_H); + draw_box_simple(canvas, 0x00, DICE_BAR_X + 1, DICE_BAR_Y + 1, + DICE_BAR_W - 2, DICE_BAR_H - 2); + uint16_t fill = + (uint16_t)(((uint32_t)(DICE_BAR_W - 2) * hold_permil) / 1000); + if (fill > 0) { + draw_box_simple(canvas, 0xFF, DICE_BAR_X + 1, DICE_BAR_Y + 1, fill, + DICE_BAR_H - 2); + } + } + + display_refresh(); +} + +bool dice_input_collect(char *rolls, uint32_t target) { + uint32_t count = 0; + uint8_t position = 0; + bool ret = false; + bool redraw = true; + uint16_t last_bar_permil = 0; + char status[48]; + static CONFIDENTIAL uint8_t msg_tiny_buf[MSG_TINY_BFR_SZ]; + +#if DEBUG_LINK + _Static_assert(sizeof(DebugLinkDecision) <= MSG_TINY_BFR_SZ, + "DebugLinkDecision must fit the tiny message buffer"); +#endif + + if (target > DICE_MAX_ROLLS) { + return false; + } + + reset_msg_stack = false; + + dice_accept = false; + dice_pressed = false; + dice_committed = false; + dice_press_start = 0; + dice_release_time = 0; + dice_have_release = false; + dice_short_events = 0; + dice_hold_events = 0; + + call_leaving_handler(); + + snprintf(status, sizeof(status), _("PRESS next HOLD ok")); + +#ifndef EMULATOR + keepkey_button_set_on_press_handler(&dice_on_press, NULL); + keepkey_button_set_on_release_handler(&dice_on_release, NULL); +#endif + + ButtonRequest br; + memset(&br, 0, sizeof(br)); + br.has_code = true; + br.code = ButtonRequestType_ButtonRequest_DiceRoll; + msg_write(MessageType_MessageType_ButtonRequest, &br); + + while (count < target) { + bool pressed; + uint32_t held = 0; + uint8_t shorts = 0; + uint8_t holds; + + /* One critical section performs the whole read-classify-drain step, so + * the in-flight hold below cannot also be classified by the release ISR + * (and vice versa): whoever gets there first sets dice_committed. */ +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + { + uint32_t now = getSysTime(); + pressed = dice_pressed; + if (pressed) { + held = now - dice_press_start; + if (!dice_committed && held >= DICE_HOLD_MS) { + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } + } + /* Queued short presses stay queued until a debounce window has passed + * since the release that produced them, giving dice_on_press the + * chance to retract a bounce-generated one before it is acted on. + * Deliberately NOT conditioned on the button being up: a retraction + * can only happen inside that window, so once it closes the count is + * final. Waiting for the button to be released instead would let a + * tap-then-hold commit the digit the tap was meant to move off of. */ + if (dice_have_release && now - dice_release_time >= DICE_DEBOUNCE_MS) { + shorts = dice_short_events; + dice_short_events = 0; + } + holds = dice_hold_events; + dice_hold_events = 0; + } +#ifndef EMULATOR + svc_enable_interrupts(); +#endif + + uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); + switch (tiny_msg) { + case MessageType_MessageType_ButtonAck: + dice_accept = true; /* arms the button ISRs and debug injection */ + break; + + case MessageType_MessageType_Cancel: + case MessageType_MessageType_Initialize: + if (tiny_msg == MessageType_MessageType_Initialize) { + reset_msg_stack = true; + } + goto dice_exit; + +#if DEBUG_LINK + case MessageType_MessageType_DebugLinkDecision: { + const DebugLinkDecision *dld = (const DebugLinkDecision *)msg_tiny_buf; + if (dice_accept && dld->has_input) { + for (const char *c = dld->input; *c != '\0' && count < target; c++) { + if (*c >= '1' && *c <= '6') { + rolls[count++] = *c; + snprintf(status, sizeof(status), _("Entered %c (%lu)"), *c, + (unsigned long)count); + } else if (*c == 'u' && count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } + } + redraw = true; + } + break; + } + + case MessageType_MessageType_DebugLinkGetState: + call_msg_debug_link_get_state_handler( + (DebugLinkGetState *)msg_tiny_buf); + break; +#endif + + default: + break; + } + + if (shorts > 0) { + position = (uint8_t)((position + shorts) % DICE_POSITIONS); + redraw = true; + } + + /* Commits arrive either from the in-flight check above or from a release + * that completed inside the poll gap; both funnel through here, and + * dice_committed guarantees at most one per press. */ + while (holds-- > 0 && count < target) { + if (position < DICE_UNDO_POS) { + rolls[count++] = (char)('1' + position); + snprintf(status, sizeof(status), _("Entered %c (%lu)"), + (char)('1' + position), (unsigned long)count); + } else if (count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } else { + snprintf(status, sizeof(status), _("Nothing to undo")); + } + redraw = true; + } + + uint16_t bar_permil = 0; + if (pressed && held < DICE_HOLD_MS) { + bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + } else if (pressed) { + bar_permil = 1000; /* held past the threshold: keep the bar full */ + } + + /* Quantize the bar so idle passes stay refresh-free. */ + bar_permil = (uint16_t)(bar_permil - (bar_permil % 50)); + if (redraw || bar_permil != last_bar_permil) { + dice_draw_screen(count, target, position, status, bar_permil); + last_bar_permil = bar_permil; + redraw = false; + } + + animate(); + display_refresh(); + } + + ret = true; + +dice_exit: + dice_accept = false; +#ifndef EMULATOR + keepkey_button_set_on_press_handler(NULL, NULL); + keepkey_button_set_on_release_handler(NULL, NULL); +#endif + memzero(status, sizeof(status)); + memzero(msg_tiny_buf, sizeof(msg_tiny_buf)); + return ret; +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index c7a964f37..34c9e82cb 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -647,7 +647,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/fsm_msg_debug.h b/lib/firmware/fsm_msg_debug.h index 3a1635c9d..8cd7e564c 100644 --- a/lib/firmware/fsm_msg_debug.h +++ b/lib/firmware/fsm_msg_debug.h @@ -17,6 +17,9 @@ void fsm_msgDebugLinkGetState(DebugLinkGetState* msg) { resp->has_reset_word = true; strlcpy(resp->reset_word, reset_get_word(), sizeof(resp->reset_word)); + resp->dice_digest.size = reset_get_dice_digest(resp->dice_digest.bytes); + resp->has_dice_digest = resp->dice_digest.size > 0; + if (storage_hasMnemonic()) { resp->has_mnemonic = true; strlcpy(resp->mnemonic, storage_getMnemonic(), sizeof(resp->mnemonic)); diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 574930f6c..c2f4ac2ad 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,6 +44,18 @@ static bool awaiting_entropy = false; static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static bool no_backup; +/* 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]; @@ -53,7 +66,17 @@ char CONFIDENTIAL mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; 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) { + 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, @@ -92,6 +115,47 @@ void reset_init(bool display_random, uint32_t _strength, random_buffer(int_entropy, 32); + /* Dice must fold in BEFORE the entropy display and EntropyRequest below: + * the value the user (and DebugLink) sees is then the post-mix commitment, + * and the host contribution arrives strictly after it. */ + 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; + } + + dice_mix(int_entropy, dice_rolls, rolls_needed); + memzero(dice_rolls, sizeof(dice_rolls)); + } + if (display_random) { static char CONFIDENTIAL ent_str[4][17]; data2hex(int_entropy, 8, ent_str[0]); @@ -270,6 +334,9 @@ 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(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); @@ -286,4 +353,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/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 141ec6039..a63fa114e 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,5 +1,6 @@ set(sources app_confirm.cpp + dice.cpp recovery.cpp signing.cpp storage.cpp diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp new file mode 100644 index 000000000..93654062e --- /dev/null +++ b/unittests/firmware/dice.cpp @@ -0,0 +1,64 @@ +extern "C" { +#include "keepkey/firmware/dice_input.h" +} + +#include "gtest/gtest.h" + +#include +#include + +static std::string hexlify(const uint8_t *bytes, size_t len) { + static const char *alph = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += alph[bytes[i] >> 4]; + out += alph[bytes[i] & 0xF]; + } + return out; +} + +TEST(Dice, RollsForStrength) { + // d6 = 2.585 bits/roll; Coldcard-convention targets. + EXPECT_EQ(dice_rolls_for_strength(128), 50u); + EXPECT_EQ(dice_rolls_for_strength(192), 75u); + EXPECT_EQ(dice_rolls_for_strength(256), 99u); +} + +TEST(Dice, MixZeroEntropyVector) { + // SHA256(0x00*32 || "123456") + uint8_t entropy[32]; + memset(entropy, 0, sizeof(entropy)); + dice_mix(entropy, "123456", 6); + EXPECT_EQ(hexlify(entropy, 32), + "16ba88244e0230b0fc84868b703a0e32c344be1b0284f2e67e59715f123748d6"); +} + +TEST(Dice, MixNonZeroEntropyVector) { + // SHA256(0x00..0x1f || "654321165243") + uint8_t entropy[32]; + for (int i = 0; i < 32; i++) entropy[i] = (uint8_t)i; + dice_mix(entropy, "654321165243", 12); + EXPECT_EQ(hexlify(entropy, 32), + "d1ab5a0b7f106313b6ba44d6863c5d1b90397d9e4a0f87a0a6baa25bad00ae97"); +} + +TEST(Dice, MixDependsOnRolls) { + uint8_t a[32], b[32]; + memset(a, 0xAB, sizeof(a)); + memset(b, 0xAB, sizeof(b)); + dice_mix(a, "111111", 6); + dice_mix(b, "111112", 6); + EXPECT_NE(0, memcmp(a, b, 32)); +} + +TEST(Dice, MixUsesExactCount) { + // Only `count` bytes of the roll buffer may contribute. + uint8_t a[32], b[32]; + memset(a, 0, sizeof(a)); + memset(b, 0, sizeof(b)); + const char rolls_a[8] = {'1', '2', '3', '4', '5', '6', '1', '2'}; + const char rolls_b[8] = {'1', '2', '3', '4', '5', '6', '6', '5'}; + dice_mix(a, rolls_a, 6); + dice_mix(b, rolls_b, 6); + EXPECT_EQ(0, memcmp(a, b, 32)); +}