From b2a5041da0c57611e1deeb5296d1d00197f1ecae Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 15:10:17 -0300 Subject: [PATCH 1/6] feat(reset): on-device dice-roll entropy in the ResetDevice flow ResetDevice(dice_entropy=true) collects dice rolls on the device with the single button before the internal entropy is displayed or leaves the device: short press advances a 1-6/undo selector, an 800ms hold commits (distinct from the 1200ms confirm hold on purpose -- 99 rolls at 1200ms is a slog), with a 30ms debounce window on the raw EXTI edges. Roll targets follow the Coldcard convention: 50/75/99 for 128/192/256-bit seeds. The mix happens at the only safe seam in reset_init -- after random_buffer() and before the display_random screen and EntropyRequest -- as int_entropy = SHA256(rng32 || rolls). The displayed Internal Entropy commitment is therefore the post-mix value and sha256(displayed || external) still reproduces the mnemonic, so the existing host verification recipe covers the dice path unchanged. A completion screen shows the roll count and the first 8 bytes of SHA256(rolls) so the user can confirm the device received exactly what was rolled. The entry screen renders in x>=128 only: it runs with display_constant_power(true) (PIN-matrix precedent -- OLED supply current correlates with lit pixels and rolls are seed material), and the display driver fills x<128 with the inverse of the right half. Under DEBUG_LINK, DebugLinkDecision.input injects synthetic rolls ('1'-'6', 'u' = undo; 40-char chunks so the decoded struct fits MSG_TINY_BFR_SZ, statically asserted) and DebugLinkState.dice_digest exposes SHA256 of the collected string. Emulator coverage: test_msg_resetdevice.py::test_reset_device_dice (announcement, chunked injection with undo churn, digest parity against a host-side simulation, end-to-end mnemonic check); gtest Dice.* covers the mix construction with fixed vectors. OLED evidence in docs/security/evidence/dice-entropy-reset (Gate-3 on-device pass of the physical press path still owed). Pins: device-protocol 342174d (dice fields), python-keepkey 58d4e02 (bindings + debuglink injection + test). --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- .../01-dice-screen-initial.png | Bin 0 -> 751 bytes .../02-after-three-rolls.png | Bin 0 -> 710 bytes .../dice-entropy-reset/03-after-undo.png | Bin 0 -> 693 bytes .../dice-entropy-reset/04-digest-confirm.png | Bin 0 -> 544 bytes .../05-postmix-internal-entropy.png | Bin 0 -> 711 bytes .../06-backup-explainer.png | Bin 0 -> 897 bytes .../evidence/dice-entropy-reset/README.md | 37 ++ include/keepkey/firmware/dice_input.h | 49 +++ include/keepkey/firmware/reset.h | 4 +- include/keepkey/transport/messages.options | 5 + lib/firmware/CMakeLists.txt | 1 + lib/firmware/dice_input.c | 328 ++++++++++++++++++ lib/firmware/fsm_msg_common.h | 3 +- lib/firmware/fsm_msg_debug.h | 3 + lib/firmware/reset.c | 55 ++- scripts/emulator/capture-dice-flow.py | 101 ++++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/dice.cpp | 64 ++++ 20 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png create mode 100644 docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png create mode 100644 docs/security/evidence/dice-entropy-reset/03-after-undo.png create mode 100644 docs/security/evidence/dice-entropy-reset/04-digest-confirm.png create mode 100644 docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png create mode 100644 docs/security/evidence/dice-entropy-reset/06-backup-explainer.png create mode 100644 docs/security/evidence/dice-entropy-reset/README.md create mode 100644 include/keepkey/firmware/dice_input.h create mode 100644 lib/firmware/dice_input.c create mode 100644 scripts/emulator/capture-dice-flow.py create mode 100644 unittests/firmware/dice.cpp diff --git a/deps/device-protocol b/deps/device-protocol index 674777f6d..342174d93 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 674777f6d4dd16e2b8c4c2df10608976375ee879 +Subproject commit 342174d93c32209fa7ea3e0553f214c5214a4d59 diff --git a/deps/python-keepkey b/deps/python-keepkey index 1f2eecd22..58d4e02cb 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 1f2eecd227f73996b1d60af3af1b2972430527ad +Subproject commit 58d4e02cb17da2bedf233506bdffdd9b86830209 diff --git a/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png b/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..a6ae648c1ecd9f08f76252c5cf6b5b70dd57a730 GIT binary patch literal 751 zcmV&eSvn5spY&DQ#}FyJZ)$E5CjAtMajYV>P}k0DyCV zKj>ltXDjVR0A3%sC;;WgYOewC`oKj2C^uI7TmX43wgJd1zYTySD{h?g0N|YH%K`wJ zg9`v8p3V^fnu7}fB%aP80D5zK0P3o^7J%N|9)P+kt_A?*Jxlo^J1N0tIXpfN0Lpuo z%3Oq8=o@c=y=_Sl0U!VbfB+BxJ_cX}Cj;0d2mm#J1iv~&!Hlq0tj(r^+vHdMk!U^; z)Br$MDVPz)inXaFsoP}krQwYQ0{}CIs$a-6vwFgd0DxLBZ|D;MO{E(GX#7X)2t3hs z0Sv341uz=XLvlF)j`O<0X~TXE91yrFfJX5KF*)&R4QuH(HQ-wS8gGF+0%)j>0JQY4 z2SBawaJ?N3cSys+O#rmq7Zo#&9@4O|9RQh-&uM4HYXqC(HH~(+01yBIz<&pz`}ZYr z-|!o*Jb+3>o@Nt(?h{Pjb{nqcZwc|-o@NKY%S-8N$rJ!P_%8rnUP@m}rU2N%#{r<{ z87yiOSyl!B59l#y#ekj{TcVwkWn}>HfGz_S1B5DDpvs6kHS(M;0G4)hK*gXYx67Fglz?_sa`5fk_Y_^;9=eP3A`n}boO37m;x}9 z0i3CR4Z!$x28;oWGr+Dgd0QZp=X+9*U{k{Wn5+S6l@quiF(r0UO;_Fm>=5janHsRS zbyw$%761Z300;m9AOHk_01yBIKmZ5;0pOMZ-d5hm@^_N|$XSOq%~`_}0F;pI`3Rny zP&pe&cu4SU0H~7zBy~*m7yzk0S>%H{10X+<;hWns0P1^Z3#4aez&wD*)qrG9L`(0V h?mz4;aIpP%06*jxs!&s_{09I4002ovPDHLkV1lhuF`NJZ literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png b/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png new file mode 100644 index 0000000000000000000000000000000000000000..8cb183fd90d8e26379b4486d8f77debe0c84bd44 GIT binary patch literal 710 zcmV;%0y+JOP)CrL)=`#?m{6@D4!$6rNYEkHeeV4b3pGw zk&#m#kp|2F@clq#0EkjMU;=>e2Py+Vl-dC+0E7g9Vp$A;V<7W#0LF?TE&yPxCYDhE znu7sAcLifPRdfQ-91H-80*vKU(F;J&Y^zM`p9?_GY^zM`pA7(VJ6ZW6h6yXd+*JT` zJ6YvMS(Fl%RKo!FmT?6RzyUY_2jBok02sq!0GkXBAO|2e>|jP%FV<(ZL$SzL`Vlo9 zh8zIcvJPg1@nU^yOZ-{n-c#kZ4GjQi9LX<-lXoo{Zvp^v!FfkL0IHoH2q6D~bPj+u z-4{SJ3t9lJ5x?|xGR84@&$~rhAF^wMgb%YQjvUaf?${e9KtXF5tMWt5n%K3 zHvj+}jziYGehS3w4WMIoZ5aSjm|eRJz~P96@}#h-AwpyNDv(}yo)k7U1Zd1$1$rY_ zb+4Q5z*W5*z@#!5{&;Z!4!{9800-az9DoCG01m(bIDlsYIJ_Lrs2%v{a_Yl9i+fC4 z%LD*PQoeQw;$PKo*PjjO<5`R@S!0LAyJP_QJ{s{jB107*qoM6N<$f*&9sn*aa+ literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/03-after-undo.png b/docs/security/evidence/dice-entropy-reset/03-after-undo.png new file mode 100644 index 0000000000000000000000000000000000000000..c575b32aaae356154abffae48b5727c233ca66a4 GIT binary patch literal 693 zcmV;m0!safP)%|Qc*Z4g$}4g%0Sy8<9jj1vLqom~NtC&sA&Ah)wLc5Y*&JbvyH zjZpxQ+gS>C#w8*GxeNnXTgD@B01m(bH~B6^2Czur0CE5#Lj_}Dy;z@Bg<_Fc z`jKc32yy@*%M^@-@nU^yN!TrN@2TOn1q}c_j^vj^=Uq#}n*e}Z(C_F7fU4310aSh< z?g&bv`vPcYK?|T|v4`Y#01AWG8BRMc=fDPmy8@^rZxB-Oal<` zrHD)q%y_`HFbzPeDQl@30nn^1@QEbVA#14`0VE*908*_1Fp=1E))q*$2EasO&xuff!nRpow?_b^tS&s_fZ{+**4ORh0M?cnF*pDR z-~b$e18@KizyUY_2jBo4fCKnF0GpT18Gk4INlgStDTVW;#bpA36p}e!OD87kFb&RB zUJd|t7yutM$>MkmfB@%<3_#rD3V@T&R{>DpceX%y=L+Zt@VXoj^iu%;N(p)W#;3qw b`E>w)!z8Lu;UBmm00000NkvXXu0mjf$uJVO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ed476b39abad3519641fe02c4155ee288752af3c GIT binary patch literal 544 zcmV+*0^j|KP)g-j__2oqvs1teKy*of zJ-94@M$81*Yzh1|0~ow9U{eK*>(w4G6kwk{U{(yhJO%m-JZ%1G4@UFPL-zR>MNt$* zQAD_Z;1NA^< zv^eZr6+kzK!*P_ughARB=aU484jKx8=6y79rvR&w+q>!0OxtMx!tCB)a&u+hJ-P6SyBANU#b?-x%RT=_WVE56!yDbay()mY zTiy*``|a3|U(8i^&=&#B9T}@kTs!^2Ys}vZVDcbFk(hBI=(mKKLfY8%xoap8GeGQ^ z0pw?fmc0e&32jQbma1CSVI~KQSpqi9l!>G|t#b+>3l8avEcuoc#T5OMsu50j{5E4~QQz ixOx`aC!PYuNBjWx^;6{_ferZp0000Xbr1Hjx2SpTPE zAPJ~$&rN`Nod`()Ic*7e{kLRC>Enk;`zvooZD+7Fai!xfD4%TvC<9v~%`yAnk(>aNOFwFlznap6563I~xcP9wPte!R-Dg5{X12kw`eSE1jqo z=KY;a=gaT2O;WV5F1Xguv1ozQU}ico@^}mZ$IMYUc~|4BH5Rk*83)gn()( zbTA$`1uOwtn%p&eyC@6nDG^$WF6JOvVm@B=5QBtOU4sH*?~002ovPDHLkV1l#9MCt$l literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png b/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png new file mode 100644 index 0000000000000000000000000000000000000000..74ce8c14ce969c1a1bd07ca1df77ba6838140917 GIT binary patch literal 897 zcmV-{1AhF8P)Nkl|CN2;~2Nnw#8hVR+TY(Q0BRSyx*?I;9nqXklSt;VWRl)1Ev7=5I5={A~u9O9t9C z!$0-E_G^7?8daN&fXJcs!mL`KJbt_(ctg>*y01_ssszkm{lBoneji#(0w%1h`Y@qKusUSYhHAgv7vKBVgJd;7*PId6;{0 zJONkj0e1rjJz4_(DbNN20W4DomUlb_BB6oa`w2)dJ^~JS+M~%>1bm%bCXzAij%u=alX~6-T!mypPVM=M_jybWAK*mAb`a#9uK+5 zokL-+7eCM&XeMT)55$aOQ|a@s(t2OD!l*9|*ebR}hSlFQvCQ&A<`|pFoHN`JT`$CP z0<0XT`lVG*8mVnV<8cDWuo^Z9R;k`@BS5-D;3NSsy&8C11dws|90A5?G6~`Uia^|C zDrX3A9_}Q-9BJ>X2D1cg1i%Xq2@VMjA8oo$*&drOeT}9wn_E$Hjl}*!2HKo08;83H z_&ct-&n$1R#x=?HO3@b<78V|XK4bDI1OtAtsVD;i`a8 z{xzAN#c8?-y(V#&WUMQ>!VW4)s0cMDwz>5@0x+{KGkK{8Vy`h)r|ex0D@l`!&nG}8 z#HamQk>OaxW7peHw+Vns(j@v1MnFF6IAI9^t{2qD1i+@lsAUP5ml^9U!h&=a8P;EI z`k}Ws5Rus)(xMI5yoMi1tGX~_@UH~~)dz7jF$wybrJAl{=`$8N<0iT0JOYq&TjcE}AUI%- zJPd&m0VpFwkcmu8Y!Hw{0Ht1=ncXH}Joi0*#{`5`#IOc10@_O*YeOd>`$C5|GM@*D z!m5j(3&D9T4o!?k2cp>x0tzN00mLB!UWY^8U9kte3gbOLmFK?cn5dN&I40yQd>j7& XT5qzjlOj}X00000NkvXXu0mjfut}4q literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/README.md b/docs/security/evidence/dice-entropy-reset/README.md new file mode 100644 index 000000000..88dbed9f5 --- /dev/null +++ b/docs/security/evidence/dice-entropy-reset/README.md @@ -0,0 +1,37 @@ +# On-device dice entropy in the ResetDevice flow + +Emulator captures of `ResetDevice(dice_entropy=true, display_random=true, +strength=256)` driven by `scripts/emulator/capture-dice-flow.py` via +DebugLinkDecision.input injection. + +The screen runs with `display_constant_power(true)` (PIN-matrix precedent: +dice rolls are seed material, and OLED supply current correlates with lit +pixels). The display driver fills x<128 with the inverse of x>=128, which is +why the left half of every capture shows a readable inverse copy — the user +faces the right half. + +- `01-dice-screen-initial.png` — entry screen: `ROLL 1/99` counter, seven + selector cells (digits 1–6 + `<` undo), active cell rendered inverse-video + (white box, black glyph), `PRESS next HOLD ok` hint. Inactive digits are + legible on hardware (white on 0x22 gray) but collapse to solid white in the + 1bpp DebugLink threshold; the inverse half documents them. +- `02-after-three-rolls.png` — after injecting `123`: counter `ROLL 4/99`, + status `Entered 3 (3)`. +- `03-after-undo.png` — after injecting `u`: counter back to `ROLL 3/99`, + status `Removed #3`. +- `04-digest-confirm.png` — completion screen: `99 rolls recorded. Digest: + 6CFC611198F53A73` = the first 8 bytes of SHA-256 of the ASCII roll string, + independently recomputed host-side from the injected chunks (append/undo + rules simulated) and matching exactly. +- `05-postmix-internal-entropy.png` — the standard Internal Entropy screen + now shows the POST-dice-mix value: the displayed commitment is + `SHA256(rng32 || rolls)`, produced before EntropyRequest is sent, so + `sha256(displayed || external)` still reproduces the mnemonic (asserted by + `test_msg_resetdevice.py::test_reset_device_dice`). +- `06-backup-explainer.png` — flow continues into the unchanged backup path. + +Emulator captures do not satisfy Gate-3 on their own: an on-device pass of +the entry screen (short-press advance, 800 ms hold commit, undo, digest +match against physically entered rolls) is still owed before release. The +hardware press/release/debounce path (`dice_on_press`/`dice_on_release`) +does not execute in the emulator at all. diff --git a/include/keepkey/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..de1fd2afc --- /dev/null +++ b/lib/firmware/dice_input.c @@ -0,0 +1,328 @@ +/* + * 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; + +/* Written from the button ISR, read from the UI loop under masked IRQs. */ +static volatile bool dice_pressed; +static volatile bool dice_hold_fired; +static volatile uint32_t dice_press_start; +static volatile uint32_t dice_release_time; +static volatile uint8_t dice_short_events; + +#ifndef EMULATOR +static void dice_on_press(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (dice_pressed) { + return; /* duplicate edge */ + } + dice_pressed = true; + if (now - dice_release_time >= DICE_DEBOUNCE_MS) { + dice_press_start = now; /* genuine new press */ + dice_hold_fired = false; + } /* else: bounce continuation — keep the original press_start */ +} + +static void dice_on_release(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (!dice_pressed) { + return; + } + dice_pressed = false; + dice_release_time = now; + uint32_t held = now - dice_press_start; + if (held >= DICE_DEBOUNCE_MS && held < DICE_HOLD_MS && !dice_hold_fired && + 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}; + snprintf(line, sizeof(line), "ROLL %lu/%lu", (unsigned long)(count + 1), + (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) { + draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, cx + 4, + 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 acked = false; + 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_pressed = false; + dice_hold_fired = false; + dice_press_start = 0; + dice_release_time = 0; + dice_short_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 press_start; + uint8_t shorts; + +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + pressed = dice_pressed; + press_start = dice_press_start; + shorts = dice_short_events; + dice_short_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: + acked = true; + 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 (acked && 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 (acked && shorts > 0) { + position = (uint8_t)((position + shorts) % DICE_POSITIONS); + redraw = true; + } + + uint16_t bar_permil = 0; + if (acked && pressed) { + uint32_t held = getSysTime() - press_start; + if (held >= DICE_HOLD_MS && !dice_hold_fired) { +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + dice_hold_fired = true; +#ifndef EMULATOR + svc_enable_interrupts(); +#endif + 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; + } else if (held < DICE_HOLD_MS) { + bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + } + } + + /* 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: +#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..4dc1392f6 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,11 @@ 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, not the input itself. */ +static uint8_t dice_digest[32]; +static bool 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 +59,8 @@ 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) { if (_strength != 128 && _strength != 192 && _strength != 256) { fsm_sendFailure( FailureType_Failure_SyntaxError, @@ -92,6 +99,44 @@ 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. */ + has_dice_digest = false; + if (dice_entropy) { + static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; + 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; + + static char digest_hex[17]; + data2hex(dice_digest, 8, digest_hex); + if (!confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), + _("%lu rolls recorded.\nDigest: %s"), + (unsigned long)rolls_needed, digest_hex)) { + memzero(dice_rolls, sizeof(dice_rolls)); + memzero(int_entropy, sizeof(int_entropy)); + has_dice_digest = false; + 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]); @@ -286,4 +331,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)); +} From a22220b7e0d4b3966bfcb16cdd608d2eef0841a8 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 15:46:34 -0300 Subject: [PATCH 2/6] fix(reset): disarm EntropyAck on abort and harden the dice button path Adversarial review of the dice change found a host-controllable seed, reproduced end-to-end on the emulator before the fix: ResetDevice #1 -> EntropyRequest (never acked; awaiting_entropy stays set, and nothing in the firmware ever clears it on an abort -- there is no reset abort, and CHECK_NOT_INITIALIZED still admits ResetDevice while one is mid-flight) ResetDevice #2 with dice_entropy -> host Cancel -> the new dice abort path memzeroes int_entropy and returns EntropyAck -> reset_entropy runs against 32 zero bytes, so the seed is sha256(0*32 || host_bytes) The repro produced exactly the host-predicted 24 words. The zeroing was introduced by the dice commit; before it, aborts left fresh random_buffer output in int_entropy, which masked the stale flag. reset_init now disarms awaiting_entropy on entry, so every abort -- dice, PIN mismatch, strength, no_backup -- leaves the device unable to derive a seed from host bytes alone. Covered by test_msg_resetdevice.py::test_reset_reentry_disarms_entropy_ack, which fails against the pre-fix build. Button path (dice_input.c), all found by the same review: - Release-edge bounce double-counted short presses, because press_start was retained across a bounce pair while every release re-qualified as a new short. Worse, chatter during a hold advanced the selector under the user, committing a digit they never rolled. Bounce releases are now retracted by the following press, and the UI loop may not consume queued shorts until the line has been quiet for a debounce window. - Classification was split between the release ISR (release-time duration) and the UI loop (poll-time duration against a stale snapshot), so one press could register as both an advance and a commit. A single critical section now reads, classifies and drains in one step, and dice_committed makes each press cycle classify once. - A hold that completed inside the poll gap was silently discarded: the release ISR now commits it. - Presses begun before the host's ButtonAck accrued hold time and could commit on ack. The ISRs are gated on dice_accept, mirroring confirm_sm's button_request_acked check at the press edge. Also: dice_digest is CONFIDENTIAL and cleared when the reset that produced it ends (it was sticky for the rest of the boot and kept being served over DebugLink); digest_hex is zeroed after the confirm screen; the roll counter no longer renders an impossible 'ROLL 100/99' on the final frame; and pin_font '1' (4px, not 8px) is centered on its own metric. Verified: 6/6 test_msg_resetdevice.py against kkemu (including the new regression test), 5/5 Dice gtests, evidence recaptured. --- deps/python-keepkey | 2 +- .../01-dice-screen-initial.png | Bin 751 -> 750 bytes .../02-after-three-rolls.png | Bin 710 -> 709 bytes .../dice-entropy-reset/03-after-undo.png | Bin 693 -> 692 bytes .../05-postmix-internal-entropy.png | Bin 711 -> 719 bytes lib/firmware/dice_input.c | 150 ++++++++++++------ lib/firmware/reset.c | 38 ++++- 7 files changed, 134 insertions(+), 56 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 58d4e02cb..ee9369f12 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 58d4e02cb17da2bedf233506bdffdd9b86830209 +Subproject commit ee9369f124bb5f34373e45a19687096e8108a044 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 index a6ae648c1ecd9f08f76252c5cf6b5b70dd57a730..52dbe28bbe2bde530bdd74eb3da3c3839d1c6ceb 100644 GIT binary patch delta 726 zcmV;{0xA9P1?~lqB!9I@L_t(|ob8x_l7t`#hJF8+=H^JHky;Zi zE@^eiCS+57>ugtLW4U8BzDxjsbAUZ)B7w1$_9Ot?4=xHod9d1Z0Bk?FC;;WbYF`T= zuf;L|dF7V@kYL4)a~%Mj6K$CQKyz>bfW*@|13+_d0f5BQIe!H}Z*C7jofYQ-(3{%> zP-n&203f|*DL1l_;%t^f^LYS}-m_HZO2{RB<1MhZOmQLr1b_e#00O{a07h^zfJK4; zpa2luRJY^{NPi1C07rP1PanBC;LQPn0w6+cHyz9%c1SIuet`*Ry0d0FA$h9f2#mEr8uDXaVf?aGBf{fHB@?I6bbP1NR8r6hI?+ zgE)EqDGg_&r{sXI0cbxz4M0O}7eKSa-2kZd-Cb`-!|kHWvj8-$FDhmh-K9CiasZ7@ z{q1sVIiA0qB}w^0wJhPR1Ayw2MLwuA z0CJ0TUtFdEQ1_iJK<|uzaR9H&0c1`@OYNWTzw9k=vix@dKj#LjP%e$J&;S4c07*qo IM6N<$f{RWxDgXcg delta 727 zcmV;|0x13N1@8rrB!9L^L_t(|ob8y4lEWYfMfv}qc4u3WE8t@r8FO1)Xp06X|E0A5~7UrVL{*ulpEpywGZY7<#j1^^G}F=)kro)=r9oswl`0Puh= z0~G^=DqEn+h&na$oGt*Cc5^_*peDD=tWqOSf00e*l5C8%|00;m9 z;FbX1R^G<)cas0eS%)>vS;G?ml#uNC2%eizIZzu&cu4SU0H~7zBy~*m7yzk0S>%H{ z10X+<;hWns0P1^Z3#4aez&wD*)qrG9L`(0V?mz4;aIpP%06*jxs!&s_{09I4002ov JPDHLkV1oB%F?;|3 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 index 8cb183fd90d8e26379b4486d8f77debe0c84bd44..cc8f5ca95241640a791c4f4d9e1907974d8fa990 100644 GIT binary patch delta 685 zcmV;e0#g0P1;quBB!7%aL_t(|ob8!`vVPtsc&75-D-drnYW~ zi%VEtCce2;>7Ewg9&0oYEL{aaKn-E&K<`9uEdca z4i|4)^gabZHh&hb#c2XiZ}mC=`R}8P0Ict^08VE?3*f9rk;zj5nB?sYr{k9Az%v35 z1yGs1!5vEaREt8Sx6A>z0jQt94M3&YDS&E+rvXsk@9FnlXi@B<%ew$nvrkRTU38af zh~)q(gZl0=ORsS((rZdsT^xV|Z~%>&t9mwosOokA8GnbW99bUIIi2(o(ChB8UTK=% zvw8_A0VedKvV0Bgj01DwS1_0T7+}z2`-)QauKsFyYcQWCNzXd?TAQj1H69mH$ zU^=}35(cSAKARvIh5)C~4L}4XU3&&--u?stfIV=?%=?Ex%$@)`X4j?x5QW*b+W_o= zSSSw)JAXAqFov%J>4E1#VW)-w#>iEmHFH(BhG|Y*)zbkCDxK-~7YE<~9DoCG01m(b zH~X$ta2>$s<|crsR{{JxCFaL3t^%FqzXSLK$mXh0 Tb!;`W00000NkvXXu0mjfbW|Z? delta 686 zcmV;f0#W_N1;z!CB!7)bL_t(|ob8zbl7k=&MY;c_*=bG4CxPMsg1TX*f)Qf=Q$kBa z++4!$LLpEnpCiSk!o*fKU>E>%K<`13ky9R#2Fw8P{Xk^^h*CRX0)X!aDg!{2+5sy7 zgam+MSqy+0AQ>pmQetjg8@Ky1!Fl?bOO*E41WNM0*vKU(F;J&Y^zM` zp9?_GY^zM`pA7(VJ6ZW6h6yXd+*JT`J6YvMS(Fl%RKo!FmT?6RzyUY_2jBok02sq! z0GkXBAO|2e>|jP%FV<(ZL$SzL`Vlo9h8zIcvJPg1@nU^yOZ-{n-c#kZ4GjQi9LX<- zlXoo{Zvp^v!GC#2Jpihm9ta@+fpiXlHQg6LGYeV(tr5SF+zvn?c%9+2<8lsc5V$LV zO7aGCDCpBYtf$B1fU5vhR)Hr1sB~)qsP(@e0QG#E&%4m0IHcm?ApmOOOBZt+J*47c zJAhy$uPLka8p9^Nri9(a0XP5$&>6X^R|ANu?gx-@sDH`{Dcp2UCu0PHIQ}U|!c6CM zGDtuPXaE2d!eJDE)ma4qD1^ft0MzE=$DEq^7n*ATP@9h*b85nazXU+SAQj2yCJ2Tp zz;s3dBn(oKd~SkZm;xNaFaQyhbR7|3^YS+U0341(*1UcS#Ow{AV|HyB08yA-y9~hL zh=uZ`uz#r`LSycc&YdrVu)1OQ1=zJ-Q+1^M<##C?=^1He86;Mi}K ztFHkN^#*H%Ie>3K>Qw;$PKo*PjjO<5`R@S!0LAyJ UP_QJ{s{jB107*qoM6N<$f_ms500000 diff --git a/docs/security/evidence/dice-entropy-reset/03-after-undo.png b/docs/security/evidence/dice-entropy-reset/03-after-undo.png index c575b32aaae356154abffae48b5727c233ca66a4..ea5a39d1e67decb506b650ae0a98ba68dc13e872 100644 GIT binary patch delta 668 zcmV;N0%QHP1+)c_B!7EJL_t(|ob8!`w!;t%hV%X}?Vh_v{1VDatL>6=#)Kdrveqap z7FsPl1)j=(%JWilOoiAopn3p690PkNB$I)P7Gf|1z~=**0)S;}Faf~l1DOJVWovLP z0ABfJ0C?rE2C%h53^=9p0TjnnM`H+p=AZ$@GAJv}jsegdG=G3t24$t$K>+$>R{-RJ zaUuYHvMT`cz&I5E#CF!k%59F6`_EOW#V7!X?W~27af>8H%7y`~m2nRofCF#<4!{A7 z05FP+0W4BD00khZ#}37!1Si)N0QT^Nr>y~R4k#3Wq{Mn-U^=l~YE9S|7;vV0ZN)x- zo_^AcL+7AH?SEYWWM`qTpF;rZtsV!U@_x9ZDC%wtpcw@%fR;s($z1`M$=e90`z^=7 z7J-`rs043NQ^`NoqR`V*V!&wt>gA^as5EN=s2bc2fLhzq8w^=6u%ASQx4XilfUYG_T;Ij~!8d&jwdtn*?NhvF-6amnzBk=J=(vX!@ ziU0)2FaSw4045S^&N>2;Y5+_m)|_|*WK$Obh_9y|0ol|=0OISZM_}6si3hJtgl(g~ zE{_0^sDGY##0kZTn5eJI#{sOB89q1w2jBo4fCF#<4!{9800-az9DoD(JpfxTTW9>8 z@Rgbfu2Ks3ON(U!0CCA&ugQ&xx=e#Rm6rp+HVl9-n$qTY3xELkiwq!jk2?TvI$s4q zecw3(;hj66AHeHkK+wMe_*Y8E(>MMK49c$q_!R@j6RJ@AGK1v+0000?enHcvWi>o!HW5fZm71fl zxX|jtL*SwO*E}vYMrlONfZ_oF@gC@%kW2S>?*al%m?HB;fL4N~?Z4g$}4g%0Sy8<9j zj1vLqom~NtC&sA&Ah)wLc5Y*&JbvyHjZpxQ+gS>C#w8*GxeNnXTgD@B01m(bH~B6^2Czur0CE5#Lj_}Dy;z@Bg<_Fc`jKc32yy@*%M^@-@nU^yN!TrN@2TOn1q}c_ zj^vj^=Uq#}n|}a+T+r|62!N{60|8WiAnpiCqWc19W4*BMSbF6Y1o zfx7~zBySK?AwI2PJv}A|oCTor6nG+lidqXmP5*uX)ciK*yP#1tsd#t@fZFgy#oR`l zR6HyPKqlmC%2n|i!J>Fgq1D9!H~j1X8Yq06^m48_SKtG0%E?=270Z3?F0HR&D zSqp%K)&(Hib(?hpAbcqbrUz!6a4k#&5b&jlOb^U>z_l<9K&mNgsTu*$tS#_~B-J5n zsTu(!Aj1GstpPBR*mKqvNVNvQL}Jg0TOcj!B7hX@XLP#?>#18{TL{8~XC}h7 zSzotD01~9EEMkmfB@%<3_#rD3V@T&R{>DpceX%y=L+Zt@VXoj^iu%; jN(p)W#;3qw`E>w)!z8Lu;UBmm00000NkvXXu0mjf%PS1M 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 index 4f9f05df20c28a825aafc019449f70ee448aa678..33155c567498204bc31d7affbf0ce5ac2c5c25d2 100644 GIT binary patch delta 695 zcmV;o0!aPG1*nr}l zS)ouUtOD=2+LCMFzBU8yYct@!HUsMaatss!-R-prkmf=t0>Eh{;P=-fJK7k2nT=0t z$+U^G4RM;|Dv-|_0c7BK*lTf6)R z{>u|u-gRo_PHP+u>O&g8W%1SDj_`pz`-nAq9G(cf)B{R~buF zfP3WBC=?2XLZR>mlKb1NsHHj}D0*^Ow*_Jy-2R`Ep7bC|r@lk!eI5bue6&U}%so_@ z#o`RhGA-b;3uQj9%%0yNPc43O0uBvz(oHAv(+H3SPPqgQTVs%;8NEl-gb9chvpSvB z(eT@(J%1WnIs-u`l8{7z-UgEhh=C82cs8ECW}N_D{ElU(A}-Tj@XG|u_JE}Kc>+)p z1kXX-r1cbVh;U2+Fq(pYdJ3c|VD#)m-q#dGT(3|ltd6A2G}v7pGEFj695(>f3O3wB zC;Ndi#%|;PE&(WGTUb2C?&Cil&2(5p4qycMRxqR81}~?8-!lTFz0w{qt7GBs2mN`+ dQ=sq>egH7}-*tI{@z($V002ovPDHLkV1g+pNm&2@ delta 687 zcmV;g0#N(>aNOFwFlzna zp6563I~xcP9wPte!R-Dg5{X12kw`eSE1jqo=KY;a=gaT2O;WV5F1Xguv1ozQU}icc(^-p2Tr5N-MpSh8DbFba`gI)DYppcuwnAf_fbNcpzHNnJ)O$7o zRAAPNz}VIf0V3N75Z5{xzMgh2rY8_U5r_I^(OGPFSEBV%ipiUdy#l*a04UFl0P{Z69uSp>^$WF6JOvVm@B=5Q VBtOU4sH*?~002ovPDHLkV1lXaL|Xs= diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index de1fd2afc..6f04c13e3 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -62,38 +62,63 @@ extern bool reset_msg_stack; -/* Written from the button ISR, read from the UI loop under masked IRQs. */ +/* 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_hold_fired; +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(); - if (dice_pressed) { - return; /* duplicate edge */ + /* 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 (now - dice_release_time >= DICE_DEBOUNCE_MS) { - dice_press_start = now; /* genuine new press */ - dice_hold_fired = false; - } /* else: bounce continuation — keep the original press_start */ + 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_pressed) { + 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_DEBOUNCE_MS && held < DICE_HOLD_MS && !dice_hold_fired && - dice_short_events < 8) { + 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++; } } @@ -128,7 +153,10 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, display_constant_power(true); DrawableParams p = {.color = 0xFF, .x = DICE_LEFT, .y = 0}; - snprintf(line, sizeof(line), "ROLL %lu/%lu", (unsigned long)(count + 1), + /* 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); @@ -142,8 +170,11 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, DICE_CELL_SIZE, DICE_CELL_SIZE); uint8_t ink = active ? 0x00 : 0xFF; if (i < DICE_UNDO_POS) { - draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, cx + 4, - DICE_GRID_Y + 2); + /* 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); @@ -191,11 +222,14 @@ bool dice_input_collect(char *rolls, uint32_t target) { reset_msg_stack = false; + dice_accept = false; dice_pressed = false; - dice_hold_fired = 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(); @@ -214,16 +248,39 @@ bool dice_input_collect(char *rolls, uint32_t target) { while (count < target) { bool pressed; - uint32_t press_start; - uint8_t shorts; + 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 - pressed = dice_pressed; - press_start = dice_press_start; - shorts = dice_short_events; - dice_short_events = 0; + { + 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 the line has been quiet for + * a debounce window, giving dice_on_press the chance to retract a + * bounce-generated one before it is acted on. */ + if (!pressed && 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 @@ -232,6 +289,7 @@ bool dice_input_collect(char *rolls, uint32_t target) { switch (tiny_msg) { case MessageType_MessageType_ButtonAck: acked = true; + dice_accept = true; /* arms the button ISRs */ break; case MessageType_MessageType_Cancel: @@ -270,37 +328,34 @@ bool dice_input_collect(char *rolls, uint32_t target) { break; } - if (acked && shorts > 0) { + if (shorts > 0) { position = (uint8_t)((position + shorts) % DICE_POSITIONS); redraw = true; } - uint16_t bar_permil = 0; - if (acked && pressed) { - uint32_t held = getSysTime() - press_start; - if (held >= DICE_HOLD_MS && !dice_hold_fired) { -#ifndef EMULATOR - svc_disable_interrupts(); -#endif - dice_hold_fired = true; -#ifndef EMULATOR - svc_enable_interrupts(); -#endif - 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; - } else if (held < DICE_HOLD_MS) { - bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + /* 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. */ @@ -318,6 +373,7 @@ bool dice_input_collect(char *rolls, uint32_t target) { 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); diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 4dc1392f6..c2f4ac2ad 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -45,10 +45,17 @@ 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, not the input itself. */ -static uint8_t dice_digest[32]; + * 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]; @@ -61,6 +68,15 @@ void reset_init(bool display_random, uint32_t _strength, 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, @@ -102,9 +118,10 @@ void reset_init(bool display_random, uint32_t _strength, /* 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. */ - has_dice_digest = false; + 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)) { @@ -119,14 +136,16 @@ void reset_init(bool display_random, uint32_t _strength, sha256_Raw((const uint8_t*)dice_rolls, rolls_needed, dice_digest); has_dice_digest = true; - static char digest_hex[17]; data2hex(dice_digest, 8, digest_hex); - if (!confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), - _("%lu rolls recorded.\nDigest: %s"), - (unsigned long)rolls_needed, 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)); - has_dice_digest = false; + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Reset cancelled")); layoutHome(); @@ -315,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)); From dcb8ef364068c285298d0abf0be7b648a35aba0e Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 15:49:23 -0300 Subject: [PATCH 3/6] fix(reset): drop the now-unused acked local in the dice loop The ButtonAck gate moved into the volatile dice_accept flag that the button ISRs read, so the local was set but never read in builds without DEBUG_LINK -- -Werror=unused-but-set-variable failed the device build. The debug-injection branch now gates on dice_accept too, which is the same condition. --- lib/firmware/dice_input.c | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index 6f04c13e3..2da55daab 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -204,7 +204,6 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, bool dice_input_collect(char *rolls, uint32_t target) { uint32_t count = 0; uint8_t position = 0; - bool acked = false; bool ret = false; bool redraw = true; uint16_t last_bar_permil = 0; @@ -288,8 +287,7 @@ bool dice_input_collect(char *rolls, uint32_t target) { uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); switch (tiny_msg) { case MessageType_MessageType_ButtonAck: - acked = true; - dice_accept = true; /* arms the button ISRs */ + dice_accept = true; /* arms the button ISRs and debug injection */ break; case MessageType_MessageType_Cancel: @@ -302,7 +300,7 @@ bool dice_input_collect(char *rolls, uint32_t target) { #if DEBUG_LINK case MessageType_MessageType_DebugLinkDecision: { const DebugLinkDecision *dld = (const DebugLinkDecision *)msg_tiny_buf; - if (acked && dld->has_input) { + 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; From 4695c6fd6632d56bb4c5d3e37ea7eb0de7991523 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 15:53:37 -0300 Subject: [PATCH 4/6] style: clang-format dice_input.c Whitespace only -- comment alignment and two line wraps, per clang-format-20 with the repo .clang-format (the version CI pins). --- lib/firmware/dice_input.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index 2da55daab..31deb7633 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -66,7 +66,7 @@ extern bool reset_msg_stack; * 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_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; @@ -191,7 +191,8 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, 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); + 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); @@ -318,7 +319,8 @@ bool dice_input_collect(char *rolls, uint32_t target) { } case MessageType_MessageType_DebugLinkGetState: - call_msg_debug_link_get_state_handler((DebugLinkGetState *)msg_tiny_buf); + call_msg_debug_link_get_state_handler( + (DebugLinkGetState *)msg_tiny_buf); break; #endif From 7e20a9993e8e3a6860da363729c586a045a2541e Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 15:56:27 -0300 Subject: [PATCH 5/6] fix(dice): apply a queued selector advance before a following hold The drain gate also required the button to be up, so a tap immediately followed by a hold left the advance queued: the hold committed the digit the tap was meant to move off of, and the selector only caught up afterwards. Same silent wrong-digit class as the bounce bug. A retraction can only happen inside the debounce window after a release, so once that window closes the count is final whether or not a new press is already in flight. Dropping the button-up requirement lets the pass that sees the in-flight hold apply the earlier tap first -- the loop body already orders advances before commits. --- lib/firmware/dice_input.c | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index 31deb7633..996ef540b 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -270,11 +270,14 @@ bool dice_input_collect(char *rolls, uint32_t target) { } } } - /* Queued short presses stay queued until the line has been quiet for - * a debounce window, giving dice_on_press the chance to retract a - * bounce-generated one before it is acted on. */ - if (!pressed && dice_have_release && - now - dice_release_time >= DICE_DEBOUNCE_MS) { + /* 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; } From 8a8acdc928b498e272697676e78cd4c6277d065d Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 4 Aug 2026 16:42:34 -0300 Subject: [PATCH 6/6] chore(deps): pin the canonical dice-entropy merges device-protocol -> cf308fd5e (merge of keepkey/device-protocol#119), python-keepkey -> a8e41261c (merge of keepkey/python-keepkey#210). Both previously pointed at the pre-merge branch commits; the old pins remain ancestors of their base branches, so this is a pin move rather than a content change. The pyk merge also brings in the taproot confirmation-sequence commit that branch had and this one did not -- test_msg_signtx_taproot.py and test_msg_resetdevice.py both pass against kkemu built from these pins (11/11), and the ARM full build is unchanged at .text 616,604. --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index 342174d93..cf308fd5e 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 342174d93c32209fa7ea3e0553f214c5214a4d59 +Subproject commit cf308fd5e76f3f2e02ba38347ceccaa382b08505 diff --git a/deps/python-keepkey b/deps/python-keepkey index ee9369f12..a8e41261c 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit ee9369f124bb5f34373e45a19687096e8108a044 +Subproject commit a8e41261cd15e63dec41bdad04309768179740cb