AI analysis · SeedSigner
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.
No defects found in the dice path.
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.
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()
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.
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.
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
dice_dynamic_trace.py, run against
an export of tag 0.8.7Zero 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.
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.
| Input | Expected | Result |
|---|---|---|
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.
Two levels of comparison, kept distinct because they are different strengths of evidence:
3238f6fd and run: 4,000 random roll sequences produced zero
mismatches, alongside both published vectors.Independently written projects produce the same seed from the same rolls. The companion document covers them in detail.
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.
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.
| Releases | Dates | Construction | A rolled six recorded as | Same seed as today? |
|---|---|---|---|---|
| 0.0.1 – 0.0.2 | Dec 2020 | no dice feature yet | n/a | n/a |
| 0.2.0 – 0.4.3 | Jan – Jul 2021 | int(rolls, 6), last word forced through abandon, zeroing three entropy bits | 0 | No |
| 0.4.4 | Aug 2021 | int(rolls, 6) → 32 bytes little-endian | 0 | No |
| 0.4.5 | Nov 2021 | sha256(rolls) | 0 | No |
| 0.4.6 – 0.8.7 | Feb 2022 – present | sha256(rolls), first 16 bytes at 50 rolls | 6 | Yes |
| Commit | Date | Stated rationale | Effect on the seed |
|---|---|---|---|
f8ab179f | 2021-01-15 | “New module structure” | Original dice.py, rolls parsed as a base-6 number. |
bb54af05 | 2021-08-20 | “chaining camera entropy; mnemonic methods factored out” | Changed. Dropped the abandon final-word substitution, which had been zeroing three entropy bits. |
5422aaf0 | 2021-10-13 | “make dice seed generation verifiable outside seedsigner” | Changed. Base-6 parsing → sha256. |
42242758 | 2022-01-05 | “Remove special case for dice roll 6” | Changed. A rolled six stopped being recorded as 0. |
451757a0 | 2022-01-05 | “Test dice roll math against a set of known dice rolls” | Added pinned known-answer vectors. |
dac78b3a | 2022-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.
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.
6 recorded as 0,
then used the full digest for 24 words. That is the same convention the
companion document's addendum describes, and
verify_constructions.py in this
repository already implements it.6 recorded as 0. No shipping tool does this,
but the formula is in the table above. Before 0.4.4, a word substitution zeroed
three entropy bits on top of that.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
dice_dynamic_trace.py and dice_validation.py accompany this document.
Both need pip install mnemonic embit==0.8.0 in a virtual environment.
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.
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.
| File | What it establishes |
|---|---|
dice_dynamic_trace.py | The 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.py | The cross-checks in §2, three ways that share no code: a from-scratch BIP-39 implementation, python-mnemonic, and embit. |
dice_coldcard_crosscheck.py | The 4,000-sequence run against Coldcard’s transcribed construction. |
evidence/verify_constructions.py | Independent 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/. |
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 claim | Why 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. |