BIP-93 (codex32) in JavaScript: a checksummed, Shamir-aware encoding for BIP-32 master seeds.
One file, no dependencies, no build step. It runs in a browser opened straight
from disk, in Node, and anywhere else that has BigInt.
const seed = 'ffeeddccbbaa99887766554433221100';
codex32.fromSeed(seed, { threshold: 3, id: 'cash' });
// 'ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln'
// the seed, checksummed — and BIP-93's own test vector 3, character for character
const { shares } = codex32.split(seed, { threshold: 3, count: 5, id: 'cash' });
// five strings; any three recover the seed, any two reveal nothing at all
codex32.recover(shares.slice(0, 3)).seed; // Uint8Array of the original bytesA BIP-39 seed phrase is 24 words with an 8-bit checksum: it will notice a scrambled word, but it cannot tell you which one, cannot be split, and carries no metadata. codex32 replaces it with a bech32-alphabet string that:
- detects errors properly. The BCH code catches any corruption of up to 8 characters, and fails to notice larger random corruption with probability under 3 in 10^20 — 3 in 10^23 for the long code.
- splits, natively. Shamir secret sharing over GF(32), with the shares checksummed by the same code — so a share you find in a drawer in 2041 can be checked on its own, before you go looking for the others.
- fits on paper. A 128-bit seed is 48 characters; a 256-bit seed is 74. The
alphabet has no
b,i,oor1, so nothing you write by hand is ambiguous. - can be worked by hand. The whole scheme was designed to be doable with paper volvelles and no computer at all. This library is the fast path, not the only one.
Every string carries the threshold, a 4-character identifier tying shares of one split together, and the share index — so shares cannot be silently mixed between splits.
Version 0.1.0. Experimental. The implementation reproduces every test vector BIP-93 publishes and rejects every invalid one, and it is the code already running in dice39 — but it has had no outside review. Check what it produces against another implementation before you trust it with anything, and read Before you rely on this.
As far as I can tell this is the first JavaScript implementation of BIP-93:
there is nothing on npm under codex32, and a GitHub search turns up
implementations in Rust, Python, Ruby, PHP and PostScript but none in JS. If
you know of another, please open an issue — I would rather compare notes than
be first.
Not on npm yet. Take the file:
<script src="codex32.js"></script>
<script>codex32.decode('ms10test…')</script>const codex32 = require('./codex32.js'); // CommonJS
import codex32 from './codex32.js'; // ESM, default importThe paths are relative because there is no package to resolve a bare
'codex32' to yet. There is nothing to build and nothing to fetch at runtime:
copying codex32.js into your own tree and reading it is a legitimate way to
use this.
Two things to know if you do. The file is one CommonJS module, so named
imports do not work — import { decode } from … gives you undefined; take
the default and destructure it. And if you drop it into a package whose
package.json says "type": "module", Node will try to parse it as ES module
source and fail with does not provide an export named 'default'; rename it
codex32.cjs and it works.
Try it: open demo/index.html — encode a seed, split it,
recover it, inspect a string. It works from file://.
Parses and fully validates. Throws a Codex32Error otherwise.
const d = codex32.decode('ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln');
d.k // 3 — the threshold, as a number (0 = unshared secret)
d.id // 'cash' — the 4-character identifier
d.indexChar // 's' — the share index
d.isSecret // true — index 's' means this is the secret, not a share
d.seedBytes // 16 — the master seed's length
d.checksumLength // 13 — 13 for the short code, 15 for the long one
d.payload // [ … ] — payload symbols, 0..31
d.data // [ … ] — every symbol after the 'ms1' prefix
d.string // the input, trimmed and lowercasedThe same checks, without throwing.
Encodes a master seed (16–64 bytes, as a Uint8Array, an array of numbers, or
a hex string) as an unshared codex32 secret — share index s.
threshold is 0 for a seed you do not intend to split, or 2–9. It is part
of the checksummed data, so it is decided here, not at split time. id is
exactly 4 characters from the bech32 alphabet.
The master seed carried by a string.
Splits a seed into count shares, any threshold of which recover it.
const out = codex32.split(seed, { threshold: 3, count: 5, id: 'cash' });
out.secret // the unshared secret string
out.shares // ['ms13casha…', 'ms13cashc…', …] — count of them
out.indices // ['a','c','d','e','f']
out.checked // 10 — how many k-subsets the self-check recovered fromindices— which share indices to use, as a string. Defaults toSHARE_INDICES, which skipss.rng—function(n) → n random bytes. Defaults tocrypto.getRandomValues, or to thecryptomodule on Node 18 and earlier, which has no globalcrypto. Pass your own for deterministic tests, or when your runtime has neither.verify— setfalseto skip the self-check described below. Don't.
Recovers the secret from exactly threshold distinct shares of one split.
More is not better here: passing extra shares is an error, not a convenience,
because a mismatched extra share would silently move the answer.
Produces a further share from exactly k existing ones — the way to add a share later without re-splitting. The secret counts as one of the k.
Exactly k, never fewer: interpolating through fewer points fits a lower-degree polynomial that agrees with them and diverges everywhere else. The checksum is itself linear, so the result would look like a perfectly valid share. A wrong one, discovered years later.
Four random characters of the alphabet, for when the identifier carries no meaning.
CHARSET (the 32 bech32 characters), SHARE_INDICES (the 31 usable share
indices), SECRET_INDEX, MIN_SEED_BYTES, MAX_SEED_BYTES, version.
polymod, longPolymod, createChecksum, verifyChecksum, gfMul,
GF32_INV, lagrange, interpolate, toBase32, fromBase32, kSubsets,
randomSymbols — the pieces the API is built from, exposed for tests, for
checking a paper computation, and for anything the high-level calls do not
cover. Not covered by semver.
Everything thrown is a Codex32Error with a stable code, so a caller can tell
a typo from a wrong length without matching on English:
| code | meaning |
|---|---|
MIXED_CASE |
upper and lower case in one string |
PREFIX |
does not start with ms1 |
BAD_CHAR |
a character outside the bech32 alphabet |
TOO_SHORT |
shorter than the smallest legal string |
CHECKSUM |
the checksum does not verify |
BAD_THRESHOLD |
threshold not 0 or 2–9, or out of range for a split |
BAD_INDEX |
bad share index, or index other than s with threshold 0 |
PADDING |
an incomplete final group larger than 4 bits |
SEED_LENGTH |
seed outside 16–64 bytes |
BAD_ID |
identifier not 4 characters of the alphabet |
BAD_SEED |
seed neither bytes nor an even-length hex string |
BAD_COUNT, BAD_INDICES |
bad arguments to split |
SHARE_COUNT, NO_SHARES |
wrong number of shares |
MIXED_ID, MIXED_THRESHOLD, MIXED_LENGTH, DUPLICATE_INDEX |
shares that do not belong together |
SELF_CHECK |
a split failed to recover its own seed — see below |
NO_RNG, BAD_RNG |
no usable randomness |
node test/run.js # everything
node test/run.js vector # just the cases whose name contains "vector"Tested on Node 18; it needs nothing newer than BigInt.
Or open test/browser.html — the same cases, in whatever
browser you plan to run this in. There is no framework and nothing to install;
test/suite.js is an array of { name, run } that either
runner walks.
46 cases, covering:
- Every BIP-93 test vector. All five valid ones — including the 512-bit long-code vector — reproduced, and all 64 invalid ones rejected, each with the error code it should give.
- The field arithmetic itself. Every element of GF(32) times its tabled inverse is 1; multiplication commutes and distributes over the whole table.
- Splitting. Every threshold 2–9 at 16, 32 and 64 bytes; 2-of-31; chosen indices; recovery from every subset; and refusal to combine shares of different splits, thresholds, or the same index twice.
- Corruption. Every single-character substitution at every position of a share is caught, and a dense sweep of two-character corruptions with it.
- Padding. That the spec's non-zero padding bits are accepted (see below), and that an incomplete group larger than 4 bits is not.
- The default randomness. Every other case injects a deterministic
rng, which is exactly how a broken default survives a green test run: this one passes none, splits twice, and checks the two splits differ.
The vectors are not typed in. tools/extract-vectors.py
lifts them out of the BIP text itself, from a pinned commit checked against a
pinned sha256, and asserts the count of every group before writing
test/vectors.js. A mistyped character in a 48-character bech32 string would
otherwise get blessed as expected behaviour.
This code began inside dice39, which ships its own inlined copy. Two copies of anything drift, so there is a page that runs both over the same inputs and compares every answer:
python3 tools/extract-dice39.py [path/to/bip39_generator.html]
# then open test/compare-with-dice39.htmlIt compares 2880 answers: secret strings over hundreds of seeds, thresholds and identifiers; splits made by each library and recovered by the other; and the rejection of all 64 invalid vectors. As of this writing: identical behaviour, no mismatches.
A few things that are easy to get wrong, and that the tests pin down.
Padding bits are not required to be zero. Unlike BIP-173, BIP-93 says an
incomplete final group "MUST be 4 bits or less, and is discarded" — and says
nothing about its value. Test vector 1 ends in padding bits of 0b10. A
BIP-173-style zero check here rejects a string the spec calls valid. Encoding
still zero-pads, so re-encoding vector 1's seed gives a different string with
the same meaning. Both are correct.
94 and 95 symbols are valid under neither code. The short code covers up to 93 data symbols and the long one starts at 96. Seeds of 47 bytes and up use the long code and its 15-symbol checksum.
The checksum is linear, and that is load-bearing. Interpolating whole
strings — checksum included — is what makes a derived share's checksum come out
right without recomputing it. It is also why a share derived from too few points
still looks valid, which is why deriveShare insists on exactly k.
Recovering from a threshold-0 string is a special case. It is already the secret; running it through interpolation would evaluate a polynomial at the very point that defines it, and hand back zeros.
split recovers the seed from every k-subset of the shares it just made — up
to 200 of them — and compares it byte for byte with the input before returning
anything. If it disagrees, it throws SELF_CHECK and gives you nothing.
A field-arithmetic bug that reaches the far end of this costs somebody their coins, silently, years later — the shares verify, they just do not reconstruct what went in. This costs microseconds. Leave it on.
- Recover with something that is not this library. Splitting and recovering with one implementation proves only that it agrees with itself, which is the one thing it cannot fail at. Take a string this produces and read it back with the reference implementation, or by hand with the paper computer. That is what turns a passing test suite into evidence.
split()hands back the seed. Anythresholdof the strings in that array reconstruct the secret, so until they reach separate places it is seed-equivalent material sitting in one process, in one array, in a language with a compacting garbage collector and no way to wipe a string. What happens to it betweensplit()returning and the shares being written down is the part of the problem this library does not solve.- Randomness is
crypto.getRandomValuesunless you replace it — falling back to thecryptomodule on Node 18 and earlier, which has no globalcrypto. If you pass your ownrng, it is on you; a predictable one makes the shares worthless. The deterministic generator in the test suite is for tests, never for keys. - Nothing here is constant-time, and JavaScript is not a place to make that claim. This is for encoding and recovering seeds, not for handling them in a hostile process.
- No network, no storage, no dependencies — but the process you run it in
may have all three.
codex32.jsis one readable file; the point of shipping it that way is that you can check that claim yourself in an afternoon. - 0.1.0 means the API can still move.
internalsis not covered by semver at all.
- Error correction. BIP-93 detects errors; the reference implementation can also correct them, and the paper computer is built around that. This does detection only.
- BIP-39 conversion, BIP-32 derivation, xprv. codex32 encodes the master seed; what you do with those bytes is another library's job. dice39 is where the 24-word side of this lives.
- A CLI.
node -eand the demo page cover it so far.
Pull requests welcome for the first two.
codex32 is by Leon Olsson Curr and Pearlwort Sneed, specified in BIP-93 (BSD-3-Clause), with a reference implementation in Rust and PostScript. The constants and algorithms here follow that spec; the mistakes are mine.
This library was extracted from dice39, an offline generator that turns physical dice rolls into a BIP-39 seed phrase and, with this code, into codex32 shares.
MIT licensed — see LICENSE.