feat(reset): on-device dice-roll entropy in the ResetDevice flow - #341
Conversation
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.
Adversarial review found a host-controllable seed — fixed in a22220bI 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)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 Button path (4 findings)
Hygiene / display (3 findings)
Five further findings were refuted on verification (including a claimed Re-verified after the fix: 6/6 |
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.
|
Two follow-ups pushed since the review comment above:
Re-verified after both: 6/6 |
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.
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.
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— afterrandom_buffer(), before thedisplay_randomscreen andEntropyRequest: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 ofSHA256(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.inputinjects synthetic rolls in DEBUG_LINK builds ('1'-'6', 'u' undo; 40-char chunks so the decoded struct fitsMSG_TINY_BFR_SZ— statically asserted).DebugLinkState.dice_digestexposesSHA256(rolls).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 intest_msg_resetdevice.pyagainst kkemu (4 pre-existing tests untouched); mutation check on the digest assert fails as expected.Dice.*: mix-construction vectors (5/5 in the built image).docs/security/evidence/dice-entropy-reset/— the on-screen digest6CFC611198F53A73matches an independent host-side recomputation.Builds
Landing order
342174dfails.58d4e02.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.