Skip to content

feat(dice): opt-in verifiable dice modes, MIXED and DICE ONLY (7.15) - #757

Draft
BitHighlander wants to merge 6 commits into
audit/715-scope-repairfrom
audit/715-dice-only
Draft

feat(dice): opt-in verifiable dice modes, MIXED and DICE ONLY (7.15)#757
BitHighlander wants to merge 6 commits into
audit/715-scope-repairfrom
audit/715-dice-only

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Stacked on #756 (audit/715-scope-repair). Six commits; the dice commits are unchanged from the tree that went green on run 34568530751 — the only difference under them is #756's report-evidence port (fail-closed wrapper, manifest + sha256 beside the PDF), which this unit now inherits.

What

Two opt-in ways to seed a device from dice, selected by the host on ResetDevice and confirmed on the device before anything is collected:

mode request seed verify offline with
MIXED dice_entropy=true SHA256d(tagM ‖ device_draw ‖ SHA256(tagU ‖ rolls)) — the device shows its 32-byte draw as 24 words before you roll rolls + the 24 device words
DICE ONLY dice_entropy=true, dice_only=true SHA256(rolls) — byte-for-byte ColdCard Dice-Rolls-Only rolls alone
  • The consent screen names the mode; host Cancel aborts the ceremony. dice_only without dice_entropy, and either mode with no_backup, are refused with SyntaxError.
  • The roll digest is shown in full on the device; any face over 30% of the rolls is refused (ColdCard's rule).
  • Host EntropyAck is still consumed in both dice modes and its bytes discarded. The default (no-dice) reset is unchanged: SHA256(device ‖ host), as it has been since 2014 — see docs/dice-vs-coldcard.md for why that stays.
  • Features.supports_dice_modes (field 28) so hosts gate on capability, never version: older firmware silently skips the unknown dice_only field and derives a different wallet.
  • tools/verify_dice_seed.py --rolls … --words 24 [--device-words "…"] recomputes the mnemonic from what the user wrote down. No device secret, no network.

Evidence

  • CI on this head: run 34576787296 (every job, both variants). Report renders section K Seed Generation Hardening 21/21: python K1–K1f (mixed, only, biased-rolls refused, only-requires-entropy, no_backup refused, consent-cancel aborts) and native K3–K7c (derivation vectors computed in Python, not captured from the code; bias gate boundaries; exact-count).
  • 7.15 full links with 36 B of SRAM reserve headroom; no new statics — the digest text reuses current_words.
  • Gate 3 hardware evidence (OLED on a real device) is still owed before merge.

Pins

Twin of the 7.14.3 unit; the two products' reset.c/dice_input.c/docs/tests are identical apart from the mnemonic-pager scratch buffers.

A user can currently prove their rolls were captured -- the digest is
SHA256(rolls) and they can recompute it -- but not that the rolls reached the
seed. The derivation is

    seed = SHA256( SHA256(int_entropy || rolls) || ext_entropy )

and of those three inputs the user holds exactly one. int_entropy is never
disclosed and ext_entropy comes from the host, which on our own stack generates
it inside the hdwallet transport and never surfaces it. Firmware that showed a
correct digest and then ignored the rolls would be indistinguishable from
outside.

Adds the mode that closes that, matching ColdCard's Dice-Rolls-Only:

    seed = SHA256(rolls)

The RNG draw is discarded rather than folded in, and the host's EntropyAck is
consumed but its bytes dropped, so the wire flow and every existing host are
unchanged. Nothing in the derivation is unknown to the user, so tools/
verify_dice_seed.py recomputes the whole wallet offline from the roll string
with no secret from the device and no network. Its BIP39 implementation was
checked against the reference `mnemonic` library over 600 vectors, 0 mismatches.

The choice is made ON THE DEVICE, after the digest confirm, not by a protobuf
field. A host can therefore neither select it nor force it, which is the same
property ColdCard gets by having no host entropy command at all -- and it needs
no device-protocol change, so no submodule pin moves.

It is opt-in and cannot become a default: confirm() requires a deliberate button
hold, and declining leaves the mixed derivation in place. That matters, because
the mode is strictly less forgiving. Mixing protects a user whose dice are bad;
dice-only protects a user whose device is bad. ColdCard's July 2026 disclosure
-- a five-year PRNG regression cutting the effective search space to about 72
bits -- is the case where only the second helped, which is why both modes should
exist and why this one must be chosen deliberately.

Staged as its own unit off the 7.15 candidate rather than into it: this is a new
derivation mode, and the SOP excludes new features from a product at acceptance.
Replaces the earlier "Dice Only?" confirm, which had a design flaw the dice
test exposed: a one-button confirm() ends only by hold or by the host's
Cancel, so it could not be declined without aborting the whole reset. The
choice is now a selector like roll entry itself -- short press toggles, hold
commits, MIXED first -- driven through the same button/ISR code, which is
factored out (dice_poll, dice_session_begin/end) rather than copied so the two
screens cannot drift apart in how they treat a stale press or the host ack.

Opting into dice_entropy now always yields a derivation the user can recompute
from what they hold. The host's EntropyAck is still consumed, so the wire flow
and every existing host are unchanged, but its bytes are dropped in both modes.

  MIXED: user = SHA256("KK\x01D" || rolls)
         seed = SHA256(SHA256("KK\x01SM" || device_draw || user))
         The 32-byte 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. Showing it is safe for the
         mirror-image reason it was unsafe under display_random: the other
         half is dice the host never sees.
  ONLY:  seed = SHA256(rolls). Coldcard's Dice-Rolls-Only, byte for byte. The
         device draw is discarded.

The default, no-dice reset is untouched. Host entropy stays there because it
is the one backstop against a device RNG that is broken but honest -- the
Coldcard July 2026 class of failure, which no on-device health test detects --
and it is removed exactly where it blocked verification.

Also: rolls with any face over 30% are refused before a digest is drawn
(Coldcard's rule); the digest is shown in full, not truncated to 8 bytes; the
mnemonic pager is factored into show_mnemonic_pages() so the device-entropy
words and the backup words share one code path and one DebugLink surface.

tools/verify_dice_seed.py gains --device-words for MIXED. Unit vectors were
computed in Python from the published formulas, not captured from this code,
and the same formulas are restated independently in the host suite; all three
agree on every vector.

The tag bytes are arrays, not string literals: "\x01D" is one byte in C.
The host tests that drive the on-device dice mode selector live on
keepkey:feat/dice-modes @ 28cfaf91d, a feature branch off canonical
reconcile/upstream-sync @ 7f538a9. Canonical cannot take them yet: the
release products do not carry the selector, and a version gate cannot tell
the 7.15 candidate apart from this 7.15 unit. When the unit is accepted and
ported to 7.14.3, the branch merges into canonical and every product re-pins.
…selector

Owner direction: the mode is a host-side choice made before the ceremony
starts, so the wallet can explain what is coming. ResetDevice.dice_entropy
alone is MIXED; with the new dice_only field it is DICE ONLY. dice_only
without dice_entropy is refused with a SyntaxError before any screen.

The device still gates it: a consent screen names the mode it was asked for
-- "Dice Only" / "Dice + Device", with the roll count -- and requires a hold.
This is the correct use of confirm() where the selector was not: on a
one-button device a confirm() ends only by hold or by the host's Cancel, and
cancelling the reset is exactly the right answer to a mode the user did not
choose. A host therefore cannot select dice-only silently; it can only ask.

Removes dice_mode_select() and its draw code. The button-poll and session
factoring stays, since the roll collector uses it.

Pins:
  deps/device-protocol -> 5517c8de3, feat/dice-only-field-7.14.3: the one
    dice_only commit cherry-picked onto the products' current pin 8545cd5b6.
    It is not based on canonical up/release-protocol (27d3fa1f6) because the
    twelve commits between carry EIP-712 streaming fields with no .options on
    either product, and the pb_callback_t gate would fail the build. The
    canonical-based twin, feat/dice-only-field @ 451e9a7, is the PR target;
    this pin retires when the products advance their dp pin to canonical.
  deps/python-keepkey -> feat/dice-modes: tests for the host-selected modes,
    with bindings regenerated from the canonical-based dp tip.
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).
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant