Independent verifier for the dice-roll entropy of a COLDCARD Mkx / Q, matching the firmware roll by roll.
Third-party dice tools stop matching the Coldcard as soon as
Add some dice rolls into the mix is used — same digits, different seed. The
reason is that key 4 re-seeds the hasher with the current entropy instead of
concatenating the raw rolls. Nothing is broken and no entropy is lost, but the
scheme is undocumented, so no external tool reproduces it.
ccdice.py implements both formulas and refreshes the hash after every single
roll, so you can compare against the Coldcard screen key press by key press and
pinpoint exactly where any divergence appears. Single file, no dependencies, no
network, self-testing against five publicly reproducible vectors.
python3 ccdice.py --selftest
python3 ccdice.py 24This script is meant only to verify that the COLDCARD (Mk4 / Q) firmware
correctly implements the Dice Roll and Add some dice rolls into the mix
functions.
It must never be used to generate a seed that will hold funds. Typing the rolls of a real seed into a computer defeats the entire purpose of a hardware signer. Only enter throwaway test sequences, to be destroyed after verification.
It reimplements the Coldcard firmware mechanics exactly
(shared/seed.py, functions add_dice_rolls / approve_word_list):
| Stage | Formula applied |
|---|---|
Initial session (Dice Roll) |
SHA256(rolls_ascii) |
Later session (key 4) |
SHA256(current_entropy ‖ rolls_ascii) |
The entropy is then truncated to 16 bytes for 12 words, or kept at 32 bytes for 24 words, then converted into BIP39 words.
The official rolls.py and third-party web tools only implement
SHA256(all rolls concatenated). They do not model the re-seeding of the
hasher with the current entropy when key 4 is pressed, and therefore diverge
as soon as that function is used.
This is neither a bug nor a lost checksum: it is simply a chained mixing scheme that Coinkite does not document.
Both stem from the key 4 handler in the firmware's shared/seed.py.
1. Randomness checks are disabled on added rolls.
The firmware validates dice rolls only during the initial generation session:
it warns when a face exceeds 30% of the throws, and enforces a minimum roll
count. The key 4 path calls the same routine with those checks turned off.
After pressing 4, the Coldcard performs no distribution check and enforces no
minimum. You could add twenty rolls of all 1s and it would stay silent.
ccdice.py applies both checks to every block.
2. Truncation differs between 12 and 24-word seeds.
On confirmation, the entropy is truncated to 16 bytes for 12-word seeds and
kept at the full 32 bytes for 24-word seeds. This carries over into the
re-seeding value shown when key 4 is pressed:
| Seed length | Re-seeding value |
|---|---|
| 12 words | SHA256(16-byte entropy) |
| 24 words | SHA256(32-byte entropy) |
The rolls themselves hash identically in both modes, so a 12/24 mismatch between the script and the device stays invisible during input and only diverges at confirmation.
- Python 3.6 or later
- No external dependencies, no network access, no external files
- A Unix terminal for key-by-key reading (automatic fallback otherwise)
The 2048 BIP39 words are embedded in the script. On startup,
check_wordlist() rebuilds the canonical form of the reference file
english.txt from the bitcoin/bips repository — one word per line, each
ending with \n — and compares its digest against:
2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda
This is an equivalence check against the official reference, not a file read. If a word were altered, missing or out of order in the script, the digest would diverge and execution would stop.
⚠️ Do not confuse this with entropy. This digest concerns the wordlist and nothing else. The starting entropy of a dice session — the value the Coldcard displays before the first roll — is an entirely separate value:e3b0c442…7852b855, the SHA256 of the empty string.
python3 ccdice.py --selftestRun this before any use. The vectors come from observations made on real hardware, published publicly, and are reproducible by anyone on their own Coldcard.
All of them start from the same series of 60 rolls:
1111111111 2222222222 3333333333 4444444444 5555555555 6666666666
Checks the initial state of the pool, before any roll.
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
This is the SHA256 of the empty string. This value must match the one shown at the bottom of the Coldcard screen before the first roll.
Validates the core mechanics: hashing of the ASCII string, truncation to 16 bytes, BIP39 conversion.
SHA256 entropy : 76098bd44297998c1fd26314d49fbd560db54797e045d1e1773286f35e54f8b9
Truncated 16 B : 76098bd44297998c1fd26314d49fbd56
Expected words : invite era vital lunch kangaroo ship leg erase below pilot urban protect
The same 60 rolls, followed by 222 entered without leaving the input
screen.
SHA256 entropy : f0e6b7664f783e3afa14498aa1188ac229f0e30e31f8c92799421d3f8a65c632
Expected words : valley cup sunset page loop buddy tribe matrix melt angle cargo luggage
The pool value at the moment key 4 is pressed to open
Add some dice rolls into the mix, before any new roll.
5dd23ea0950ab9716a171affcaaf05145f9d35f8de1970d3bb2a70312a88dd7e
This is SHA256(76098bd4…d49fbd56), i.e. the hash of the entropy already
truncated to 16 bytes. This is the heart of the mechanism, and the exact point
where third-party tools break.
The same 60 rolls, confirmed, then 222 entered via key 4.
SHA256 entropy : f488fcc1c3a4865ff5c41d38e463d95ab145a7283b5aaf01a6e0149df469fc37
Expected words : virus elevator corn mansion embody copper strike lonely decline cart wagon remind
The demonstration lies in comparing vectors 3 and 5. Exactly the same 63 digits, in the same order. Only the way they are entered changes. Result: two entirely different seeds, each perfectly valid with a correct checksum.
Embedded wordlist: 2048 words, matches the BIP39 reference
SHA256 (canonical english.txt form): 2f5eed53a4727b4bf8880d8f3f19...
-> wordlist integrity check, unrelated to entropy
[OK ] SHA256 of empty string (starting entropy)
[OK ] 12 words / 60 rolls
[OK ] 12 words / 63 rolls (same session)
[OK ] Re-seeding entropy after [4]
[OK ] 12 words / 60 then [4] 222
ALL TESTS PASS
Exit code 0 if everything passes, 1 otherwise.
If a single test fails, do not use the script: it does not reproduce the
firmware mechanics.
python3 ccdice.py 12 # 12-word seed
python3 ccdice.py 24 # 24-word seedInput is taken one roll at a time, with no need to press Enter: the script
reproduces the behaviour of the Coldcard screen, which redisplays the hash after
every key press (screen_updater() in the firmware loop).
| Key | Action |
|---|---|
1 to 6 |
Enter a roll |
ENTER |
Confirm the block (equivalent to OK on the Coldcard) |
4 |
After confirming: continue with Add some dice rolls into the mix |
q |
Finish |
--------------------------------------------------------------------------
GENERATION
--------------------------------------------------------------------------
Entropy BEFORE input (compare with the Coldcard screen):
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
#0001 [3] 4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce
#0002 [5] 1a5f1e2e3f4c8b9d0a7e6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a392817
...
#0060 [6] 76098bd44297998c1fd26314d49fbd560db54797e045d1e1773286f35e54f8b9
Entropy AFTER truncation (16 bytes):
76098bd44297998c1fd26314d49fbd56
BIP39 words (60 rolls total):
invite era vital lunch kangaroo ship
leg erase below pilot urban protect
Block distribution: 1:10 2:10 3:10 4:10 5:10 6:10
[4] add more rolls [q] finish
| Element | Meaning |
|---|---|
Entropy BEFORE input |
Pool state when the block opens |
#NNNN [c] hash |
Roll number, face obtained, running SHA256 |
Entropy AFTER truncation |
Effective entropy used for the BIP39 conversion |
Block distribution |
Count per face |
Comparison points against the Coldcard:
- The entropy shown before the first roll of a block
- The hash after every roll, continuously
- The BIP39 words on confirmation
The hash shown during input is the full 32-byte SHA256 — that is indeed what the Coldcard displays. Truncation to 16 bytes (12 words) only happens on confirmation, and the script displays it separately.
Every ADD [key 4] block takes the re-seeding value as its BEFORE input
entropy — the one from vector 4. This is the most discriminating comparison
point.
The script reproduces the firmware test: if a face exceeds 30% of a block's rolls, it prints a warning equivalent to the Coldcard message "Distribution of dice rolls is not random". On a deliberately degenerate test series, this warning is normal and expected.
A warning is printed below 50 rolls (12 words) or 99 rolls (24 words), the thresholds enforced by the firmware.
- Run
--selftestand check that all five vectors pass. - On the Coldcard, open a dice input screen.
- Read the entropy shown before the first roll → it must be
e3b0c4…52b855. Check that it matches in the script. - Enter the rolls in parallel on both, comparing the hash after each press.
- Confirm on both sides, compare the resulting words.
- Press
4on both sides, compare the re-seeding value, then continue entering in parallel. - Destroy the roll notes and exit the test seed.
The point of comparing roll by roll: if a divergence occurs, you know at which exact roll it appears, instead of noticing a mismatch at the end of a block.
- Covers only the dice-only path. On the standard
New Seed Wordsflow, the Coldcard mixes in entropy from its internal TRNG: the result is non-reproducible by design, and that is not an anomaly. - Does not verify firmware authenticity or signature. Malicious firmware can apply the correct formula and still divert funds elsewhere. This verification is a mathematical consistency check, not a proof of device integrity.
- English wordlist only.
MIT — see LICENSE.