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));
+}