Skip to content

feat(reset): on-device dice-roll entropy in the ResetDevice flow - #341

Merged
BitHighlander merged 7 commits into
alphafrom
feat/dice-entropy-reset
Aug 5, 2026
Merged

feat(reset): on-device dice-roll entropy in the ResetDevice flow#341
BitHighlander merged 7 commits into
alphafrom
feat/dice-entropy-reset

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

What

ResetDevice(dice_entropy=true) collects dice rolls on the device with the single button before the internal entropy is displayed or leaves the device: short press advances a 1-6/undo selector, an 800 ms hold commits the selection (deliberately shorter than the 1200 ms confirm hold — 99 rolls at 1200 ms is a slog), 30 ms debounce on the raw EXTI edges. Roll targets follow the Coldcard convention: 50/75/99 rolls for 128/192/256-bit seeds.

Why device-side entry

Desktop-entered dice only defends while the device's RNG contribution stays secret; against the compromised-host + weak-device-RNG combination (the Coldcard failure class) the rolls must never touch the computer. This is the high-assurance half of the dice story — host-side mixing via EntropyAck continues to work unchanged for the convenient flow.

Where the mix happens

At the only safe seam in reset_init — after random_buffer(), before the display_random screen and EntropyRequest:

int_entropy = SHA256(rng32 || rolls)

The displayed Internal Entropy commitment is therefore the post-mix value, and sha256(displayed || external) still reproduces the mnemonic — the existing host verification recipe covers the dice path unchanged. A completion screen shows the roll count and the first 8 bytes of SHA256(rolls) so the user can confirm the device received exactly what was rolled.

The entry screen draws only in x≥128 and runs with display_constant_power(true) (PIN-matrix precedent: OLED supply current correlates with lit pixels, and rolls are seed material — the driver fills x<128 with the inverse of the right half).

Testability

  • DebugLinkDecision.input injects synthetic rolls in DEBUG_LINK builds ('1'-'6', 'u' undo; 40-char chunks so the decoded struct fits MSG_TINY_BFR_SZ — statically asserted).
  • DebugLinkState.dice_digest exposes SHA256(rolls).
  • pyk test_reset_device_dice: announcement → chunked injection with undo churn → digest parity vs a host-side simulation of the same append/undo rules → post-mix entropy → mnemonic check. 5/5 in test_msg_resetdevice.py against kkemu (4 pre-existing tests untouched); mutation check on the digest assert fails as expected.
  • gtest Dice.*: mix-construction vectors (5/5 in the built image).
  • OLED evidence: docs/security/evidence/dice-entropy-reset/ — the on-screen digest 6CFC611198F53A73 matches an independent host-side recomputation.

Builds

build result
emulator (kkemu) ✅ 5/5 pyk reset suite
device btc-only ✅ .text 364,084
device full, stacked on #339 + #340 ✅ .text 616,316 → ~35 KB ROM free

Landing order

  1. feat(reset): dice-entropy fields for on-device roll collection keepkey/device-protocol#119 (proto fields) — must merge first or CI's submodule fetch of pin 342174d fails.
  2. feat(reset): drive and verify on-device dice-entropy collection keepkey/python-keepkey#210 (bindings + debuglink + test) — pin 58d4e02.
  3. feat(rom): route device snprintf to the integer-only newlib engine (+24 KB ROM) #339 + chore(crypto): repin trezor-firmware with rolled blake2b rounds (+12 KB ROM) #340 (ROM reclaim) — the full variant does not fit without them.
  4. This PR.

Still owed (Gate-3)

On-device pass of the physical press path: short-press advance, 800 ms hold commit, undo, digest match against physically entered rolls. The ISR/debounce code (dice_on_press/dice_on_release) does not execute in the emulator at all.

ResetDevice(dice_entropy=true) collects dice rolls on the device with
the single button before the internal entropy is displayed or leaves
the device: short press advances a 1-6/undo selector, an 800ms hold
commits (distinct from the 1200ms confirm hold on purpose -- 99 rolls
at 1200ms is a slog), with a 30ms debounce window on the raw EXTI
edges. Roll targets follow the Coldcard convention: 50/75/99 for
128/192/256-bit seeds.

The mix happens at the only safe seam in reset_init -- after
random_buffer() and before the display_random screen and
EntropyRequest -- as int_entropy = SHA256(rng32 || rolls). The
displayed Internal Entropy commitment is therefore the post-mix value
and sha256(displayed || external) still reproduces the mnemonic, so
the existing host verification recipe covers the dice path unchanged.
A completion screen shows the roll count and the first 8 bytes of
SHA256(rolls) so the user can confirm the device received exactly what
was rolled.

The entry screen renders in x>=128 only: it runs with
display_constant_power(true) (PIN-matrix precedent -- OLED supply
current correlates with lit pixels and rolls are seed material), and
the display driver fills x<128 with the inverse of the right half.

Under DEBUG_LINK, DebugLinkDecision.input injects synthetic rolls
('1'-'6', 'u' = undo; 40-char chunks so the decoded struct fits
MSG_TINY_BFR_SZ, statically asserted) and DebugLinkState.dice_digest
exposes SHA256 of the collected string. Emulator coverage:
test_msg_resetdevice.py::test_reset_device_dice (announcement, chunked
injection with undo churn, digest parity against a host-side
simulation, end-to-end mnemonic check); gtest Dice.* covers the mix
construction with fixed vectors. OLED evidence in
docs/security/evidence/dice-entropy-reset (Gate-3 on-device pass of
the physical press path still owed).

Pins: device-protocol 342174d (dice fields), python-keepkey 58d4e02
(bindings + debuglink injection + test).
Adversarial review of the dice change found a host-controllable seed,
reproduced end-to-end on the emulator before the fix:

  ResetDevice #1 -> EntropyRequest (never acked; awaiting_entropy stays
  set, and nothing in the firmware ever clears it on an abort -- there
  is no reset abort, and CHECK_NOT_INITIALIZED still admits ResetDevice
  while one is mid-flight)
  ResetDevice #2 with dice_entropy -> host Cancel -> the new dice abort
  path memzeroes int_entropy and returns
  EntropyAck -> reset_entropy runs against 32 zero bytes, so the seed is
  sha256(0*32 || host_bytes)

The repro produced exactly the host-predicted 24 words. The zeroing was
introduced by the dice commit; before it, aborts left fresh random_buffer
output in int_entropy, which masked the stale flag. reset_init now
disarms awaiting_entropy on entry, so every abort -- dice, PIN mismatch,
strength, no_backup -- leaves the device unable to derive a seed from
host bytes alone. Covered by
test_msg_resetdevice.py::test_reset_reentry_disarms_entropy_ack, which
fails against the pre-fix build.

Button path (dice_input.c), all found by the same review:

- Release-edge bounce double-counted short presses, because press_start
  was retained across a bounce pair while every release re-qualified as
  a new short. Worse, chatter during a hold advanced the selector under
  the user, committing a digit they never rolled. Bounce releases are
  now retracted by the following press, and the UI loop may not consume
  queued shorts until the line has been quiet for a debounce window.
- Classification was split between the release ISR (release-time
  duration) and the UI loop (poll-time duration against a stale
  snapshot), so one press could register as both an advance and a
  commit. A single critical section now reads, classifies and drains in
  one step, and dice_committed makes each press cycle classify once.
- A hold that completed inside the poll gap was silently discarded: the
  release ISR now commits it.
- Presses begun before the host's ButtonAck accrued hold time and could
  commit on ack. The ISRs are gated on dice_accept, mirroring
  confirm_sm's button_request_acked check at the press edge.

Also: dice_digest is CONFIDENTIAL and cleared when the reset that
produced it ends (it was sticky for the rest of the boot and kept being
served over DebugLink); digest_hex is zeroed after the confirm screen;
the roll counter no longer renders an impossible 'ROLL 100/99' on the
final frame; and pin_font '1' (4px, not 8px) is centered on its own
metric.

Verified: 6/6 test_msg_resetdevice.py against kkemu (including the new
regression test), 5/5 Dice gtests, evidence recaptured.
@BitHighlander

Copy link
Copy Markdown
Owner Author

Adversarial review found a host-controllable seed — fixed in a22220b

I ran a four-lens adversarial review over this branch (ISR/timing, entropy/crypto, protocol, display), with every finding independently verified by a refutation pass. Ten survived. One was critical and exploitable, and I reproduced it end-to-end on the emulator before fixing it.

The critical bug (introduced by this PR)

ResetDevice #1                  -> EntropyRequest, host never acks
                                   (awaiting_entropy stays set; nothing in the
                                   firmware clears it on abort — there is no
                                   reset abort, and CHECK_NOT_INITIALIZED still
                                   admits ResetDevice while one is mid-flight)
ResetDevice #2 dice_entropy=1   -> host sends Cancel
                                   the dice abort path memzeroes int_entropy
EntropyAck(host bytes)          -> reset_entropy runs against 32 ZERO bytes
                                   seed = sha256(0^32 || host_bytes)

The repro produced exactly the host-predicted 24 words. No user interaction is needed — the host drives every step.

The zeroing is mine; before this PR aborts left fresh random_buffer() output in int_entropy, which masked the pre-existing stale-flag bug. reset_init now disarms awaiting_entropy on entry, which also hardens the pre-existing PIN-mismatch/strength/no_backup abort paths. Covered by test_reset_reentry_disarms_entropy_ack (keepkey/python-keepkey#210), confirmed to fail against the pre-fix build.

Button path (4 findings)

  • Release-edge bounce double-counted short pressespress_start was retained across a bounce pair while every release re-qualified as a new short. Worse: chatter during a hold advanced the selector under the user, committing a digit they never rolled — silently breaking the whole verifiability promise. Bounce releases are now retracted by the following press, and the UI loop cannot consume queued shorts until the line has been quiet for a debounce window.
  • Split-clock classification — the release ISR classified on release-time duration while the UI loop classified on poll-time duration against a stale snapshot, so one press could register as both an advance and a commit. One critical section now reads, classifies and drains atomically; dice_committed makes each press cycle classify exactly once.
  • Holds completing inside the poll gap were silently discarded — the release ISR now commits them.
  • Pre-ButtonAck presses accrued hold time and could commit the moment the ack landed. The ISRs are gated on dice_accept, mirroring confirm_sm's check at the press edge.

Hygiene / display (3 findings)

  • dice_digest is now CONFIDENTIAL and cleared when the reset ends (it was sticky for the rest of the boot and kept being served over DebugLink); digest_hex is zeroed after the confirm screen.
  • The counter no longer renders an impossible ROLL 100/99 as the final frame.
  • pin_font '1' is 4px wide, not 8 — now centered on its own metric.

Five further findings were refuted on verification (including a claimed press_input/yes_no conflict and a claimed 255.91-bit shortfall) and no code changed for them.

Re-verified after the fix: 6/6 test_msg_resetdevice.py against kkemu, 5/5 Dice.* gtests, exploit repro now returns Failure: Not in Reset mode, evidence recaptured, btc-only device build green.

The ButtonAck gate moved into the volatile dice_accept flag that the
button ISRs read, so the local was set but never read in builds without
DEBUG_LINK -- -Werror=unused-but-set-variable failed the device build.
The debug-injection branch now gates on dice_accept too, which is the
same condition.
Whitespace only -- comment alignment and two line wraps, per
clang-format-20 with the repo .clang-format (the version CI pins).
The drain gate also required the button to be up, so a tap immediately
followed by a hold left the advance queued: the hold committed the digit
the tap was meant to move off of, and the selector only caught up
afterwards. Same silent wrong-digit class as the bounce bug.

A retraction can only happen inside the debounce window after a release,
so once that window closes the count is final whether or not a new press
is already in flight. Dropping the button-up requirement lets the pass
that sees the in-flight hold apply the earlier tap first -- the loop
body already orders advances before commits.
@BitHighlander

Copy link
Copy Markdown
Owner Author

Two follow-ups pushed since the review comment above:

  • 4695c6fclang-format on dice_input.c (whitespace only). lint-format was the one red check; it passes now.

  • 7e20a99 — a defect in my own hardening fix, found by re-reviewing the rewritten button logic (the adversarial pass only saw the original). The short-press drain gate also required the button to be up, so a tap immediately followed by a hold left the advance queued: the hold committed the digit the tap was meant to move off of, and the selector only caught up afterwards. Same silent wrong-digit class as the bounce bug, just a different trigger.

    A retraction can only happen inside the debounce window after a release, so once that window closes the count is final whether or not a new press is already in flight — the button-up requirement bought nothing and cost correctness. The loop body already orders advances before commits, so dropping it makes the pass that sees the in-flight hold apply the earlier tap first.

Re-verified after both: 6/6 test_msg_resetdevice.py, 5/5 Dice.* gtests, format check clean against clang-format-20.

device-protocol -> cf308fd5e (merge of keepkey/device-protocol#119),
python-keepkey -> a8e41261c (merge of keepkey/python-keepkey#210).
Both previously pointed at the pre-merge branch commits; the old pins
remain ancestors of their base branches, so this is a pin move rather
than a content change. The pyk merge also brings in the taproot
confirmation-sequence commit that branch had and this one did not --
test_msg_signtx_taproot.py and test_msg_resetdevice.py both pass
against kkemu built from these pins (11/11), and the ARM full build is
unchanged at .text 616,604.
@BitHighlander
BitHighlander changed the base branch from develop to alpha August 5, 2026 01:55
@BitHighlander
BitHighlander merged commit e3f842b into alpha Aug 5, 2026
15 checks passed
BitHighlander added a commit that referenced this pull request Aug 5, 2026
Reconciles this branch with alpha now that the dice-entropy work (#341) has
landed there. The two are NOT in tension -- they are complementary, and
merging them clarified why:

  reset_init() keeps dice_entropy and drops display_random. The dice block
  folds rolls into int_entropy; the display block merely SHOWED that value.
  Removing the screen leaves dice untouched functionally.

  The comment above the dice block claimed the displayed entropy was a
  verifiable post-mix commitment. That was wrong and is corrected here: a
  host that supplies ext_entropy and reads the screen once computes
  SHA256(shown || ext) -- the seed pre-image -- and dice change nothing,
  because the displayed value is already post-mix. So the screen actively
  undermined the feature it appeared to support. The roll digest is safe by
  contrast: it hashes the user's own input, not seed material.

  Also carries #341's awaiting_entropy disarm, which closes the
  host-controllable-seed hole on aborted resets.

deps/python-keepkey -> b44f1b3, which retargets the two tests that asserted
the Internal Entropy ButtonRequest. They now send display_random=True and
assert the next message is PinMatrixRequest, testing the compatibility
claim directly.

Verified: ARM device build links (.text 616,620) and 6/6 reset tests pass
against an emulator built from this branch, dice test included.
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