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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deps/device-protocol
Submodule device-protocol updated 1 files
+18 −2 messages.proto
123 changes: 80 additions & 43 deletions docs/DiceEntropy.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -36,20 +53,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
Expand Down Expand Up @@ -92,26 +105,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
Expand All @@ -120,12 +154,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.
Expand Down
84 changes: 57 additions & 27 deletions docs/dice-vs-coldcard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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 )
Expand Down Expand Up @@ -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.
29 changes: 26 additions & 3 deletions include/keepkey/firmware/dice_input.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -42,8 +54,19 @@ uint32_t dice_rolls_for_strength(uint32_t strength_bits);
/// 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);
/// 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]);

#endif
11 changes: 8 additions & 3 deletions include/keepkey/firmware/reset.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,17 @@ 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,
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);
Expand Down
Loading
Loading