Skip to content

About

Javascript implementation of the Codex32 secret sharing system

Resources

Stars

2 stars

Watchers

1 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

codex32.js

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 bytes

What codex32 is

A 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, o or 1, 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.

Status

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.

Install

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 import

The 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://.

API

decode(string) → object

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 lowercased

isValid(string) → boolean

The same checks, without throwing.

fromSeed(seed, { threshold, id }) → string

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.

toSeed(stringOrDecoded) → Uint8Array

The master seed carried by a string.

split(seed, { threshold, count, id, indices, rng, verify }) → object

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 from
  • indices — which share indices to use, as a string. Defaults to SHARE_INDICES, which skips s.
  • rng — function(n) → n random bytes. Defaults to crypto.getRandomValues, or to the crypto module on Node 18 and earlier, which has no global crypto. Pass your own for deterministic tests, or when your runtime has neither.
  • verify — set false to skip the self-check described below. Don't.

recover(shares) → { seed, string, decoded }

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.

deriveShare(strings, indexChar) → string

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.

randomId(rng?) → string

Four random characters of the alphabet, for when the identifier carries no meaning.

Constants

CHARSET (the 32 bech32 characters), SHARE_INDICES (the 31 usable share indices), SECRET_INDEX, MIN_SEED_BYTES, MAX_SEED_BYTES, version.

internals

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.

Errors

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

Tests

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.

Against dice39

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.html

It 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.

Notes from the implementation

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.

The self-check

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.

Before you rely on this

  • 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. Any threshold of 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 between split() returning and the shares being written down is the part of the problem this library does not solve.
  • Randomness is crypto.getRandomValues unless you replace it — falling back to the crypto module on Node 18 and earlier, which has no global crypto. If you pass your own rng, 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.js is 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. internals is not covered by semver at all.

What is not here

  • 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 -e and the demo page cover it so far.

Pull requests welcome for the first two.

Credits

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.

About

Javascript implementation of the Codex32 secret sharing system

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages