From 54c8b68b1c389a50cef98f66836d81e6c49b1f3d Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 10 Sep 2026 21:17:11 -0700 Subject: [PATCH 1/3] feat(dice): two opt-in verifiable modes, host-selected with on-device consent Port of the 7.15 unit (audit/715-dice-only @ bedb6ee47) to the bitcoin-only product, which the owner named as the primary target for this feature. dice_input.c/.h, dice.cpp, the capture script, the verifier and both docs are byte-identical to the 7.15 unit. reset.c is identical except where the two products already differed: 7.15 shares the mnemonic pager's scratch with its BIP-85 flow through file-scope buffers, and this product has no BIP-85, so show_mnemonic_pages() owns its scratch as function-local statics here, as the backup pager did before it was factored. Nothing else diverges. What the unit does, on both products: - ResetDevice.dice_entropy alone is MIXED: the device shows its 32-byte draw as 24 BIP-39 words BEFORE the rolls are entered, then seed = SHA256d("KK\x01SM" || draw || SHA256("KK\x01D" || rolls)). - dice_entropy + dice_only is ONLY: seed = SHA256(rolls). Coldcard's Dice-Rolls-Only byte for byte. dice_only without dice_entropy is refused. - The device shows a consent screen naming the mode the host asked for; holding proceeds, the only "no" is cancelling the reset. - The host's EntropyAck is consumed and its bytes dropped in both modes, so the wire flow is unchanged. The default no-dice reset is untouched. - Rolls with any face over 30% are refused before a digest is drawn; the digest is shown in full; tools/verify_dice_seed.py recomputes the wallet offline from the roll string (plus the 24 words for MIXED). Pins, as on the 7.15 unit: deps/device-protocol -> 5517c8de3 (feat/dice-only-field-7.14.3): the dice_only field cherry-picked onto this product's current pin 8545cd5b6. Not canonical up/release-protocol, because the twelve commits between carry EIP-712 streaming fields with no .options here and the pb_callback_t gate would fail. Retires when the product advances its pin. deps/python-keepkey -> c0e9bd8 (feat/dice-modes): host tests for both modes. --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- docs/DiceEntropy.md | 86 +++++--- docs/dice-vs-coldcard.md | 84 ++++--- include/keepkey/firmware/dice_input.h | 27 +++ include/keepkey/firmware/reset.h | 2 +- lib/firmware/dice_input.c | 204 +++++++++++------ lib/firmware/fsm_msg_common.h | 3 +- lib/firmware/reset.c | 305 ++++++++++++++++++-------- scripts/emulator/capture-dice-flow.py | 9 +- tools/verify_dice_seed.py | 189 ++++++++++++++++ unittests/firmware/dice.cpp | 72 ++++++ 12 files changed, 763 insertions(+), 222 deletions(-) create mode 100755 tools/verify_dice_seed.py diff --git a/deps/device-protocol b/deps/device-protocol index 8545cd5b6..5517c8de3 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 8545cd5b615f5832374afbf06387a3f28869285e +Subproject commit 5517c8de31cee85663ff6b3a376f1f97bafb9212 diff --git a/deps/python-keepkey b/deps/python-keepkey index 7f538a95f..c0e9bd829 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 7f538a95f00fdea59879ceab1a684762fe408411 +Subproject commit c0e9bd82942b86cf71cfc5afebb8f8ca667deb84 diff --git a/docs/DiceEntropy.md b/docs/DiceEntropy.md index b10369b99..81e11253a 100644 --- a/docs/DiceEntropy.md +++ b/docs/DiceEntropy.md @@ -36,20 +36,16 @@ worth doing — it catches a device that quietly ignores button presses. ## What the digest does not prove -It does not prove the rolls reached the seed. `dice_mix()` is a separate step, -and neither `int_entropy` nor the mixed result is ever displayed. Firmware that -showed a correct digest and then skipped the mix would look identical from the -outside. +On its own it does not prove the rolls reached the seed: a digest of the input +says nothing about what was done with it, and firmware that showed a correct +digest and then ignored the rolls would look identical from outside. That is +why the dice ceremony no longer offers an unverifiable derivation. Both modes +it offers (below) are built so the user can recompute the seed, and the digest +is the commitment that recomputation checks against. -The seed is - -``` -seed = SHA256( SHA256(int_entropy || rolls) || ext_entropy ) -``` - -Of those three inputs the user holds exactly one. `int_entropy` is the device -RNG draw and `ext_entropy` is supplied by the host, so there is no computation -the user can perform that confirms their rolls are in the result. +Before this unit the dice derivation was +`SHA256(SHA256(int_entropy || rolls) || ext_entropy)` — three inputs, of which +the user held one, so no such recomputation existed. An earlier revision displayed `int_entropy` and described it as a verifiable commitment. That was strictly worse, and not for the reason the old text here @@ -92,26 +88,47 @@ cannot today prove their dice reached the seed.** They can prove the rolls were captured, which is worth something and catches a device that drops presses. They cannot prove the rolls were used. -## Closing the gap +## The two opt-in modes Verifiability requires that the derivation contain nothing the user does not -hold. Concretely that means a dice-only mode in which `seed = SHA256(rolls)`, -with the device RNG and the host's `ext_entropy` excluded from the derivation -rather than mixed in, and the full digest shown so the commitment is 256 bits -rather than 64. That is ColdCard's Dice-Rolls-Only, and it is the only shape -that yields an offline check. +hold. The host selects the mode in `ResetDevice` before the ceremony starts, +so the wallet can explain what is coming, and the device shows a consent +screen naming the mode it was asked for — holding proceeds, and the only "no" +is cancelling the reset, which is the right answer to a mode the user did not +choose. Two modes, both verifiable: + +**MIXED** (`dice_entropy` alone). The device first shows its own 32-byte RNG draw +as 24 BIP-39 words, which the user copies down, and only then collects the +rolls. Because the draw is committed before the device has seen a roll, it +cannot be chosen to steer the result. Then + +``` +user = SHA256("KK\x01D" || rolls) +seed = SHA256(SHA256("KK\x01SM" || device_draw || user)) +``` + +Showing the draw is safe here for the mirror-image reason it was unsafe under +`display_random`: the other half is dice the host never sees. + +**DICE ONLY** (`dice_entropy` with `dice_only`). `seed = SHA256(rolls)`, +ColdCard's Dice-Rolls-Only byte for byte. The device draw is discarded, so the wallet rests entirely on the +quality and privacy of the rolls. A biased die, a short sequence, or a +photographed roll sheet is the whole seed. That is why it is an explicit +choice, never a default, and why the device refuses rolls where any face +exceeds 30% of the total before a digest is ever shown. -It carries a real cost, which is why it must be an explicit advanced choice and -never a default: it stakes the wallet entirely on the quality and privacy of the -user's dice. A biased die, a short sequence, or a photographed roll sheet is the -whole seed. The mixed mode is safer for almost everyone and unverifiable; the -dice-only mode is verifiable and less forgiving. Both are defensible; silently -shipping the second as the default would not be. +In both modes the host's `EntropyAck` is still consumed, so the wire flow and +every existing host are unchanged, but its bytes are dropped. The digest is +shown in full. `tools/verify_dice_seed.py` recomputes the wallet offline from +the roll string, plus the 24 device words for MIXED, with no secret from the +device and no network — compare its output with the backup words the device +showed, and the derivation has been checked by code the device did not write. -## Why the current mode has no verifier +## Why the default, no-dice mode has no verifier -There is no host-side verifier for the mixing step as it stands, and adding one -would be a security regression rather than a feature. +Without dice the seed is `SHA256(device_draw || ext_entropy)`. There is no +host-side verifier for that, and adding one would be a security regression +rather than a feature. Any such tool would need the device to disclose seed-derived material for the host to check against — the exact disclosure removed above. A verifier that @@ -120,12 +137,15 @@ from the component whose honesty is in question, and manufactures false assurance. A user who trusts a green checkmark is worse off than one who knows the mix is unverified. -Note the scope of that argument. It says the *mixed* derivation cannot be -verified without an unsafe disclosure. It does not say verification is -impossible — a derivation with no device-held or host-held inputs is verifiable -with no disclosure at all, which is what the dice-only mode above is for. +Note the scope of that argument. It says a derivation with a host-held input +cannot be verified without an unsafe disclosure. It does not say verification +is impossible — that is what the dice modes above are for: DICE ONLY has no +device- or host-held input at all, and MIXED discloses the device half only +where the other half is dice the host never sees. Host entropy stays in the +default mode because it is the one backstop against a device RNG that is +broken but honest, and it is removed exactly where it blocked verification. -For the mixed mode the assurance chain is not a tool. It is: +For the default mode the assurance chain is not a tool. It is: 1. **The digest** proves your rolls were captured. 2. **The published source** proves what the firmware does with them. diff --git a/docs/dice-vs-coldcard.md b/docs/dice-vs-coldcard.md index 0e3b7b179..df4c5b89f 100644 --- a/docs/dice-vs-coldcard.md +++ b/docs/dice-vs-coldcard.md @@ -6,8 +6,9 @@ Comparison as of 2026-09-09, against ColdCard Mk4/Mk5 5.6.2 and Q 1.5.2Q The question this answers is narrow and it is the only one that matters for an advanced user: **can you prove to yourself that the device used your dice?** -Today, on KeepKey: **no.** On ColdCard: **yes, in one of its two modes.** The -gap is not the dice-entry UI, which is comparable. It is the derivation. +Before the verifiable-dice unit: KeepKey **no**, ColdCard **yes**. With it: +both **yes**, in each of two opt-in modes. The gap was never the dice-entry UI, +which is comparable. It was the derivation. ## Side by side @@ -18,13 +19,13 @@ gap is not the dice-entry UI, which is comparable. It is the derivation. | Rolls required for a new seed | no, opt-in via `dice_entropy` | **yes, mandatory** since 5.6.1 / 1.5.1Q | | Roll count for 24 words | 99 | 99 | | Roll count for 12 words | 50 | 50 | -| Bias rejection on rolls | none | rejects any face over 30% frequency | -| Digest shown while rolling | after entry, **first 8 bytes** | live, **full 32 bytes** | +| Bias rejection on rolls | rejects any face over 30% frequency | rejects any face over 30% frequency | +| Digest shown | after entry, **full 32 bytes** | live, **full 32 bytes** | | Digest is `SHA256(rolls)` | yes | yes | -| Host can contribute entropy | **yes — `ResetDevice.external_entropy`, mandatory** | **no such command exists** | -| Device pre-mix entropy disclosed | no | yes, opt-in `View TRNG Words`, 24 BIP-39 words | -| Offline verifier published | no | yes, public-domain, stdlib-only | -| **User can verify the seed came from their rolls** | **no** | **yes** | +| Host can contribute entropy | default (no-dice) mode only; **consumed and dropped** when dice are used | **no such command exists** | +| Device pre-mix entropy disclosed | yes, MIXED dice mode, 24 BIP-39 words shown **before** rolling | yes, opt-in `View TRNG Words`, 24 BIP-39 words | +| Offline verifier published | yes, `tools/verify_dice_seed.py`, stdlib-only | yes, public-domain, stdlib-only | +| **User can verify the seed came from their rolls** | **yes**, in either opt-in dice mode | **yes** | ## Why ColdCard can show device entropy and we could not @@ -51,9 +52,9 @@ By that same rule our roll digest is fine: it hashes the user's own input, and dice fold in before `EntropyRequest`, so user entropy is committed before the host contributes anything. -## The actual gap: the derivation +## The gap this unit closes: the derivation -KeepKey today: +KeepKey's dice ceremony before this unit: ``` seed = SHA256( SHA256(int_entropy || rolls) || ext_entropy ) @@ -105,20 +106,49 @@ that is the failure that actually happened to a shipping vendor. Both are defensible. They protect against opposite threats, which is why ColdCard offers both and labels dice-only as advanced. -## What we should change - -1. **Dice-only derivation**, `seed = SHA256(rolls)`, excluding both `int_entropy` - and `ext_entropy` from the derivation. New `ResetDevice.dice_only = 11` - (fields 1-10 are taken; `dice_entropy` is 10). Requires `dice_entropy`. -2. **Publish an offline verifier** — stdlib-only, no network, taking the roll - string and word count and printing the mnemonic and fingerprint. -3. **Show the full 32-byte digest.** 64 bits already resists grinding, so this - is parity rather than a fix, but it costs one line and removes an argument. -4. **Bias rejection on the roll distribution**, matching ColdCard's 30% rule. - Cheap, and the only guard against a user whose die is visibly loaded. -5. **Never make dice-only the default.** It stakes the entire wallet on the - user's dice and their privacy. It is the advanced option, and the warning - screen has to say so. - -Items 1 and 2 are what turn "trust us" into "check it yourself". Items 3-5 are -polish and guardrails around them. +## What changed + +The host selects the mode before the ceremony starts — `dice_entropy` alone +is MIXED, `dice_entropy` with `dice_only` is DICE ONLY — so a wallet can +explain what is coming: 99 rolls, and for MIXED 24 words to copy down. The +device then shows a consent screen naming the mode it was asked for; holding +proceeds and the only "no" is cancelling the reset, which on a one-button +device is exactly the right answer to a mode the user did not choose. A host +cannot select dice-only silently. + +**MIXED** (`dice_entropy` without `dice_only`): + +``` +user = SHA256("KK\x01D" || rolls) +seed = SHA256(SHA256("KK\x01SM" || device_draw || user)) +``` + +The device shows its 32-byte draw as 24 BIP-39 words *before* the rolls are +entered, so it is committed before the device has seen them and cannot be +chosen to steer the result. Showing it is safe here for exactly the reason it +was unsafe under `display_random`: the other half is dice the host never sees. + +**DICE ONLY**: + +``` +seed = SHA256(rolls) +``` + +Byte-identical to ColdCard's Dice-Rolls-Only. The device draw is discarded. + +In both modes the host's `EntropyAck` is consumed and its bytes dropped, so +the wire flow and every existing host are unchanged; the full 32-byte digest +is shown; rolls with any face over 30% are refused before a digest is ever +drawn; and `tools/verify_dice_seed.py` recomputes the wallet offline from the +roll string (plus the 24 device words, for MIXED) with no secret from the +device and no network. + +The default, no-dice reset is untouched: `SHA256(device_draw || host_entropy)`. +Host entropy stays there because it is the only backstop against a device RNG +that is broken but honest — ColdCard's July 2026 failure, which no on-device +health test catches — and it costs nothing there, since that path was never +verifiable anyway. It is removed exactly where it blocked verification. + +What is still unmatched is hardware: no secure element, so the device draw is +weaker than ColdCard's three-source mix; one button, so 99 rolls is slower; +a smaller screen, so words and the digest page. diff --git a/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h index 3ef80c7aa..565eff274 100644 --- a/include/keepkey/firmware/dice_input.h +++ b/include/keepkey/firmware/dice_input.h @@ -27,6 +27,18 @@ * convention of 50 rolls per 128-bit seed and 99 per 256-bit. */ #define DICE_MAX_ROLLS 99 +/// How a dice ceremony derives the seed. The host selects the mode in +/// ResetDevice (dice_entropy alone = MIXED, with dice_only = ONLY) and the +/// device shows a consent screen naming it before anything happens. Both +/// opt-in modes are verifiable offline: nothing enters the derivation that +/// the user does not hold, and the host's EntropyAck bytes are consumed and +/// dropped. +typedef enum { + DICE_MODE_NONE = 0, /* no dice: the legacy device+host derivation */ + DICE_MODE_MIXED, /* device draw (shown as 24 words) + rolls */ + DICE_MODE_ONLY, /* rolls alone; the device draw is discarded */ +} DiceMode; + /// Number of rolls required for a given seed strength (128/192/256). uint32_t dice_rolls_for_strength(uint32_t strength_bits); @@ -42,6 +54,21 @@ uint32_t dice_rolls_for_strength(uint32_t strength_bits); /// host cancelled (Cancel/Initialize). bool dice_input_collect(char *rolls, uint32_t target); +/// Coldcard's rule: any face landing on more than 30% of the rolls is not a +/// fair die (or not a real one). Also true for any non-'1'..'6' byte. +bool dice_rolls_look_biased(const char *rolls, uint32_t count); + +/// ONLY mode: out = SHA256(rolls). Byte-identical to Coldcard's +/// Dice-Rolls-Only derivation, so its published verifier applies unchanged. +void dice_derive_only(const char *rolls, uint32_t count, uint8_t out[32]); + +/// MIXED mode, domain-separated after Coldcard's mixed derivation: +/// user = SHA256("KK\x01D" || rolls) +/// out = SHA256(SHA256("KK\x01SM" || device[32] || user)) +/// \a device and \a out may alias. +void dice_derive_mixed(const uint8_t device[32], const char *rolls, + uint32_t count, uint8_t out[32]); + /// 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); diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index 3170bcca5..a99621357 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -89,7 +89,7 @@ bool setup_commit(SetupKind kind, const char* mnemonic, bool imported); void reset_init(uint32_t _strength, bool passphrase_protection, bool pin_protection, const char* language, const char* label, bool _no_backup, uint32_t _auto_lock_delay_ms, - uint32_t _u2f_counter, bool dice_entropy); + uint32_t _u2f_counter, bool dice_entropy, bool dice_only); 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); diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index 996ef540b..32b4dbde2 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -144,6 +144,56 @@ void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count) { memzero(&ctx, sizeof(ctx)); } +bool dice_rolls_look_biased(const char *rolls, uint32_t count) { + uint32_t face[6] = {0, 0, 0, 0, 0, 0}; + for (uint32_t i = 0; i < count; i++) { + if (rolls[i] < '1' || rolls[i] > '6') { + return true; + } + face[rolls[i] - '1']++; + } + for (uint32_t f = 0; f < 6; f++) { + /* face/count > 0.30, in integers */ + if (face[f] * 10 > count * 3) { + return true; + } + } + return false; +} + +void dice_derive_only(const char *rolls, uint32_t count, uint8_t out[32]) { + sha256_Raw((const uint8_t *)rolls, count, out); +} + +/* Byte arrays, not string literals: "\x01D" would parse as the single byte + * 0x1D, because a C hex escape swallows every hex digit that follows it. */ +static const uint8_t DICE_TAG_USER[4] = {'K', 'K', 0x01, 'D'}; +static const uint8_t DICE_TAG_MIX[5] = {'K', 'K', 0x01, 'S', 'M'}; + +void dice_derive_mixed(const uint8_t device[32], const char *rolls, + uint32_t count, uint8_t out[32]) { + uint8_t user[32]; + uint8_t seed[32]; + SHA256_CTX ctx; + + sha256_Init(&ctx); + sha256_Update(&ctx, DICE_TAG_USER, sizeof(DICE_TAG_USER)); + sha256_Update(&ctx, (const uint8_t *)rolls, count); + sha256_Final(&ctx, user); + + sha256_Init(&ctx); + sha256_Update(&ctx, DICE_TAG_MIX, sizeof(DICE_TAG_MIX)); + sha256_Update(&ctx, device, 32); + sha256_Update(&ctx, user, 32); + sha256_Final(&ctx, seed); + sha256_Raw(seed, 32, seed); + + memcpy(out, seed, 32); + memzero(seed, sizeof(seed)); + memzero(user, sizeof(user)); + 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(); @@ -202,24 +252,10 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, 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; - } - +/* Arm the button ISRs, reset the shared press state, and announce the screen. + * Shared by every dice screen so the mode selector and the roll collector + * cannot drift apart in how they treat the host ack or a stale press. */ +static void dice_session_begin(void) { reset_msg_stack = false; dice_accept = false; @@ -233,8 +269,6 @@ bool dice_input_collect(char *rolls, uint32_t target) { 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); @@ -245,49 +279,97 @@ bool dice_input_collect(char *rolls, uint32_t target) { 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; +static void dice_session_end(void) { + dice_accept = false; +#ifndef EMULATOR + keepkey_button_set_on_press_handler(NULL, NULL); + keepkey_button_set_on_release_handler(NULL, NULL); +#endif +} - /* 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. */ +/* 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. */ +static void dice_poll(bool *pressed, uint32_t *held, uint8_t *shorts, + uint8_t *holds) { + *held = 0; + *shorts = 0; #ifndef EMULATOR - svc_disable_interrupts(); + 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++; - } + { + 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; } + /* 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(); + svc_enable_interrupts(); +#endif +} + +static uint16_t dice_hold_permil(bool pressed, uint32_t held) { + 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. */ + return (uint16_t)(bar_permil - (bar_permil % 50)); +} + +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; + } + + dice_session_begin(); + + snprintf(status, sizeof(status), _("PRESS next HOLD ok")); + + while (count < target) { + bool pressed; + uint32_t held; + uint8_t shorts; + uint8_t holds; + dice_poll(&pressed, &held, &shorts, &holds); + uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); switch (tiny_msg) { case MessageType_MessageType_ButtonAck: @@ -354,15 +436,7 @@ bool dice_input_collect(char *rolls, uint32_t target) { 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)); + uint16_t bar_permil = dice_hold_permil(pressed, held); if (redraw || bar_permil != last_bar_permil) { dice_draw_screen(count, target, position, status, bar_permil); last_bar_permil = bar_permil; @@ -376,11 +450,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); -#endif + dice_session_end(); 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 1ff45e305..d4f60eb5c 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -583,7 +583,8 @@ void fsm_msgResetDevice(ResetDevice* msg) { 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_dice_entropy && msg->dice_entropy); + msg->has_dice_entropy && msg->dice_entropy, + msg->has_dice_only && msg->dice_only); } void fsm_msgEntropyAck(EntropyAck* msg) { diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index da54e5c39..dfd380ecd 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -76,11 +76,20 @@ static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static uint8_t CONFIDENTIAL dice_digest[32]; static bool has_dice_digest = false; +/* Which dice derivation this ceremony uses. Chosen ON THE DEVICE, never by + * the host, so a host can neither select a mode nor force one. Cleared with + * the digest, so an abandoned ceremony cannot leave it armed for the next. */ +static DiceMode dice_mode = DICE_MODE_NONE; + static void dice_digest_clear(void) { memzero(dice_digest, sizeof(dice_digest)); has_dice_digest = false; + dice_mode = DICE_MODE_NONE; } +static bool show_mnemonic_pages(const char* mnemonic, const char* title_base, + ButtonRequestType type); + bool setup_isArmed(void) { return setup.kind != SETUP_NONE; } bool setup_isArmedAs(SetupKind kind) { @@ -198,7 +207,7 @@ bool setup_commit(SetupKind kind, const char* mnemonic, bool imported) { void reset_init(uint32_t _strength, bool passphrase_protection, bool pin_protection, const char* language, const char* label, bool _no_backup, uint32_t _auto_lock_delay_ms, - uint32_t _u2f_counter, bool dice_entropy) { + uint32_t _u2f_counter, bool dice_entropy, bool dice_only) { if (_strength != 128 && _strength != 192 && _strength != 256) { fsm_sendFailure( FailureType_Failure_SyntaxError, @@ -207,6 +216,13 @@ void reset_init(uint32_t _strength, bool passphrase_protection, return; } + if (dice_only && !dice_entropy) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("dice_only requires dice_entropy")); + layoutHome(); + return; + } + /* Nothing below this line writes storage. Everything the host asked for is * staged, and stays staged until reset_entropy() reaches setup_commit(). * Returning early from any of the screens below therefore rolls the whole @@ -278,41 +294,90 @@ void reset_init(uint32_t _strength, bool passphrase_protection, return; } - /* Dice fold in before EntropyRequest, so the host contribution arrives - * strictly after the device has committed to its own. + /* Dice ceremony. The host selects the mode in ResetDevice so a wallet can + * explain what is coming before anything starts; the device then shows a + * consent screen naming the mode it was asked for, so a host cannot pick + * one silently. (It is a confirm, not a selector: on a one-button device a + * confirm() ends only by hold or by the host's Cancel, and "cancel the + * reset" is exactly the right answer to a mode the user did not want.) + * Both modes are verifiable offline: nothing enters the derivation that + * the user does not hold, and the host's EntropyAck bytes are consumed and + * dropped in reset_entropy(). * - * Neither half is displayed. Earlier firmware rendered the device half on - * the OLED under ResetDevice.display_random and called it a verifiable - * commitment; it was not one. The screen was drawn strictly BEFORE - * EntropyRequest, so it disclosed the exact 32 bytes whose complement the - * host itself supplies: anyone who reads the OLED and knows ext_entropy - * computes SHA256(shown || ext_entropy), the seed pre-image. Dice could - * never coexist with it, so the value shown was the raw RNG draw rather - * than a mixed one -- earlier comments here claiming a POST-mix value were - * wrong on every reachable path. Trezor, whose ResetDevice this inherits, - * removed the same feature for the same reason (PR #4119). + * MIXED: seed = SHA256d(tag || device_draw || SHA256(tag2 || rolls)). + * The device draw is shown as 24 BIP-39 words BEFORE the rolls + * are entered, so it is committed before the device has seen them + * and cannot be chosen to steer the result. The user copies the + * words down; with those and the rolls they recompute the seed. + * ONLY: seed = SHA256(rolls). The device draw is discarded. Coldcard's + * Dice-Rolls-Only, byte for byte. * - * ResetDevice.display_random stays in the wire schema and is ignored by - * fsm_msgResetDevice(), which is why the old "Can't show internal entropy - * when backup is skipped" syntax check is gone: there is no longer an - * entropy screen for it to be inconsistent with. + * Showing the device draw here is safe for the reason it was unsafe under + * the old ResetDevice.display_random screen. That screen revealed the same + * 32 bytes while the OTHER half was still host-supplied and uncommitted, + * so anyone who read it and knew ext_entropy held the seed pre-image. Here + * the other half is dice the user rolls after the words are shown and that + * never cross a wire; the host contributes nothing. display_random stays on + * the wire and is ignored. Trezor removed the same inherited feature for the + * same reason (PR #4119). * - * The roll digest below is safe by contrast: it is a hash of the user's - * own input, not of seed material. + * The roll digest is shown in full. In ONLY mode it is the seed material + * itself; in both it is the commitment the offline verifier checks. * - * The digest needs no clear here -- setup_stage() above ran setup_abort(), - * which zeroes it. */ + * The digest and mode need no clear here -- setup_stage() above ran + * setup_abort(), which zeroes them. */ if (dice_entropy) { static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; - static char CONFIDENTIAL digest_hex[17]; + static char CONFIDENTIAL digest_body[128]; uint32_t rolls_needed = dice_rolls_for_strength(strength); + dice_mode = dice_only ? DICE_MODE_ONLY : DICE_MODE_MIXED; + bool consented = + dice_only + ? confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Only"), + _("Seed from %lu rolls ALONE. No device randomness: " + "your dice are the only protection. Verifiable " + "offline."), + (unsigned long)rolls_needed) + : confirm(ButtonRequestType_ButtonRequest_DiceRoll, + _("Dice + Device"), + _("Copy the 24 entropy words shown next, then roll " + "%lu dice. Verifiable offline."), + (unsigned long)rolls_needed); + if (!consented) { + /* setup_abort() is the whole rollback -- staged settings, int_entropy, + * strength, roll digest, mode. */ + setup_abort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + layoutHome(); + return; + } + + if (dice_mode == DICE_MODE_MIXED) { + /* All 32 bytes, always 24 words, whatever strength was requested: the + * verifier needs the whole draw. mnemonic_from_data() returns bip39.c's + * static buffer; the pager copies it before anything else runs, and + * mnemonic_clear() zeroes it afterwards on every path. */ + bool shown = + show_mnemonic_pages(mnemonic_from_data(int_entropy, 32), _("Entropy"), + ButtonRequestType_ButtonRequest_DiceRoll); + mnemonic_clear(); + if (!shown) { + setup_abort(); + layoutHome(); + return; + } + } + + /* Empty for the duration of roll entry, so a DebugLink reader can tell + * the roll screen apart from the word pages that may precede it. */ + memzero(current_words, sizeof(current_words)); if (!dice_input_collect(dice_rolls, rolls_needed)) { memzero(dice_rolls, sizeof(dice_rolls)); - /* setup_abort() is the whole rollback -- staged settings, int_entropy, - * strength, roll digest. Load-bearing: the tiny-message pump that - * accepted the Cancel/Initialize does not dispatch fsm_msgCancel, so - * nothing else has aborted the ceremony at this point. */ + /* Load-bearing: the tiny-message pump that accepted the + * Cancel/Initialize does not dispatch fsm_msgCancel, so nothing else + * has aborted the ceremony at this point. */ setup_abort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Reset cancelled")); @@ -320,15 +385,33 @@ void reset_init(uint32_t _strength, bool passphrase_protection, return; } + if (dice_rolls_look_biased(dice_rolls, rolls_needed)) { + memzero(dice_rolls, sizeof(dice_rolls)); + setup_abort(); + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Dice rolls look biased: one face exceeds 30% of the rolls")); + 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)); + { + char hex[4][17]; + data2hex(dice_digest, 8, hex[0]); + data2hex(dice_digest + 8, 8, hex[1]); + data2hex(dice_digest + 16, 8, hex[2]); + data2hex(dice_digest + 24, 8, hex[3]); + snprintf(digest_body, sizeof(digest_body), + _("%lu rolls. Digest, SECRET:\n%s %s\n%s %s"), + (unsigned long)rolls_needed, hex[0], hex[1], hex[2], hex[3]); + memzero(hex, sizeof(hex)); + } + bool confirmed = confirm_constant_power_paged( + ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), digest_body); + memzero(digest_body, sizeof(digest_body)); if (!confirmed) { memzero(dice_rolls, sizeof(dice_rolls)); setup_abort(); @@ -338,7 +421,11 @@ void reset_init(uint32_t _strength, bool passphrase_protection, return; } - dice_mix(int_entropy, dice_rolls, rolls_needed); + if (dice_mode == DICE_MODE_ONLY) { + dice_derive_only(dice_rolls, rolls_needed, int_entropy); + } else { + dice_derive_mixed(int_entropy, dice_rolls, rolls_needed, int_entropy); + } memzero(dice_rolls, sizeof(dice_rolls)); } @@ -359,57 +446,38 @@ void reset_init(uint32_t _strength, bool passphrase_protection, msg_write(MessageType_MessageType_EntropyRequest, &resp); } -void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { - if (!setup_require(SETUP_RESET, _("Not in Reset mode"))) { - return; - } - - SHA256_CTX ctx; - sha256_Init(&ctx); - sha256_Update(&ctx, int_entropy, 32); - sha256_Update(&ctx, ext_entropy, len); - sha256_Final(&ctx, int_entropy); - - const char* temp_mnemonic = mnemonic_from_data(int_entropy, strength / 8); - - memzero(int_entropy, sizeof(int_entropy)); - - if (setup.no_backup) { - /* Consent for this path is the two WARNING holds taken during the same - * ceremony, in reset_init(). */ - if (!setup_commit(SETUP_RESET, temp_mnemonic, /*imported=*/false)) - goto exit; - fsm_sendSuccess(_("Device reset")); - goto exit; - } else { - if (!confirm(ButtonRequestType_ButtonRequest_Other, - _("Recovery Seed Backup"), - "This recovery seed will only be shown ONCE. " - "Please write it down carefully,\n" - "and DO NOT share it with anyone. ")) { - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Reset cancelled")); - /* storage_reset() used to run here. Nothing was written, so there is - * nothing to reset -- and a host-reachable wipe is not a rollback. */ - setup_abort(); - layoutHome(); - goto exit; - } - } - - /* - * Format mnemonic for user review - */ +/* Page \a mnemonic under one ButtonRequest per screen, exposing each screen's + * words through reset_get_word() for DebugLink. Used for the backup words + * and, in the dice MIXED mode, for the device-entropy words the user copies + * down to verify the seed offline. Sends its own Failure and returns false + * when the user cancels or the sentence does not fit; the caller owns the + * ceremony rollback. Its scratch is zeroed at entry, because the format loop + * depends on empty page strings and a prior caller may have aborted, and on + * every exit. */ +static bool show_mnemonic_pages(const char* mnemonic, const char* title_base, + ButtonRequestType type) { uint32_t word_count = 0, page_count = 0; - static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_BUF]; static char CONFIDENTIAL mnemonic_by_screen[MAX_PAGES][MNEMONIC_BY_SCREEN_BUF]; + static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_BUF]; static char CONFIDENTIAL formatted_mnemonic[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; static char CONFIDENTIAL mnemonic_display[FORMATTED_MNEMONIC_BUF]; static char CONFIDENTIAL formatted_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + bool ok = false; + + memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + memzero(formatted_word, sizeof(formatted_word)); + memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); - strlcpy(tokened_mnemonic, temp_mnemonic, TOKENED_MNEMONIC_BUF); + if (mnemonic == NULL) { + fsm_sendFailure(FailureType_Failure_Other, _("No mnemonic to display")); + goto done; + } + + strlcpy(tokened_mnemonic, mnemonic, TOKENED_MNEMONIC_BUF); char* tok = strtok(tokened_mnemonic, " "); @@ -428,8 +496,7 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { if (MAX_PAGES <= page_count) { fsm_sendFailure(FailureType_Failure_Other, _("Too many pages of mnemonic words")); - setup_abort(); - goto exit; + goto done; } snprintf(mnemonic_display, FORMATTED_MNEMONIC_BUF, "%s %s", @@ -458,30 +525,93 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { /* Have user confirm mnemonic is sets of 12 words */ for (uint32_t current_page = 0; current_page < page_count; current_page++) { - char title[MEDIUM_STR_BUF] = _("Backup"); + char title[MEDIUM_STR_BUF]; + strlcpy(title, title_base, MEDIUM_STR_BUF); /* make current screen mnemonic available via debuglink */ strlcpy(current_words, mnemonic_by_screen[current_page], MNEMONIC_BY_SCREEN_BUF); if (page_count > 1) { - /* snprintf: 20 + 10 (%d) + 1 (NULL) = 31 */ - snprintf(title, MEDIUM_STR_BUF, _("Backup %" PRIu32 "/%" PRIu32 ""), - current_page + 1, page_count); + snprintf(title, MEDIUM_STR_BUF, _("%s %" PRIu32 "/%" PRIu32 ""), + title_base, current_page + 1, page_count); } /* Keep the legacy one-request-per-group host protocol while paging the * narrower physical OLED layout locally inside that request. */ - if (!confirm_constant_power_paged( - ButtonRequestType_ButtonRequest_ConfirmWord, title, - formatted_mnemonic[current_page])) { + if (!confirm_constant_power_paged(type, title, + formatted_mnemonic[current_page])) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + goto done; + } + } + + ok = true; + +done: + memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + memzero(formatted_word, sizeof(formatted_word)); + return ok; +} + +void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { + if (!setup_require(SETUP_RESET, _("Not in Reset mode"))) { + return; + } + + SHA256_CTX ctx; + memzero(&ctx, sizeof(ctx)); + /* In either dice mode int_entropy is ALREADY the whole derivation, set in + * reset_init(), and is used verbatim. The host's EntropyAck is still + * consumed, so the wire flow and every host stay unchanged, but its bytes + * are dropped: folding them in would put a value the user does not hold + * back into the derivation and destroy the offline check that is the + * entire point of opting in. Not re-hashed either, so the published + * derivations are exactly what the verifier computes. */ + if (dice_mode == DICE_MODE_NONE) { + sha256_Init(&ctx); + sha256_Update(&ctx, int_entropy, 32); + sha256_Update(&ctx, ext_entropy, len); + sha256_Final(&ctx, int_entropy); + } + + const char* temp_mnemonic = mnemonic_from_data(int_entropy, strength / 8); + + memzero(int_entropy, sizeof(int_entropy)); + + if (setup.no_backup) { + /* Consent for this path is the two WARNING holds taken during the same + * ceremony, in reset_init(). */ + if (!setup_commit(SETUP_RESET, temp_mnemonic, /*imported=*/false)) + goto exit; + fsm_sendSuccess(_("Device reset")); + goto exit; + } else { + if (!confirm(ButtonRequestType_ButtonRequest_Other, + _("Recovery Seed Backup"), + "This recovery seed will only be shown ONCE. " + "Please write it down carefully,\n" + "and DO NOT share it with anyone. ")) { fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Reset cancelled")); + /* storage_reset() used to run here. Nothing was written, so there is + * nothing to reset -- and a host-reachable wipe is not a rollback. */ setup_abort(); + layoutHome(); goto exit; } } + if (!show_mnemonic_pages(temp_mnemonic, _("Backup"), + ButtonRequestType_ButtonRequest_ConfirmWord)) { + setup_abort(); + goto exit; + } + /* Every page was held through. This is the commit point: the settings the * user chose during THIS ceremony and the seed land together, or neither * lands. */ @@ -492,11 +622,6 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { /* The roll digest is cleared by setup_abort(); every path that reaches * here has already run it, directly or through setup_commit(). */ memzero(&ctx, sizeof(ctx)); - memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); - memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); - memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); - memzero(mnemonic_display, sizeof(mnemonic_display)); - memzero(formatted_word, sizeof(formatted_word)); mnemonic_clear(); layoutHome(); } diff --git a/scripts/emulator/capture-dice-flow.py b/scripts/emulator/capture-dice-flow.py index b99529168..acea7de14 100644 --- a/scripts/emulator/capture-dice-flow.py +++ b/scripts/emulator/capture-dice-flow.py @@ -57,7 +57,14 @@ def snap(name): ret = client.call_raw(proto.ResetDevice( strength=256, passphrase_protection=False, pin_protection=False, language='english', label='dice evidence', - dice_entropy=True)) + dice_entropy=True, dice_only=True)) +assert isinstance(ret, proto.ButtonRequest), ret + +# The consent screen names the mode the host selected. DICE ONLY is the +# shortest verifiable flow; MIXED would add the 24 device-entropy word pages. +snap("00-mode-consent.png") +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) assert isinstance(ret, proto.ButtonRequest), ret client.transport.write(proto.ButtonAck()) diff --git a/tools/verify_dice_seed.py b/tools/verify_dice_seed.py new file mode 100755 index 000000000..be08d0eed --- /dev/null +++ b/tools/verify_dice_seed.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Recompute a KeepKey dice wallet offline, from what you hold. + +Both dice modes are designed so that every input to the seed is one you can +write down, so this script needs no secret from the device and no network. + +DICE ONLY -- seed = SHA256(rolls) + The rolls are the entire derivation. Identical to Coldcard's + Dice-Rolls-Only, so their published verifier gives the same answer. + + ./verify_dice_seed.py --rolls 5312... --words 24 + +MIXED -- seed = SHA256(SHA256(b"KK\\x01SM" + device + SHA256(b"KK\\x01D" + rolls))) + The device showed its own 32-byte draw as 24 BIP-39 words BEFORE you + rolled. Pass those words back in; with them and your rolls the seed is + fully determined. The host's EntropyAck bytes are consumed by the device + and discarded, so they do not appear here. + + ./verify_dice_seed.py --rolls 5312... --words 12 \\ + --device-words "abandon ability able ..." + +Compare the printed mnemonic with the backup words the device showed. If +they differ, the device did not derive the wallet from your rolls (and, in +MIXED, from the entropy it committed to). Do not fund it. + +WARNING: in DICE ONLY the roll string IS the wallet; in MIXED the roll string +plus the device words are. Anyone who obtains them recreates your keys. Run +this on a machine you would trust with the seed, and prefer a throwaway run: +roll, verify the math, then start again with fresh rolls for the wallet you +actually fund. + +Neither mode is a default, and DICE ONLY in particular has no device +randomness by design: a biased die, too few rolls, or a photographed roll +sheet is the whole wallet. The device refuses rolls where any face exceeds +30% of the total, as Coldcard does, but that is a floor, not a guarantee. +""" + +import argparse +import hashlib +import os +import sys + +# 50 rolls for 128-bit, 75 for 192-bit, 99 for 256-bit -- matches +# dice_rolls_for_strength() in lib/firmware/dice_input.c. +ROLLS_FOR_STRENGTH = {128: 50, 192: 75, 256: 99} +STRENGTH_FOR_WORDS = {12: 128, 18: 192, 24: 256} + +# Byte tags, matching lib/firmware/dice_input.c. Python's \x takes exactly +# two hex digits, so b"KK\x01D" is the four bytes K, K, 0x01, D. +TAG_USER = b"KK\x01D" +TAG_MIX = b"KK\x01SM" + +WORDLIST_CANDIDATES = ( + "deps/crypto/trezor-firmware/crypto/bip39_english.txt", + "deps/crypto/bip39_english.txt", +) + + +def find_wordlist(explicit): + if explicit: + return explicit + here = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + for rel in WORDLIST_CANDIDATES: + path = os.path.join(here, rel) + if os.path.isfile(path): + return path + return None + + +def load_wordlist(path): + with open(path) as handle: + words = [line.strip() for line in handle if line.strip()] + if len(words) != 2048: + raise SystemExit("wordlist %s has %d entries, expected 2048" + % (path, len(words))) + return words + + +def mnemonic_from_entropy(entropy, words): + """BIP39: entropy + SHA256 checksum, split into 11-bit indices.""" + checksum_bits = len(entropy) * 8 // 32 + digest = hashlib.sha256(entropy).digest() + bits = "".join("{:08b}".format(b) for b in entropy) + bits += "".join("{:08b}".format(b) for b in digest)[:checksum_bits] + return " ".join(words[int(bits[i:i + 11], 2)] + for i in range(0, len(bits), 11)) + + +def entropy_from_mnemonic(sentence, words): + """Inverse of the above for the 24-word device sentence, checksum + verified: a typo in a copied word is caught here, not blamed on the + device.""" + parts = sentence.split() + if len(parts) != 24: + raise SystemExit("--device-words must be the 24 words the device " + "showed; got %d" % len(parts)) + try: + bits = "".join("{:011b}".format(words.index(w)) for w in parts) + except ValueError as exc: + raise SystemExit("not a BIP-39 word: %s" % exc) + entropy = bytes(int(bits[i:i + 8], 2) for i in range(0, 256, 8)) + if bits[256:] != "{:08b}".format(hashlib.sha256(entropy).digest()[0]): + raise SystemExit("device words fail their BIP-39 checksum; re-check " + "the transcription before suspecting the device") + return entropy + + +def seed_dice_only(rolls): + return hashlib.sha256(rolls.encode("ascii")).digest() + + +def seed_mixed(device_entropy, rolls): + user = hashlib.sha256(TAG_USER + rolls.encode("ascii")).digest() + inner = hashlib.sha256(TAG_MIX + device_entropy + user).digest() + return hashlib.sha256(inner).digest() + + +def main(): + ap = argparse.ArgumentParser( + description="Recompute a KeepKey dice wallet from what you hold.") + ap.add_argument("--rolls", help="roll string, digits 1-6 (default: stdin)") + ap.add_argument("--words", type=int, choices=sorted(STRENGTH_FOR_WORDS), + required=True, help="word count the device produced") + ap.add_argument("--device-words", + help="the 24 device-entropy words shown before rolling " + "(MIXED mode); omit for DICE ONLY") + ap.add_argument("--wordlist", help="path to bip39_english.txt") + args = ap.parse_args() + + rolls = args.rolls if args.rolls is not None else sys.stdin.read() + rolls = "".join(rolls.split()) + + bad = sorted(set(rolls) - set("123456")) + if bad: + raise SystemExit("rolls contain non-d6 characters: %s" % ", ".join(bad)) + + strength = STRENGTH_FOR_WORDS[args.words] + expected = ROLLS_FOR_STRENGTH[strength] + if len(rolls) != expected: + raise SystemExit( + "got %d rolls, but a %d-word dice seed uses exactly %d.\n" + "A different count derives a different wallet, so this is a " + "transcription error, not a warning." % (len(rolls), args.words, + expected)) + + path = find_wordlist(args.wordlist) + words = load_wordlist(path) if path else None + + digest = hashlib.sha256(rolls.encode("ascii")).digest() + if args.device_words: + if words is None: + raise SystemExit("MIXED mode needs the BIP-39 wordlist to decode " + "--device-words; pass --wordlist") + device_entropy = entropy_from_mnemonic(args.device_words, words) + seed = seed_mixed(device_entropy, rolls) + mode = "MIXED seed = SHA256d(tag || device || SHA256(tag || rolls))" + else: + seed = seed_dice_only(rolls) + mode = "DICE ONLY seed = SHA256(rolls)" + + print("mode : %s" % mode) + print("rolls : %d" % len(rolls)) + print("roll digest : %s" % digest.hex()) + print(" : the device showed this in full on the Dice Rolls " + "screen") + if args.device_words: + print("device draw : %s" % device_entropy.hex()) + print("entropy : %s" % seed[:strength // 8].hex()) + + if words is None: + raise SystemExit( + "\nno BIP39 wordlist found; pass --wordlist to " + "print the mnemonic. The entropy above is the value the backup " + "words encode.") + + mnemonic = mnemonic_from_entropy(seed[:strength // 8], words) + print() + print("mnemonic :") + parts = mnemonic.split() + for i in range(0, len(parts), 4): + print(" %2d. %s" % (i + 1, " ".join(parts[i:i + 4]))) + print() + print("If these are not the words the device showed, the device did not " + "derive") + print("the wallet from your rolls. Do not fund it.") + + +if __name__ == "__main__": + main() diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp index 93654062e..3986835d6 100644 --- a/unittests/firmware/dice.cpp +++ b/unittests/firmware/dice.cpp @@ -62,3 +62,75 @@ TEST(Dice, MixUsesExactCount) { dice_mix(b, rolls_b, 6); EXPECT_EQ(0, memcmp(a, b, 32)); } + +// Expected values below were computed in Python from the published formulas, +// not captured from this code: a vector produced by the function under test +// would only prove the function agrees with itself. + +TEST(Dice, DeriveOnlyIsPlainSha256OfRolls) { + // SHA256("123456") -- the Coldcard Dice-Rolls-Only derivation. + uint8_t out[32]; + dice_derive_only("123456", 6, out); + EXPECT_EQ(hexlify(out, 32), + "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"); +} + +TEST(Dice, DeriveMixedVector) { + // SHA256d("KK\x01SM" || 0x00..0x1f || SHA256("KK\x01D" || "654321165243")) + uint8_t device[32]; + for (int i = 0; i < 32; i++) device[i] = (uint8_t)i; + uint8_t out[32]; + dice_derive_mixed(device, "654321165243", 12, out); + EXPECT_EQ(hexlify(out, 32), + "17a9fa5d19f12732194d3ff3fd73a8960880914fa902a799c7c3dc71ede30b58"); +} + +TEST(Dice, DeriveMixedZeroDeviceVector) { + uint8_t device[32]; + memset(device, 0, sizeof(device)); + uint8_t out[32]; + dice_derive_mixed(device, "123456", 6, out); + EXPECT_EQ(hexlify(out, 32), + "ea5a7aaab1b6a55383edf17b1f55a6dcb0f3530c0a91a2d8940aeee68374864f"); +} + +TEST(Dice, DeriveMixedAliasesInPlace) { + // reset.c derives into the same buffer the device draw lives in. + uint8_t device[32], separate[32]; + for (int i = 0; i < 32; i++) device[i] = (uint8_t)i; + dice_derive_mixed(device, "654321165243", 12, separate); + dice_derive_mixed(device, "654321165243", 12, device); + EXPECT_EQ(0, memcmp(device, separate, 32)); +} + +TEST(Dice, DeriveMixedDiffersFromLegacyMix) { + // The tagged derivation must not collide with dice_mix() on the same + // inputs, or a wallet could be silently re-derived under the wrong mode. + uint8_t device[32], mixed[32]; + memset(device, 0, sizeof(device)); + memcpy(mixed, device, 32); + dice_derive_mixed(device, "123456", 6, device); + dice_mix(mixed, "123456", 6); + EXPECT_NE(0, memcmp(device, mixed, 32)); +} + +static std::string rolls_with_ones(size_t ones, size_t total) { + std::string s(ones, '1'); + const char *rest = "23456"; + for (size_t i = 0; s.size() < total; i++) s += rest[i % 5]; + return s; +} + +TEST(Dice, BiasGateIsThirtyPercentPerFace) { + // Coldcard's rule: any face over 30% of the rolls. 30/99 = 30.3% fails, + // 29/99 = 29.3% passes; 16/50 = 32% fails, 15/50 = 30% exactly passes. + EXPECT_TRUE(dice_rolls_look_biased(rolls_with_ones(30, 99).c_str(), 99)); + EXPECT_FALSE(dice_rolls_look_biased(rolls_with_ones(29, 99).c_str(), 99)); + EXPECT_TRUE(dice_rolls_look_biased(rolls_with_ones(16, 50).c_str(), 50)); + EXPECT_FALSE(dice_rolls_look_biased(rolls_with_ones(15, 50).c_str(), 50)); +} + +TEST(Dice, BiasGateRejectsNonDiceBytes) { + EXPECT_TRUE(dice_rolls_look_biased("12345612345612345612345612345612345612345612345612340", 51)); + EXPECT_TRUE(dice_rolls_look_biased("1234567", 7)); +} From 997c59ad0632478625ffb4404431e1d83f097731 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 10 Sep 2026 21:39:50 -0700 Subject: [PATCH 2/3] fix(dice): review fixes -- SRAM, consent text, no_backup, capability bit Findings from an adversarial review of the unit (86 agents, 3 refuters per finding) and from the first CI runs, all confirmed against the code: - The full 7.15 image failed to link: "Insufficient runtime SRAM: require 16 KiB stack/heap reserve". Its base reserve was 16,420 B against a 16,384 B floor, and the unit had added a 128-byte static for the digest page. That page is now formatted into current_words -- 265 bytes, already CONFIDENTIAL, idle between roll entry and the backup pager -- so the unit adds no static RAM beyond the 4-byte mode. - dice with no_backup is refused with SyntaxError before any screen, as display_random with no_backup was: the modes exist to be checked against the backup words, and a reset that never shows them would put seed material on the OLED under a WARNING that recovery is impossible. - The MIXED consent screen now says the 24 entropy words are NOT a backup, since the pages that follow are otherwise indistinguishable from the backup pages. Both consent bodies are shortened so neither can ever paginate. - Features.supports_dice_modes (28) is reported. Older firmware skips the unknown dice_only field and derives a different wallet without complaint, so hosts and the test suite gate on this bit rather than on a version. - dice_mix() was dead once the modes replaced it; removed with its tests. The non-collision test now compares against the old formula's Python-computed vector instead. A unit test that placed its invalid byte past `count` is corrected. - tools/verify_dice_seed.py looked for a wordlist file the repo does not ship; it now reads the firmware's own bip39_english.c (slicing "abandon" to "zoo", since the file quotes other strings too) and was checked to yield exactly the reference English list. - Stale comments describing an on-device mode selector are corrected; docs/DiceEntropy.md "What happens" now describes the shipped ceremony. Pins: deps/device-protocol -> 0848887cf04a67342c468a40b36fa67d15cdca9b (feat/dice-only-field-7.14.3, adds Features.supports_dice_modes = 28 on the products' dp base); deps/python-keepkey -> 51d7cd112aee21daea44c124310c7b08e921ee07 (feat/dice-modes: capability-gated tests, the digest-subpage and roll-injection driver fixes, no_backup and consent-cancel tests, atlas entries). --- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- docs/DiceEntropy.md | 37 ++++++++++---- include/keepkey/firmware/dice_input.h | 4 -- include/keepkey/firmware/reset.h | 9 +++- lib/firmware/dice_input.c | 13 +---- lib/firmware/fsm_msg_common.h | 7 +++ lib/firmware/reset.c | 43 +++++++++++----- scripts/emulator/capture-dice-flow.py | 12 ++++- tools/verify_dice_seed.py | 66 +++++++++++++++++------- unittests/firmware/dice.cpp | 74 ++++++++++----------------- 11 files changed, 159 insertions(+), 110 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index 5517c8de3..0848887cf 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 5517c8de31cee85663ff6b3a376f1f97bafb9212 +Subproject commit 0848887cf04a67342c468a40b36fa67d15cdca9b diff --git a/deps/python-keepkey b/deps/python-keepkey index c0e9bd829..51d7cd112 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit c0e9bd82942b86cf71cfc5afebb8f8ca667deb84 +Subproject commit 51d7cd112aee21daea44c124310c7b08e921ee07 diff --git a/docs/DiceEntropy.md b/docs/DiceEntropy.md index 81e11253a..ecdb3feb1 100644 --- a/docs/DiceEntropy.md +++ b/docs/DiceEntropy.md @@ -1,22 +1,39 @@ # Dice Entropy -On-device dice rolls, folded into the seed at creation time. Available from -firmware v7.14.3 (bitcoin-only line) and v7.15.0 (`ResetDevice.dice_entropy`). +On-device dice rolls as seed entropy, in two modes a user can verify offline. +Available from firmware v7.14.3 (bitcoin-only line) and v7.15.0 +(`ResetDevice.dice_entropy`, `ResetDevice.dice_only`); a host must check +`Features.supports_dice_modes` before offering either, because older firmware +skips unknown fields silently rather than refusing them. ## What happens `reset.c:reset_init()`, when `dice_entropy` is set: -1. `dice_input_collect()` gathers rolls on the device's own button — short press +1. A consent screen names the mode the host selected — "Dice + Device" or + "Dice Only" — with the roll count. Holding proceeds; the only "no" is the + host's Cancel, which aborts the reset. `no_backup` is refused with dice. +2. MIXED only: the device shows its own 32-byte RNG draw as 24 BIP-39 words, + titled "Entropy N/M", and the consent screen has just said these are NOT a + backup. The user copies them down. This happens before any roll is entered, + so the draw is committed before the device has seen the rolls. +3. `dice_input_collect()` gathers rolls on the device's own button — short press selects 1-6, long press commits. 50 rolls for a 12-word seed, 75 for 18, 99 for 24 (`dice_rolls_for_strength`). Rolls are stored as ASCII `'1'`-`'6'`, one byte each. -2. `dice_digest = SHA256(rolls)`. The first 8 bytes are shown on the OLED as 16 - hex characters, with the roll count, on a confirm screen. -3. `dice_mix(int_entropy, rolls, count)` replaces the internal entropy with - `SHA256(int_entropy || rolls)` (`dice_input.c:138`). -4. Only then does the device send `EntropyRequest`, so the host's contribution - arrives strictly after the device has committed to its own. +4. Rolls on which any face lands more than 30% of the time are refused with a + `SyntaxError` before anything else is shown (`dice_rolls_look_biased`). +5. `dice_digest = SHA256(rolls)`. All 32 bytes are shown on the OLED as 64 hex + characters, with the roll count, on a paged confirm screen. +6. The seed is derived per mode (`dice_derive_only` / `dice_derive_mixed`, in + `dice_input.c`): + - ONLY: `seed = SHA256(rolls)` + - MIXED: `user = SHA256("KK\x01D" || rolls)`; + `seed = SHA256(SHA256("KK\x01SM" || draw || user))` +7. The device sends `EntropyRequest` and consumes the host's `EntropyAck`, so + the wire flow is unchanged, but the bytes are dropped: nothing enters the + derivation that the user does not hold. +8. The backup words are shown and the seed is committed as usual. Cancelling at any point aborts the reset and zeroes the buffers. Nothing is stored. @@ -27,7 +44,7 @@ The digest is over the rolls, and nothing else. A user who wrote their rolls down can recompute it: ``` -printf '536142...' | shasum -a 256 # first 16 hex chars == displayed digest +printf '536142...' | shasum -a 256 # all 64 hex chars == displayed digest ``` A match proves the device recorded exactly that sequence, in that order, with diff --git a/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h index 565eff274..db59ddd4f 100644 --- a/include/keepkey/firmware/dice_input.h +++ b/include/keepkey/firmware/dice_input.h @@ -69,8 +69,4 @@ void dice_derive_only(const char *rolls, uint32_t count, uint8_t out[32]); void dice_derive_mixed(const uint8_t device[32], const char *rolls, uint32_t count, uint8_t out[32]); -/// 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 a99621357..490044511 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -84,8 +84,13 @@ void setup_arm(SetupKind kind); /// ceremony before modifying storage, reports Failure, and returns false. bool setup_commit(SetupKind kind, const char* mnemonic, bool imported); -/* \a dice_entropy runs the on-device dice collection, which folds into the - * device half BEFORE the EntropyRequest and entirely before setup_arm(). */ +/* No display_random parameter: ResetDevice.display_random remains on the wire + * for host compatibility but is ignored, because internal entropy is seed + * pre-image material and must never be rendered. \a dice_entropy runs the + * on-device dice ceremony -- MIXED: the device draw is shown as 24 words, then + * seed = SHA256d(tag || draw || SHA256(tag || rolls)); with \a dice_only the + * seed is SHA256(rolls) alone. Both are confirmed on-device before anything + * runs, complete before setup_arm(), and drop the host's EntropyAck bytes. */ void reset_init(uint32_t _strength, bool passphrase_protection, bool pin_protection, const char* language, const char* label, bool _no_backup, uint32_t _auto_lock_delay_ms, diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c index 32b4dbde2..6d0aa2601 100644 --- a/lib/firmware/dice_input.c +++ b/lib/firmware/dice_input.c @@ -135,15 +135,6 @@ uint32_t dice_rolls_for_strength(uint32_t strength_bits) { } } -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)); -} - bool dice_rolls_look_biased(const char *rolls, uint32_t count) { uint32_t face[6] = {0, 0, 0, 0, 0, 0}; for (uint32_t i = 0; i < count; i++) { @@ -253,8 +244,8 @@ static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, } /* Arm the button ISRs, reset the shared press state, and announce the screen. - * Shared by every dice screen so the mode selector and the roll collector - * cannot drift apart in how they treat the host ack or a stale press. */ + * Factored out of the roll collector so any later dice screen shares its + * exact treatment of the host ack and of a stale press. */ static void dice_session_begin(void) { reset_msg_stack = false; diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index d4f60eb5c..36443a05d 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -42,6 +42,13 @@ void fsm_msgGetFeatures(GetFeatures* msg) { resp->has_supports_taproot = true; resp->supports_taproot = true; + /* Verifiable dice modes: the on-device consent screen, ResetDevice.dice_only + and the tagged MIXED derivation. Reported as a capability because older + firmware skips the unknown dice_only field and would derive a different + wallet without complaint; a host must fail closed on this bit. */ + resp->has_supports_dice_modes = true; + resp->supports_dice_modes = true; + /* Variant Name */ resp->has_firmware_variant = true; #if BITCOIN_ONLY diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index dfd380ecd..9c2fe68a3 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -76,9 +76,10 @@ static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static uint8_t CONFIDENTIAL dice_digest[32]; static bool has_dice_digest = false; -/* Which dice derivation this ceremony uses. Chosen ON THE DEVICE, never by - * the host, so a host can neither select a mode nor force one. Cleared with - * the digest, so an abandoned ceremony cannot leave it armed for the next. */ +/* Which dice derivation this ceremony uses. Selected by the host in + * ResetDevice (dice_entropy / dice_only) and confirmed on the device by the + * consent screen in reset_init() before anything runs. Cleared with the + * digest by setup_abort(), so an abandoned ceremony cannot leave it armed. */ static DiceMode dice_mode = DICE_MODE_NONE; static void dice_digest_clear(void) { @@ -223,6 +224,17 @@ void reset_init(uint32_t _strength, bool passphrase_protection, return; } + /* The dice modes exist to be checked against the backup words. A reset that + * never shows them has nothing to verify, and would put seed material (the + * digest, the entropy words) on the screen under a WARNING that recovery is + * impossible. Refused, as display_random with no_backup was. */ + if (dice_entropy && _no_backup) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Dice entropy cannot be combined with no_backup")); + layoutHome(); + return; + } + /* Nothing below this line writes storage. Everything the host asked for is * staged, and stays staged until reset_entropy() reaches setup_commit(). * Returning early from any of the screens below therefore rolls the whole @@ -328,21 +340,19 @@ void reset_init(uint32_t _strength, bool passphrase_protection, * setup_abort(), which zeroes them. */ if (dice_entropy) { static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; - static char CONFIDENTIAL digest_body[128]; uint32_t rolls_needed = dice_rolls_for_strength(strength); dice_mode = dice_only ? DICE_MODE_ONLY : DICE_MODE_MIXED; bool consented = dice_only ? confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Only"), - _("Seed from %lu rolls ALONE. No device randomness: " - "your dice are the only protection. Verifiable " - "offline."), + _("Seed from %lu rolls ALONE, no device randomness. " + "Verifiable offline."), (unsigned long)rolls_needed) : confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice + Device"), - _("Copy the 24 entropy words shown next, then roll " - "%lu dice. Verifiable offline."), + _("Next: 24 entropy words, NOT a backup. Copy " + "them, then roll %lu dice."), (unsigned long)rolls_needed); if (!consented) { /* setup_abort() is the whole rollback -- staged settings, int_entropy, @@ -398,20 +408,27 @@ void reset_init(uint32_t _strength, bool passphrase_protection, sha256_Raw((const uint8_t*)dice_rolls, rolls_needed, dice_digest); has_dice_digest = true; + /* The digest page is formatted into current_words: 265 bytes, already + * CONFIDENTIAL, and idle between roll entry and the backup pager. A new + * static buffer here cost the full 7.15 image its 16 KiB SRAM reserve, + * which sits within a few dozen bytes of the linker floor. Under + * DEBUG_LINK reset_get_word() returns this text while the page is up; the + * digest is already exposed there. */ { char hex[4][17]; data2hex(dice_digest, 8, hex[0]); data2hex(dice_digest + 8, 8, hex[1]); data2hex(dice_digest + 16, 8, hex[2]); data2hex(dice_digest + 24, 8, hex[3]); - snprintf(digest_body, sizeof(digest_body), + snprintf(current_words, sizeof(current_words), _("%lu rolls. Digest, SECRET:\n%s %s\n%s %s"), (unsigned long)rolls_needed, hex[0], hex[1], hex[2], hex[3]); memzero(hex, sizeof(hex)); } - bool confirmed = confirm_constant_power_paged( - ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), digest_body); - memzero(digest_body, sizeof(digest_body)); + bool confirmed = + confirm_constant_power_paged(ButtonRequestType_ButtonRequest_DiceRoll, + _("Dice Rolls"), current_words); + memzero(current_words, sizeof(current_words)); if (!confirmed) { memzero(dice_rolls, sizeof(dice_rolls)); setup_abort(); diff --git a/scripts/emulator/capture-dice-flow.py b/scripts/emulator/capture-dice-flow.py index acea7de14..6f963afc8 100644 --- a/scripts/emulator/capture-dice-flow.py +++ b/scripts/emulator/capture-dice-flow.py @@ -87,8 +87,16 @@ def snap(name): assert isinstance(resp, proto.ButtonRequest), resp snap("04-digest-confirm.png") -client.debug.press_yes() -ret = client.call_raw(proto.ButtonAck()) +# The full digest spans constant-power subpages; under DEBUG_LINK each one +# after the first raises its own ButtonRequest. Hold through all of them. +ret = resp +page = 1 +while isinstance(ret, proto.ButtonRequest): + if page > 1: + snap("04-digest-confirm-page%d.png" % page) + client.debug.press_yes() + ret = client.call_raw(proto.ButtonAck()) + page += 1 assert isinstance(ret, proto.EntropyRequest), ret ret = client.call_raw(proto.EntropyAck(entropy=b'E' * 32)) diff --git a/tools/verify_dice_seed.py b/tools/verify_dice_seed.py index be08d0eed..9208717fd 100755 --- a/tools/verify_dice_seed.py +++ b/tools/verify_dice_seed.py @@ -50,32 +50,60 @@ TAG_USER = b"KK\x01D" TAG_MIX = b"KK\x01SM" +# The English wordlist the firmware itself is built from, as shipped in the +# trezor-crypto submodule. It is a C source file (the .h only declares the +# array), not a .txt; either form is read. WORDLIST_CANDIDATES = ( - "deps/crypto/trezor-firmware/crypto/bip39_english.txt", - "deps/crypto/bip39_english.txt", + "deps/crypto/trezor-firmware/crypto/bip39_english.c", + "deps/crypto/trezor-firmware/crypto/bip39_english.h", ) -def find_wordlist(explicit): +def parse_wordlist(path): + """One word per line, or a C source with the words as quoted literals. + Returns the list, or None if the file does not hold exactly the 2048 + English words -- a wrong or partial file must not yield a plausible but + different sentence.""" + with open(path) as handle: + text = handle.read() + if path.endswith((".h", ".c")): + # The C source quotes other things too (its license text, for one), + # so take the run from the first word to the last rather than every + # quoted literal in the file. + import re + tokens = re.findall(r'"([a-z]+)"', text) + try: + first = tokens.index("abandon") + last = tokens.index("zoo", first) + except ValueError: + return None + words = tokens[first:last + 1] + else: + words = [line.strip() for line in text.splitlines() if line.strip()] + if len(words) != 2048 or words[0] != "abandon" or words[-1] != "zoo": + return None + return words + + +def load_wordlist(explicit): + """The wordlist from --wordlist, else the first repo candidate that + parses. None if nothing usable was found.""" if explicit: - return explicit + words = parse_wordlist(explicit) + if words is None: + raise SystemExit("%s does not contain the 2048 BIP-39 English " + "words" % explicit) + return words here = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) for rel in WORDLIST_CANDIDATES: path = os.path.join(here, rel) if os.path.isfile(path): - return path + words = parse_wordlist(path) + if words is not None: + return words return None -def load_wordlist(path): - with open(path) as handle: - words = [line.strip() for line in handle if line.strip()] - if len(words) != 2048: - raise SystemExit("wordlist %s has %d entries, expected 2048" - % (path, len(words))) - return words - - def mnemonic_from_entropy(entropy, words): """BIP39: entropy + SHA256 checksum, split into 11-bit indices.""" checksum_bits = len(entropy) * 8 // 32 @@ -143,8 +171,7 @@ def main(): "transcription error, not a warning." % (len(rolls), args.words, expected)) - path = find_wordlist(args.wordlist) - words = load_wordlist(path) if path else None + words = load_wordlist(args.wordlist) digest = hashlib.sha256(rolls.encode("ascii")).digest() if args.device_words: @@ -169,9 +196,10 @@ def main(): if words is None: raise SystemExit( - "\nno BIP39 wordlist found; pass --wordlist to " - "print the mnemonic. The entropy above is the value the backup " - "words encode.") + "\nno BIP39 wordlist found (looked for the trezor-crypto " + "submodule's bip39_english.c under deps/); pass --wordlist with " + "that file or any one-word-per-line English list to print the " + "mnemonic. The entropy above is the value the backup words encode.") mnemonic = mnemonic_from_entropy(seed[:strength // 8], words) print() diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp index 3986835d6..b2e0555b6 100644 --- a/unittests/firmware/dice.cpp +++ b/unittests/firmware/dice.cpp @@ -24,45 +24,6 @@ TEST(Dice, RollsForStrength) { 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)); -} - // Expected values below were computed in Python from the published formulas, // not captured from this code: a vector produced by the function under test // would only prove the function agrees with itself. @@ -103,15 +64,26 @@ TEST(Dice, DeriveMixedAliasesInPlace) { EXPECT_EQ(0, memcmp(device, separate, 32)); } -TEST(Dice, DeriveMixedDiffersFromLegacyMix) { - // The tagged derivation must not collide with dice_mix() on the same - // inputs, or a wallet could be silently re-derived under the wrong mode. - uint8_t device[32], mixed[32]; +TEST(Dice, DeriveMixedDiffersFromUntaggedMix) { + // The tagged derivation must not collide with what earlier firmware + // derived for the same inputs, SHA256(draw || rolls) -- here + // SHA256(0x00*32 || "123456"), computed in Python -- or a wallet could be + // silently re-derived under the wrong formula. + uint8_t device[32]; memset(device, 0, sizeof(device)); - memcpy(mixed, device, 32); dice_derive_mixed(device, "123456", 6, device); - dice_mix(mixed, "123456", 6); - EXPECT_NE(0, memcmp(device, mixed, 32)); + EXPECT_NE(hexlify(device, 32), + "16ba88244e0230b0fc84868b703a0e32c344be1b0284f2e67e59715f123748d6"); +} + +TEST(Dice, DeriveOnlyUsesExactCount) { + // Only `count` bytes of the roll buffer may contribute. + uint8_t a[32], b[32]; + 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_derive_only(rolls_a, 6, a); + dice_derive_only(rolls_b, 6, b); + EXPECT_EQ(0, memcmp(a, b, 32)); } static std::string rolls_with_ones(size_t ones, size_t total) { @@ -131,6 +103,14 @@ TEST(Dice, BiasGateIsThirtyPercentPerFace) { } TEST(Dice, BiasGateRejectsNonDiceBytes) { - EXPECT_TRUE(dice_rolls_look_biased("12345612345612345612345612345612345612345612345612340", 51)); + // A non-d6 byte anywhere inside `count` is refused, whatever the + // distribution of the rest. (An earlier version of this test placed the bad + // byte past `count`, where it is correctly never examined.) + std::string s = rolls_with_ones(8, 50); + EXPECT_FALSE(dice_rolls_look_biased(s.c_str(), 50)); + s[10] = '0'; + EXPECT_TRUE(dice_rolls_look_biased(s.c_str(), 50)); + s[10] = '7'; + EXPECT_TRUE(dice_rolls_look_biased(s.c_str(), 50)); EXPECT_TRUE(dice_rolls_look_biased("1234567", 7)); } From 90e40a99dcd6934412d892d3a19ec39675654fc8 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 10 Sep 2026 22:06:31 -0700 Subject: [PATCH 3/3] deps: advance python-keepkey (native dice tests catalogued) feat/dice-modes @ 03c2fe11f704720c7a0230908ae9971717765aa5: the report atlas names the unit's eight Dice gtests in place of the four removed with dice_mix(), which the 7.15 bitcoin-only catalog validation had flagged as missing. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 51d7cd112..03c2fe11f 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 51d7cd112aee21daea44c124310c7b08e921ee07 +Subproject commit 03c2fe11f704720c7a0230908ae9971717765aa5