Entropy you see.
An offline, single-file web app that turns physical dice rolls into a valid 24-word BIP-39 seed phrase — ready to import into a Bitcoin wallet, or into any of the many other cryptocurrencies that use the same standard. It also prints the dice-to-word tables it reads from.
Live demo at: https://bip39.postcogito.org
Share recombiner at: https://bip39.postcogito.org/codex32_recover.html
ArUco dice reader at: https://bip39.postcogito.org/dice_scanner.html
You need: a coin, and four ordinary six-sided dice. (One die works — you just roll it four times.)
| File | What it is |
|---|---|
bip39_generator.html |
The whole thing. No build step, no dependencies, no runtime network calls beyond one connectivity probe (see below). Open it in a browser. |
social-card.png |
1200×630 link-preview card for the generator. Must be served next to the HTML for og:image to resolve. |
social-card-recover.png |
Same, for the recombiner page, built from codex32-recover-logo.png. |
codex32_recover.html |
Standalone share recombiner. Rebuilds the 24 words from codex32 shares. Separate file on purpose — see below. |
social-card-scanner.png |
Same, for the dice reader, built from dice-scanner-logo.png. |
tools/make-social-card.py |
Regenerates all three cards from the project artwork. Deterministic — same inputs, same bytes. |
LICENSE-jsQR.txt |
Apache-2.0 text for the QR decoder inlined in the recombiner. |
dice_scanner.html |
Standalone reader for 3D-printed ArUco dice. Reads a whole throw by camera and counts it, so the dice themselves can be tested for fairness. Deliberately stops at words — see below. Built, not hand-edited: run tools/build-dice-scanner.py. |
tools/dice_scanner.src.html, tools/scanner-app.js |
Source for the reader. The build inlines js-aruco2 and the wordlist into the page above. |
tools/build-dice-scanner.py |
Builds dice_scanner.html from those two, js-aruco2 2.0.0 (npm, sha1-pinned) and the wordlist lifted out of bip39_generator.html. Re-run it and diff. |
LICENSE-js-aruco2.txt |
BSD text for the marker detector inlined in the reader. |
dice/ |
The printable dice: ten binary STLs (body + marks per die) and a Bambu Studio project that merges, arranges and sets them up. |
dice39-bw.png |
Line-art mark used on the printed sheets. Flattened onto white, quantised to 8 greys and inlined as a data: URI — the sheets print in black and white, so the colour logo would only muddy. |
codex32-recover-logo.png |
Source artwork for the recombiner (251×219). Inlined into codex32_recover.html as its mark and favicon, and used for that page's link-preview card. |
dice-render.png |
The five dice rendered from the STLs in dice/, shown on the reader's setup tab. Regenerate with tools/make-dice-render.py — it renders the shipped geometry, so the picture cannot drift from what someone prints. |
dice-scanner-logo.png |
Source artwork for the dice reader (213×212). Inlined into dice_scanner.html as its mark and favicon by the build, and used for that page's link-preview card. |
dice39-logo.png |
Source artwork for the generator (251×213). The app does not load it at runtime — a downscaled copy is inlined as a data: URI, so the HTML stays self-contained. Re-inline from this file if the logo changes. |
The same reason as the recombiner, only sharper. bip39_generator.html cannot
reach a camera, storage or the network, and an auditor can confirm that in
seconds. Merging a reader into the page that handles seed words would end that
however honest the code, so the reader lives in its own file and stops at
words.
It is close to being a generator — it is one checksum short — and that is exactly why it stops. A person carries the words across by hand, typing the dice code rather than the word, because the generator resolves the code independently and a disagreement is then visible. A misread QR fails its checksum loudly; a misread die yields a different but perfectly valid BIP-39 word, silently. That transcription step is the only place a person can catch it, so nothing here is allowed to remove it. For the same reason the reader carries no copy of the dice-to-word tables: checking its word against its own table would be checking the reader against itself.
24 or 12 words, chosen at the top of step 1. 24 is the default and what the page recommends; 12 exists because most hardware wallets show 12 by default and it is half as much to write down and check by hand. Both are far past anything brute-forceable. Switching length clears the boxes: words 1..N are used verbatim, so the same words at 12 and at 24 are two different phrases for two different wallets, and carrying them across the switch would silently change which one was being built.
- You supply N words,
minUser ≤ N ≤ maxUser— 12..22 at 24 words, 6..10 at 12 — from dice + the printed tables. They occupy positions 1..N and are used verbatim, 11 bits each. - Positions N+1..T-1 come from a device keystream:
SHA256(seed ‖ counter), read 11 bits at a time. - The last word straddles the boundary, and this is the one place the two
lengths genuinely differ: at 24 words it is 3 device entropy bits then an
8-bit checksum, at 12 it is 7 then 4. The checksum is that many bits off the
top of
SHA256(entropy)— the whole first byte at 24 words, its high nibble at 12. Transpose either of those and you get a phrase that passes its own checksum and opens a different wallet, with nothing on screen to say so, which is why both are read from one table (PHRASE_SIZES) rather than written out per length. - A 12-word checksum is 4 bits, so it lets through 1 wrong phrase in 16 against 1 in 256 at 24 words. The page says the right number for the length you are in; a green tick is sixteen times weaker evidence at 12.
- The seed is
SHA256(crypto.getRandomValues(32) ‖ pointer samples). The CSPRNG is the floor; the entropy pad is additive, not load-bearing.
Why the cap is two short of the total. Word 24 always carries the checksum, so a word typed into that position would have to be silently overwritten. Capping user input two short of the total means no word you type is ever altered — and the results panel proves it, with a per-word "you typed X → output X ✓" list.
Printed from the app itself (Print the dice tables + worksheet), generated in-page
from the same bundled wordlist the app validates against — so the tables cannot drift
out of sync with the dictionary, and one sha256sum bip39_generator.html covers both.
Six landscape pages: two per table, a recording worksheet, and a final page
covering what to do once the last word is written.
Procedure. Per word: flip a coin to choose the table, then roll two pairs of dice
(first pair = column, second pair = row). If the cell says REROLL, roll the dice
again on the same table — do not re-flip the coin.
Read each pair left to right, never smallest-first. Column labels are ordered
(3-5 is first die 3, second die 5), so sorting a pair low-to-high would fold
5-3 onto 3-5, double the weight of mixed pairs against doubles, and make 15 of
the 36 columns unreachable. The sheets would still yield words; they would no
longer be uniform, and nothing downstream would notice — the coin-skip detector
only tests parity. This is why the printed band states the rule and works an
example rather than leaving die order implied.
- Table A = words 1, 3, 5, … of the BIP-39 list. Table B = words 2, 4, 6, …. 1024 words each, disjoint, together the full 2048.
- The coin contributes exactly 1 bit, the dice exactly 10 → 11.000 bits per word, uniform, verified by enumeration.
- 1296 dice outcomes cannot be spread evenly over 1024 words, so 272 cells say
REROLL. Rejection is unavoidable: exact uniformity over 2048 needs2^11 | 6^k, i.e. k ≥ 11 dice. Cost is a 21% reroll rate, 1.27 throws per word. REROLLcells sit atfloor(i × 1296/272), deterministic so anyone can regenerate and diff the tables. Their positions don't affect the distribution.
Why parity and not a range split. Splitting by odd/even index rather than
1–1024 / 1025–2048 means each table still spans abandon…zoo, so neither looks
like a truncated half-dictionary — and the check for a skipped coin becomes one bit
of the word index.
Sorted-pairs detector. The one mistake the printed procedure actively
invites, and the only one no other check can see. The tables are read left to
right — first die of a pair names the column digit, second names the row digit
— so sorting a pair low-to-high folds 5-3 onto 3-5: fifteen of the
thirty-six columns become unreachable, doubles lose half their weight against
mixed pairs, and every word that comes out is still a perfectly valid BIP-39
word. The coin detector only tests parity and the shape detectors only test the
wordlist, so nothing downstream notices; it costs roughly a bit and a half per
word, silently.
Each word appears once in one table, so its position recovers the exact cell
that was looked up: if the pairs were sorted, the word came off the sorted cell.
A pair is non-descending with probability 21/36, so a word looks sorted with
probability (7/12)² = 49/144 and N words by chance with (49/144)^N — about 1
in 644 at six words, 1 in 415,000 at twelve. Both directions are checked, since
sorting high-first is the same misreading and costs the same bits. Suppressed
when every word is identical: one word is one cell, and it would read as a
second independent finding about a mistake the shape notice already names.
Typed-pattern detectors. Two shapes dice cannot produce are flagged in the
results panel: all N typed words identical, and typed word indices in
non-decreasing or non-increasing order. Both mean the boxes were filled in by
hand rather than read off a throw. Ordered indices occur by chance with
probability ~1/N! per direction — under 1 in 10⁸ even at the minimum N = 12,
and ~1 in 6×10²⁰ at N = 22, against 1 in 2048 for the coin detector at N = 12.
They warn, never block. The opening sentence of every warning — coin, shape and
sorted-pairs alike — pulses four times when it appears, then settles. Four and
not forever: a permanent blink reads as decoration and stops being seen within
seconds, and something indefinite is hostile to sit beside while you check 24
words. At roughly 0.9 Hz it stays well under the 3 Hz flash threshold, and
prefers-reduced-motion turns it off — the sentence is bold and amber either
way, so the motion is an accelerator and never the message. All-identical also trips the coin check trivially, so
the coin notice is suppressed in that case: one repeated word is not a
1-in-2^(N−1) coincidence, and saying so would be false.
Why there is no entropy meter. Entropy is a property of the process that generated a value, not of the value; a phrase drawn uniformly and one picked by hand are indistinguishable by inspection. Nor can it be estimated statistically from a single 32-byte sample — estimators like those in NIST SP 800-90B need long streams from a source, and no test has meaningful power on 256 bits. A score would also be actively harmful: it invites rerolling until the number looks good, and rerolling conditioned on the output is precisely what turns a uniform distribution into a biased one. So the app accounts for bits by construction and tests for specific known mistakes instead. Note also that repeated words are not a red flag — 12.7% of honest 24-word phrases contain one, so warning about them would cry wolf on one phrase in eight.
The coin-skip detector. Reading every word off one table costs 1 bit per word.
Because the tables are parity-split, all-N-words-same-parity is a 1-in-2^(N−1)
coincidence if the coin really was flipped (1 in 2048 at N=12, 1 in 2M at N=22), so
buildMnemonic flags it and the results panel says so. It warns, never blocks.
A word box also accepts the throw itself instead of the word, and the printed
instructions say so. It used to be off unless switched on with
?dice=true or a DICE39_CONFIG flag; both are gone, along with the
CONFIGURATION block that existed only to hold that flag. Nothing else read it.
Nothing collides: a dice code is [abht]?[1-6]{4}, and no BIP-39 word is
digits, so ordinary typing falls through untouched.
Two forms are accepted, and separators (spaces, dashes, dots) are ignored:
| You type | What happens |
|---|---|
3526 |
Names one cell on each table. The box offers both words as chips — A eyebrow, B fringe — and you click the one your coin gave. |
a3526 / h3526 |
Table A directly. b/t for Table B. |
Why the four dice alone are ambiguous. The tables are parity-split, so the
coin is a genuine bit of the word index — 3526 is a different word on A than
on B. Defaulting to one table would silently discard the flip the user actually
made and cost a bit per word, so the coin is either stated in the code or asked
for with the chips. It is never guessed.
Under each box a note shows what was looked up — table, and the four dice as
pips (⚀⚅ ⚁⚃), with the exact column and row labels in the tooltip — so it can
be checked against the printed sheet. The lookup is automated; the checking is
not. This only saves reading a cell off paper, and a transcription error still
produces a perfectly valid word.
A throw landing on a REROLL cell is left in the box as typed rather than
cleared, flagged with ↻, and blocks that position until it is replaced.
Because rerollPositions() is shared between the tables, a throw is REROLL on
both tables or on neither — the chips never offer one live word beside one dead
one, though the code handles that case rather than depending on it.
The pips are Unicode U+2680–U+2685, not a custom font. DejaVu Sans, Noto
Sans Symbols2 and Segoe UI Symbol all carry them and the CSS names those first;
where none is installed, a canvas measurement detects it and the note falls back
to printing the pair labels as digits.
Optional, in the results panel behind its own checkbox. Splits the entropy — 256 bits at 24 words, 128 at 12, giving 74- and 48-character shares — into 2-of-3, 2-of-4 or 3-of-5 shares under BIP-93.
codex32 rather than SLIP-39 because it is computable with pen and paper from printed tables — the same claim this project already makes about dice — and because it is far lighter to audit: GF(32) over the bech32 alphabet, no second 1024-word list to embed and checksum. Those are preferences. There is also a correctness reason, which is not a preference: see Why not SLIP-39 below.
The gotcha that matters. codex32 splits a BIP-32 master seed; BIP-39 goes
entropy → 24 words → PBKDF2 → seed. Those are different paths. Recombining
these shares in a generic BIP-93 tool returns 32 bytes of entropy, and
handing those to a wallet as a raw BIP-32 seed gives a different wallet —
silently, with valid checksums and no error anywhere. codex32_recover.html
does the remaining step, turning the entropy back into the 24 BIP-39 words.
The share warning in the app says so, because nothing else will.
The splitter self-checks before it shows you anything: it recovers the seed from up to 200 distinct k-subsets of the shares it just produced and compares byte for byte, refusing to display them if any subset disagrees. A GF arithmetic bug that reaches the user costs them the coins; this costs microseconds.
Each share has a QR button opening a modal with the code, the share as text, its metadata, and a print option — one share per sheet, so shares can be stored apart without anyone transcribing 74 characters by hand. The QR encodes the uppercase form, which is canonical for codex32 and fits QR's alphanumeric mode: 33 modules instead of 37 for the same string.
An optional note is printed on every share sheet — a line for remembering
what the share is for years later, plus a blank Held by: ______ for writing
the holder in at distribution time, so the constant part is printed and the
varying part is not. The note never enters the QR, which carries the share and
nothing else. The field's own caption warns about the obvious trap: whoever
finds the paper reads the note too, so it should say what the share is for,
never what it is worth or where its siblings live.
Printing a share is possible only from that modal's own button. It sets a
printing-share class for the duration of that one window.print() call and
removes it immediately after. Ctrl+P at any other moment — including with the
modal open — still prints nothing but the dice tables. The property that a
stray Ctrl+P cannot send a seed to a printer is preserved; only a deliberate
button press inside a modal you opened can.
Identifiers are 4 characters from the bech32 alphabet, which has no b, i,
o or 1. The field defaults to a fresh random identifier per split: two
different seeds sharing one identifier is a mistake that only surfaces years
later, at recovery.
Reading shares with the camera. The recombiner can scan share QR codes instead of having them typed or pasted — pasting a share means the seed material passes through the clipboard, which is the path the generator warns about.
Decoding uses jsQR 1.4.0 (Apache-2.0), inlined into the page — not the native
BarcodeDetector, which is absent in Firefox and in several Linux Chrome builds,
i.e. exactly the machines this page is meant to run on. One decoder means one
behaviour to reason about and test everywhere.
Both lengths. The recombiner reads the length off the shares: 16 bytes of recovered seed is a 12-word phrase, 32 is a 24-word one, and anything else stops with an error rather than guessing. Every count in its copy — the entropy label, the word list, the buttons, the QR captions — follows what came back, which is why the language is re-applied once a recovery lands rather than only at load.
Getting the recovered phrase out. The recombiner offers the same exports as the generator, on the same tab strip and with the same confirmation in front of each: copy the 24 words to the clipboard, or show them as a QR — one code, three of eight words, a SeedQR, or a Compact SeedQR. That needs a second library, qrcode-generator (MIT), since jsQR only reads. What leaves this page is the same secret the generator produces, but the situation is sharper: the shares were split precisely so the seed would never sit whole anywhere, and both exports put it somewhere whole. There is no connectivity probe here, deliberately — the generator checks because it is making a seed that must never have existed online, and this page cannot make that promise on your behalf.
The unminified build ships, at 257 KB rather than the 43 KB minified one. A
page that rebuilds seed material should not contain an unreadable blob; the
bundle has no eval, no new Function, and no network calls, and you can check
that yourself. The tarball was verified against the npm registry's published
sha1 and sha512 before extraction, and the sha256 of the exact inlined bytes is
recorded in the notice above the source. Frames are downscaled to 480 px before
decoding, so the loop stays responsive on a phone.
Scanning validates as it goes: a code that is not a codex32 share, a duplicate index, or a share from a different split is rejected with a reason rather than appended. It stops the camera by itself once the threshold is met, and releases the tracks on every exit path — clear, page hide, tab switch.
Why the recombiner is a separate file. The generator has one straight path from dice to words and benefits from a single starting point. Recombining is a different job at a different time, wants its own checksum, and is the intended home for later work (an air-gapped signer) that has no business sharing a process with a key generator.
Implementation note: the codex32 constants and algorithms are transcribed from the BIP-93 reference implementation and verified against its published test vectors — all five valid vectors reproduced (including the 512-bit long-checksum one), all 28 invalid strings rejected, plus split/recover round-trips over every k-subset for each offered scheme.
Asked for periodically, usually for Trezor compatibility. That is the one thing it cannot deliver, and the obvious implementation is dangerous rather than merely unhelpful.
SLIP-39 is an alternative to BIP-39, not a backup format for it. An application backing up a BIP-32 wallet "MUST use the BIP-0032 master seed as the SLIP-0039 master secret", and the recovered master secret goes straight into HMAC-SHA512 as that seed. BIP-39's PBKDF2 step never runs. The spec says outright that the two schemes are not compatible.
So splitting this page's 256-bit entropy under SLIP-39 would produce 33-word shares that a Trezor accepts without complaint and restores to different addresses than the 24 words — the device treats those 32 bytes as the seed instead of running them through BIP-39 first. Trezor's own documentation says the same thing from the other end: converting a BIP-39 seed to Shamir on-device "creates a new wallet with different addresses, not a Shamir-split version of the existing BIP-39 seed."
That failure is silent. The restore succeeds, an empty wallet appears, and the obvious conclusion is a mistyped word. Compare the codex32 gotcha above: the same entropy-versus-seed hazard exists there, but a generic BIP-93 tool hands back 32 bytes that you must then do something with, so the gap is visible at the point you cross it.
Doing it to spec means splitting the 512-bit BIP-39 seed instead. That is correct, and unusable: 512 bits yields 59-word shares, while Trezor supports only 20-word and 33-word ones — and PBKDF2 is one-way, so such shares could never return the 24 words, only the wallet.
Licensing is not the obstacle; ilap/slip39-js is MIT, as compatible as the QR libraries already bundled here. The obstacle is that there is no arrangement of SLIP-39 that backs up a BIP-39 phrase and restores on a Trezor. A SLIP-39 wallet has to be a SLIP-39 wallet from the start.
If it is ever built, it is a separate app, reusing the ideas here rather than a tab added to this page. The deciding reason is not that the mechanics differ, but that the central claim does not survive the move. Here, every word you roll appears verbatim in the phrase, and the results panel proves it word by word; that is what entropy you see means. SLIP-39 has no phrase for that to attach to. Its share words are computed from the master secret — encrypted, evaluated, checksummed — so not one of them is a word you read off a table, even in the 1-of-1 case. Dice would make the secret, and the tool would hand back words you had never seen. That is a different promise, and it deserves its own page saying so plainly.
Some of it would get simpler. SLIP-39's wordlist is exactly 1024 words, so each word is 10 bits: four dice, one table, and no coin. The coin here exists only to choose between the two 1024-word halves of BIP-39's 2048.
- A
no-corsprobe togstatic.com/generate_204runs on load and again immediately before generating or revealing a QR. If it succeeds, a blocking overlay appears. It sends nothing and cannot read the response — it only observes whether the network transport works at all. This is a nudge for someone who forgot they are online, not a proof of air-gap: it cannot see a captive portal that rewrites responses, and says nothing about whether the device is otherwise compromised. - The offline advice lives in that overlay only. There is no standing warning box on the page: when the check finds no network there is nothing to warn about, and a banner that is always up is a banner nobody reads.
- Dismissing that overlay enters preview mode, which lets you drive the whole
tool while online. Everything produced that way is labelled — a persistent banner,
a notice above the words, and a
PREVIEW — DO NOT USE.prefix on QR captions, so the warning survives a photograph. Reload the page to leave preview mode. - The print stylesheet hides the app and shows only the tables, so a stray Ctrl+P can never send a seed to a printer.
- Four export paths behind a tab strip, each behind its own checkbox, each
re-armed on generate and on reset so consent never carries over from a
previous seed. No tab is open when the results appear: the comparison above
the strip is there to be read before a choice, and an open panel would answer
the question for you. Leaving a tab clears whatever it had drawn, so a QR is
never left rendered in a panel nobody is watching. The bullet names in the
comparison double as the way in — they are
<button>s rather than anchors, since they operate the page rather than leave it, and every<a>on these pages is meant to open somewhere else.- Clipboard, for a wallet running on the same machine. Gated on the user confirming the machine is not running clipboard-reading malware — which is an assertion, not a check the page can make, so the copy states what the exposure actually is: every process on the box can read it, it may sync to other devices, and it may persist in a clipboard-history tool.
- QR, for a wallet on another device, in four encodings of the one secret: the 24 words as text in a single code, the same words split three ways (each part still exposes 8 words in the clear), and the two SeedSigner conventions — SeedQR, each word's 0-based position in the BIP-39 list as four digits in QR's numeric mode, and Compact SeedQR, the 32 bytes of entropy in binary mode with the derivable checksum left off. Wallets that read one of these commonly read none of the others, which is the reason for offering all four rather than picking one.
- Both SeedSigner formats are written at error-correction level L, not the M used elsewhere. That is what the reference implementation emits, and it is what puts a compact 24-word code at 25×25 rather than 29×29 — the printed grids used to transcribe these onto steel by hand are cut to those sizes, so matching them is a compatibility property, not a cosmetic one.
- Above the strip, a short comparison of what each export costs: the clipboard is the quickest and crudest and riskiest for those same reasons; a QR trades a cable for a camera and a lit screen; SeedQR is a different notation, not a safer one; codex32 is the only export where losing one artifact is not losing the seed, and the only one recoverable with pencil and paper.
- Everything produced in preview mode carries the
PREVIEW — DO NOT USE.label, including the clipboard text — so a pasted string cannot be mistaken for a real seed any more than a photographed QR can. - Verify each file's checksum against what the publisher announced before moving
it to an air-gapped machine:
sha256sum bip39_generator.html codex32_recover.html dice_scanner.html. Check the recombiner at least as carefully as the generator: it is the file you will run years later with real shares in hand, long after you have stopped thinking about any of this, and it is the one carrying third-party code. The printed tables are public and hold no secret, so print them from any machine.
The progress bar counts conservatively estimated bits, not raw pointer samples. Only input a human actually caused is accepted: movement counts only while the pointer is pressed, and only past a 12px step.
| Input | Credit | Why |
|---|---|---|
| Drag point | 1 bit | Low-order landing jitter only. A drag's timing is paced by the pointer hardware, not by you. |
| Tap / press | 4 bits | 1 as above, plus ~3 from the wall-clock millisecond you pressed on — that one is yours to choose. |
Target is 128 bits, covering the 124-bit worst case at N = 12. Every sample is hashed in full regardless of how conservatively it was scored.
All user-facing text lives in window.STRINGS (en, pt), including the printed
sheets. Language is chosen by ?lang=pt, then by .pt-br. in the filename,
defaulting to English; the corner toggle switches in place without reloading.
The page carries Open Graph / Twitter Card tags so a shared link renders as a
card in WhatsApp, Telegram, Signal, Slack and the rest, using social-card.png
(1200×630, generated from the logo by tools/make-social-card.py).
The card's composition is centred inside a safe area rather than aligned to one side, because preview clients crop it toward the middle at ratios that vary by client — whatever sits nearest an edge goes first. At the current sizes it survives 1.91:1, 16:9, 3:2 and 4:3 intact; a hard 1:1 square crop still clips the text. The script asserts the composition fits the safe area, so a longer title fails loudly instead of shipping a card that quietly loses its last word.
These URLs are absolute and must track where the files live. og:url,
og:image and twitter:image near the top of all three of
bip39_generator.html, codex32_recover.html and dice_scanner.html
currently point at https://bip39.postcogito.org. Publishing elsewhere — a
fork, a move — means updating all seven:
sed -i 's|https://bip39.postcogito.org|https://your.real.host|g' \
bip39_generator.html codex32_recover.html dice_scanner.htmlNote the replacement takes no trailing slash; the tags supply their own.
That sed also changes the printed sheets: every sheet prints the address in
its top-right corner, read at render time from the og:url tag rather than
kept as a second constant, so the paper and the preview card cannot disagree
about where this tool lives. If the tag is missing or not http(s), the
sheets print no address at all rather than a guess — a wrong URL on paper is
worse than none.
og:image cannot be a relative path or a data: URI — the crawler fetches it
as a separate HTTP request, so it needs an absolute https:// URL, and all of
social-card.png, social-card-recover.png and social-card-scanner.png have
to be served alongside the HTML. Without a reachable image, links still
preview, just without the card.
dice_scanner.html also links the printable dice by relative path, so dice/
has to be served next to it or the download links 404. And unlike the other
two, opening it straight off disk does not work: a camera needs a secure
context, so it wants https or localhost. Downloaded, that means serving the
folder — python3 -m http.server, then localhost:8000/dice_scanner.html —
which the page says for itself when it finds no camera.
Two things worth knowing:
- Previews are cached hard. WhatsApp and Facebook keep a scrape for days
keyed on the URL. If the card changes later, either bust it with
social-card.png?v=2in the tag, or re-scrape via the Facebook sharing debugger. - The card is static English. Crawlers don't run JavaScript, so a
?lang=ptlink still previews in English.
Publishing the file at a URL is not in tension with the offline posture — the online overlay and preview mode already handle someone opening it in a browser. Just make sure the published copy is the one whose checksum you announced.
Code: GNU AGPL v3 or later (SPDX-License-Identifier: AGPL-3.0-or-later),
© 2026 Marco Carnut. Source: https://github.com/marcocarnut/dice39.
AGPL rather than GPL because of how this tool gets attacked. GPL obligations trigger on distribution, and serving a web page isn't distribution — so under plain GPL, someone could host a modified dice39 and owe nothing. The realistic threat here is a lookalike site serving a backdoored generator, so the hosted case is the one that matters. AGPL §13 puts the source obligation on network use too. It won't stop a malicious host — attackers ignore licenses — but it removes "we complied" from a merely careless one.
The obligation is easy to meet: the served page is the complete
corresponding source. Nothing is minified, bundled, or transpiled. The one
asset not in its preferred form for modification is the inlined logo, whose
source is dice39-logo.png in this repo — which is why that file stays here
even though the app never loads it at runtime.
Not covered: the dice39 name and the logo artwork. Those are reserved,
and a fork must ship its own name and mark. This isn't about branding vanity —
for a seed generator, a fork that looks identical to the original is a phishing
vector. Change the code all you like; change the face along with it.
Both MIT, both one-way compatible with AGPLv3. Their notices must survive in any redistribution.
| Component | License |
|---|---|
BIP-39 English wordlist (SHA-256 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda) |
MIT — BIP-39 states "This BIP falls under the MIT License"; the reference implementation, trezor/python-mnemonic, is MIT © Pavol Rusnak |
| qrcode-generator | MIT © 2009 Kazuhiko Arase — notice retained inline in the source |
jsQR 1.4.0 — QR decoding, in codex32_recover.html only |
Apache-2.0 © Cozmo — one-way compatible with AGPLv3; full text in LICENSE-jsQR.txt and reproduced inside the page |