AI analysis · SeedSigner

SeedSigner dice entropy

An AI analysis of how SeedSigner turns dice rolls into a seed phrase, traced dynamically on the shipped code, cross-checked against other independent implementations, and tracked across every release since 2020 to establish whether the same rolls have always produced the same seed. Every claim below is reproducible; instructions are included.

Reviewed
0.8.7
History
Every tag, since 2020
Behavior changes
3
Analyzed by
Claude Fable 5
The short version

No defects found in the dice path.

Companion document

The construction itself is documented separately in Dice to seed: the shared recipe, which other Bitcoin projects implement it, and what imperfect dice actually cost. This page covers only SeedSigner's implementation.

1The implementation

Reviewed at tag 0.8.7, commit e0a80d4b. The function is eighteen lines, which is a good sign.

def generate_mnemonic_from_dice(roll_data: str, ...):
    entropy_bytes = hashlib.sha256(roll_data.encode()).digest()

    if len(roll_data) == DICE__NUM_ROLLS__12WORD:   # 50 rolls
        entropy_bytes = entropy_bytes[:16]          # keep 128 bits

    return bip39.mnemonic_from_bytes(entropy_bytes, ...).split()
Verified mnemonic_generation.py L64–81 at tag 0.8.7

The digits are hashed as text, not converted to base 6, not reduced to bits. For a 12-word seed only the first 128 bits of the hash are used. This is the shared construction described in the companion document.

The roll count is fixed on purpose

Verified The entry view passes return_after_n_chars, so it returns only after exactly the required number of entries. You can neither stop early nor add extra rolls.

Fixing the count is a design decision rather than an oversight. Offering extra rolls would imply 99 is insufficient, which it is not: 99 fair rolls deliver 255.9 of a 24-word seed's 256 bits, and a 100th roll overshoots what the seed can hold. The companion document works through what bias would actually justify rolling more, briefly, a die so defective you would replace it.

2Verification

Traced, not just read

Claiming “no random number generator is involved” from reading code is an inference. It was also demonstrated: the shipped function was run with hashlib.sha256 instrumented to log every construction and update, and with trip-wires on os.urandom, secrets and random.

input                            : 99 ASCII digits
RNG calls DURING seed generation : 0
RNG calls at import time         : 1
   os.urandom <- embit/util/ctypes_secp256k1.py  (context randomization)

sha256 events in the entropy path: 2 calls
   new    <-  99 bytes  655152231316521321611331544441236...
   digest -> 51531761ec7a738946e0b9f46bb11320a695495430e345c14f01ad8b3b898a6d
   new    <-  32 bytes  51531761ec7a738946e0b9f46bb11320...
   digest -> 867da839c3513afebd643a5f4b3fd635dd2e335b585f5b7a1e949e17fa6e2e3d

matches published vector         : True
Measured dice_dynamic_trace.py, run against an export of tag 0.8.7
Result

Zero randomness calls occur while the seed is generated. The single call in the process happens at import, when embit randomizes its libsecp256k1 context as side-channel hygiene, unrelated to seed material, and it occurs whether or not the dice screen is ever opened. Stated precisely because a naive trip-wire reports it and it looks alarming.

The accounting is complete: two SHA-256 calls. The first consumes exactly the 99 digits typed and produces the entropy; the second consumes that digest to derive the BIP-39 checksum. Nothing else enters the chain.

Against the published vectors

SeedSigner publishes two worked examples in docs/dice_verification.md. Both were reproduced without using its code, via a from-scratch BIP-39 implementation written from the specification, and independently via Trezor's python-mnemonic.

InputExpectedResult
99 rolls 6551522313…eyebrow obvious such suggest poet seven…exact
50 rolls 6551522313…hole luggage safe present express tragic…exact

The published fingerprint 8d9cced8 also derives correctly from the 12-word phrase, checking the whole chain from dice to Bitcoin keys rather than just the word lookup. The from-scratch implementation was validated against the official BIP-39 test vectors first, 24 of 24, so it is a known-good reference rather than a coincidental agreement.

Against other implementations

Two levels of comparison, kept distinct because they are different strengths of evidence:

Independently written projects produce the same seed from the same rolls. The companion document covers them in detail.

3Results

Verdict

No defects found in the current implementation. The dice path takes the digits the user typed, hashes them once, and derives a BIP-39 mnemonic. Nothing else enters, no randomness is consulted, and the result matches every other implementation of the shared construction and the project's own published vectors.

The history is less uniform than the current code suggests, three behavioral changes, all before February 2022, and that is the substantive finding of this analysis.

4Every release, checked

A subtle way for a seed implementation to fail is to change quietly, producing different seeds from the same input across versions without anyone noticing. The entire release history was walked, every tag back to December 2020, diffing the dice path at each.

It changed three times, all within a six-month window ending February 2022. Every release since produces identical seeds from identical rolls.

ReleasesDatesConstructionA rolled six
recorded as
Same seed as today?
0.0.1 – 0.0.2Dec 2020no dice feature yetn/an/a
0.2.0 – 0.4.3Jan – Jul 2021int(rolls, 6), last word forced through abandon, zeroing three entropy bits0No
0.4.4Aug 2021int(rolls, 6) → 32 bytes little-endian0No
0.4.5Nov 2021sha256(rolls)0No
0.4.6 – 0.8.7Feb 2022 – presentsha256(rolls), first 16 bytes at 50 rolls6Yes
The 12-word (50-roll) option arrives at 0.5.0; before that dice produced 24-word seeds only. 24-word results are identical from 0.4.6 onward, 12-word from 0.5.0.

What changed, and why

CommitDateStated rationaleEffect on the seed
f8ab179f2021-01-15“New module structure”Original dice.py, rolls parsed as a base-6 number.
bb54af052021-08-20“chaining camera entropy; mnemonic methods factored out”Changed. Dropped the abandon final-word substitution, which had been zeroing three entropy bits.
5422aaf02021-10-13“make dice seed generation verifiable outside seedsigner”Changed. Base-6 parsing → sha256.
422427582022-01-05“Remove special case for dice roll 6”Changed. A rolled six stopped being recorded as 0.
451757a02022-01-05“Test dice roll math against a set of known dice rolls”Added pinned known-answer vectors.
dac78b3a2022-03-23“12- and 24-word dice roll seed creation”Added the 50-roll path. 24-word path untouched.

The churn stopped the same week known-answer tests were added, and the construction has not moved since.

The direction of travel is legible from the commit messages. The base-6 approach worked but was idiosyncratic, so nothing else could reproduce a SeedSigner dice seed; 5422aaf0 adopted the construction other tools already used, expressly to make external verification possible. The six-recorded-as-zero quirk was a leftover from base 6, base 6 has no digit 6 which survived the hash switch by two months. On the day it was removed, pinned test vectors landed.

If you kept old roll numbers

Re-entering old rolls on a current release will not give you back your original seed. Every release since 0.4.6 hashes the digits exactly as you rolled them, and the earlier releases did not.

The rolls are still a route back. Every construction in the table above is a deterministic function of the digits you entered, so an old seed can be recovered off-device using the formula for its era. What changed is which formula, not whether one exists.

Your written words remain the primary backup and are unaffected. Treat this as the route back if those are lost, not a reason to keep rolls instead of words. If you do reconstruct, run the tool yourself on a machine that is offline, and treat the output as a live seed from the moment it appears.

Verified Since 0.4.6 (February 2022) the entropy derivation and the recorded input have both been unchanged at every release tag since, established by diffing rather than assumed.

Testing

The dice path is covered by pinned known-answer tests, not merely validity checks. tests/test_mnemonic_generation.py at 0.8.7 contains test_known_dice_rolls() and test_50_dice_rolls(), each pinning several roll sequences to expected mnemonics, plus test_verify_against_coldcard_sample().

A silent change to the algorithm would therefore break the build on both the 12- and 24-word paths. Given the history above, that coverage is doing real work.

5Degenerate cases

Typing the same digit fifty times

SeedSigner accepts it. Entering 5 fifty times produces a seed, and no guard objects.

Inferred Rated low because it is user-caused and user-visible: it requires actively deciding to type one digit repeatedly instead of rolling a die, which defeats the entire ceremony the user chose to perform. This is categorically different from a device silently producing weak entropy.

The intuitive guard is the wrong one. A “did you use enough different numbers?” check passes 123123123…, three distinct faces, about 7 bits of real randomness, while rejecting a two-faces-only sequence that genuinely carries 99 bits. The dangerous property is repetition, not low variety. The companion document covers what does work, and where those guards fall short.

Stopping early, or adding extra rolls

Verified Neither is possible. return_after_n_chars returns only on exactly 50 or 99 entries. The back button exits the flow without producing a seed.

Non-English wordlists

Verified generate_mnemonic_from_dice accepts a wordlist_language_code and passes it to bip39.mnemonic_from_bytes. The entropy derivation is unaffected, the same rolls give the same 256 bits regardless of language; only the words rendering those bits differ.

6Adjudication record

Read this before filing a finding

Everything below was raised and consciously dispositioned. The reasoning is recorded, not just the verdict, so it can be attacked efficiently rather than rediscovered. These are not settled, the weakest point on each entry is where this analysis is most likely wrong. Start there.

1. The dice path might consult a random number generator

Refuted by dynamic tracing, not just by reading: zero RNG calls during seed generation, with complete accounting of both SHA-256 calls.

Weakest point: the trace runs the Python function on a workstation, not the shipped image on device. A divergence would have to be introduced below the Python layer, in the interpreter or the OS build, which is the same gap as §7.

2. The algorithm may have changed silently across releases

Confirmed, three times, all before February 2022.

Weakest point: this is the finding most likely to still be incomplete. It rests on diffing the dice path at each tag, and before mnemonic_generation.py existed that code lived in view files under different names. A change hidden in a helper the diff did not follow would be missed.

3. The test suite does not pin the answer

False, and here is the trap that produces it. Three tests pin dice output to expected mnemonics. Reading test_dice_rolls() alone suggests otherwise, it asserts only validity, and it is easy to generalize from that one function to the file. Read the rest before concluding the construction is unpinned.

Weakest point: the tests pin the current construction, so they guard the future rather than the past. They could not have caught the 2021 changes, which predate them.

4. 99 rolls may not be enough entropy

99 rolls yield 255.9 bits against a 256-bit seed. Extra rolls overshoot what the seed can hold.

Weakest point: the bit arithmetic assumes SHA-256 behaves as an ideal randomness extractor. Standard practice, and the margins are far too large for the difference to matter, but it is an assumption rather than a proof.

5. SeedSigner may diverge from other implementations

Refuted. Independently written projects produce identical output.

Weakest point: only Coldcard was executed against SeedSigner. The others were confirmed by reading source, which is weaker evidence, and all were read at current revisions rather than at the versions any given user is running.

6. Imperfect dice weaken the seed

Addressed quantitatively in the companion document: a realistically bad die costs about 11 bits of 256.

Weakest point: min-entropy figures assume the attacker knows the die's bias exactly. That is deliberately conservative, but the modeling of what a “realistically bad” die looks like is an assumption, not a measurement of real dice.

7What we did not verify

This analysis reads and executes source at tag 0.8.7. It does not establish that the release image users actually run contains that source, which is the seam where the Coldcard Mk3 defect that prompted this work actually lived. Their seed.py looked correct; the problem was which rng.c got linked.

That gap is closable, and by you rather than by us: SeedSigner OS supports reproducible builds. Building the image from source in Docker should yield a SHA-256 matching the hash published with the release. A reader who wants the full chain, from typed digits through to the bytes on their microSD card, should run that build and compare. It is a stronger check than anything in this document.

Also not covered: the coin-flip path, which uses the same construction and the same reasoning but was not separately vector-tested; and every other SeedSigner subsystem.

8How to check this yourself

The history claims, with git alone

git clone https://github.com/SeedSigner/seedsigner
cd seedsigner

# every commit that touched the function
git log -S "generate_mnemonic_from_dice" --oneline -- '*.py'

# the three behavioral changes
git show bb54af05    # abandon-substitution removed
git show 5422aaf0    # base-6 -> sha256
git show 42242758    # six no longer recorded as 0

# the construction at any release
git show 0.4.5:src/seedsigner/views/seed_tools_view.py | grep -n 'roll_data'
git show 0.8.7:src/seedsigner/helpers/mnemonic_generation.py

The trace and the vectors

dice_dynamic_trace.py and dice_validation.py accompany this document. Both need pip install mnemonic embit==0.8.0 in a virtual environment.

With your own AI assistant

Prompt

Read src/seedsigner/helpers/mnemonic_generation.py in github.com/SeedSigner/seedsigner at tag 0.8.7, function generate_mnemonic_from_dice.

1. How do dice rolls become seed entropy, hashed as text, or converted numerically?
2. Reproduce the two worked examples in docs/dice_verification.md without using the project's own code: implement BIP-39 from the specification, and separately cross-check with Trezor's python-mnemonic.
3. Validate your from-scratch implementation against the official BIP-39 vectors first.
4. Walk the git history across every release tag. Has the dice construction ever changed? If so, when, and would the same rolls produce a different seed?
5. Instrument hashlib.sha256 and trip-wire os.urandom, secrets and random, then run the function. What is called, and when?

Tell it explicitly not to evaluate the result by testing whether the output looks random. Hashing anything produces output that passes randomness tests, including a hash of a terrible input, so such tests cannot detect a weak seed. Read the deployment-reality section of CONVENTIONS.md first, reviewers repeatedly produce confident findings that do not apply to air-gapped hardware.

What ships with this page

Every script behind the results above is in the repository beside this document. Each one prints what it checked rather than a verdict. Only evidence/verify_constructions.py is standard library only; the other three need pip install mnemonic embit==0.8.0 in a virtual environment.

FileWhat it establishes
dice_dynamic_trace.pyThe dynamic trace in §2: runs the shipped function with the entropy primitives instrumented, so “no random number generator is involved” is demonstrated rather than read.
dice_validation.pyThe cross-checks in §2, three ways that share no code: a from-scratch BIP-39 implementation, python-mnemonic, and embit.
dice_coldcard_crosscheck.pyThe 4,000-sequence run against Coldcard’s transcribed construction.
evidence/verify_constructions.pyIndependent of SeedSigner’s code entirely. Self-tests against the official BIP-39 vectors, then reproduces the published vectors from the companion document. Run it from evidence/.

9Scope and provenance

Analysis date
2026-08-03
Model
Claude Fable 5 (claude-fable-5), in Claude Code
Reviewed
Tag 0.8.7 = e0a80d4b; every tag walked for history
Executed against
python-mnemonic, embit 0.8.0, a from-scratch BIP-39 implementation, the official BIP-39 vectors, and Coldcard's transcribed construction
Source-read only
Krux, Kern, Seed Tool, Gordian seedtool-cli, iancoleman.io
Human direction
Scope, the ecosystem survey and the full-history review were directed by SeedSigner's lead developer. Several conclusions were revised in response to challenges.
What it is not
An audit. No independent engagement, no certification.

Traps, claims that look right and are not

Findings here went to fresh agents instructed to refute them. Each of the following survived a first reading and turned out to be false. They are recorded because a reviewer working the same sources will meet the same traps, and because corrections made after publication will be listed here too.

Plausible claimWhy it is wrong
“Exactly one behavioral change, in 0.4.5”There are three. bb54af05 (0.4.4) removed an abandon substitution that had been zeroing three entropy bits; 42242758 (0.4.6) stopped recording a rolled six as 0. The stability cutoff is 0.4.6, not 0.4.5.
“The test suite does not pin dice output”Three tests pin expected mnemonics. The claim comes from reading test_dice_rolls() and not the rest of the file.
“The pre-0.4.5 interface collected digits 0–5”The interface always showed 1–6; a six was mapped to 0 internally. The inference is sound, base-6 parsing does reject the character 6, and the conclusion is still false.
“Eleven releases since” / “about fifteen lines”Nine releases since 0.4.6; eighteen lines. Count, do not estimate.