carlos / contra Public

web: the browser half of the crypto contract, byte-identical to Go

AGENTS.md says crypto is byte-identical in Go and JS, pinned by shared
golden vectors. Until now that was a rule with nothing enforcing it. This is
the JS half and the suite that enforces it: the same vectors/contra-v1.json
the Go tests consume, generated by neither of them.

Argon2id is the interesting part. It is the one derivation WebCrypto cannot
do, and the choices were vendoring somebody's .wasm or writing it. A
third-party binary blob is not reviewable, and "no build step" is a stated
constraint that vendoring quietly breaks — so it is written out, in about
250 lines you can read against RFC 9106, and it runs 64 MiB at t=3 in ~1.7s
in node.

It agrees with OpenSSL 3.5 on nine parameter sets and with the published PHC
reference vector at m=65536, t=2. The corners are pinned separately, because
that is where a hand-written Argon2 goes wrong and where the 64 MiB case
does not go: a segment shorter than one address block, passes above two, an
empty password, a 64-byte tag.

BLAKE2b takes the opposite trade deliberately — BigInt, because Argon2 calls
it about sixty times per derivation while the compression function runs two
hundred thousand times. Clarity where clarity is free, 32-bit word pairs
only where they are paid for. It matches RFC 7693.

P-256 needs no elliptic-curve maths of our own: a raw scalar imports through
WebCrypto as a 67-byte PKCS#8 with the public half omitted, and the browser
recomputes the point. Verified against the golden vector's public key.
Rejection sampling is a byte-wise comparison against n, so no bignum there
either.

The KDF floor lives here rather than only in prose: checkParams refuses
m < 64MiB, t < 3, p != 1 and any algorithm that is not argon2id, before a
single expensive byte is derived. That is the defence against a malicious
server serving m=8KiB, harvesting one challenge signature and cracking
offline — and it has the test the item asks for.

The 300-line module cap is now enforced, with a per-file ratchet that only
goes down. The cap is not tidiness: this is the code holding the user's keys
in the one place we cannot sign or pin, and the only real defence is that a
person can read all of it.

make test-js is a hard gate and fails when node is missing rather than
skipping. A skip would be indistinguishable from a pass on the one check
that stops a Mac opening a vault the browser cannot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz8mMZYs75WuRC1hYpmapJ
Paul Campbell pushed by paul@keymail.dev c18b8be4c3e72a0677ac6e52887d12f97d897145 parent 3730d63
11 files changed, +1338 −2
  • .amadan/ci.d/35-js-test +10 −0
  • Makefile +19 −2
  • internal/contra/static/crypto/argon2id.js +292 −0
  • internal/contra/static/crypto/blake2b.js +99 −0
  • internal/contra/static/crypto/bytes.js +61 −0
  • internal/contra/static/crypto/code.js +165 −0
  • internal/contra/static/crypto/kdf.js +117 −0
  • internal/contra/static/crypto/p256.js +101 −0
  • internal/contra/static/crypto/sealed.js +90 −0
  • internal/contratest/jsmodules_test.go +88 −0
  • test/crypto.test.mjs +296 −0
diff --git a/.amadan/ci.d/35-js-test b/.amadan/ci.d/35-js-test
new file mode 100755
index 0000000..adfd98c
--- /dev/null
+++ b/.amadan/ci.d/35-js-test
@@ -0,0 +1,10 @@
+#!/bin/sh
+# One step of CONTRA's CI. It runs a Makefile target rather than its own copy
+# of the commands, so CI and the local gate stay ONE definition: `make ci`
+# runs these same targets in this same order, and `make test-js` is exactly
+# what CI runs here. Debugging CI is running it.
+#
+# Numbered 35 so it sits where `make ci` puts it, after the Go suite: the
+# browser crypto only matters once the server's compiles.
+set -eu
+exec make test-js
diff --git a/Makefile b/Makefile
index 53c23c6..87c8413 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH)
# make release VERSION=v0.1.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
-.PHONY: build release version-check test vet fmt-check migration-check ci
+.PHONY: build release version-check test test-js vet fmt-check migration-check ci
# build is the compile check: with more than one package matched, go
# build discards the output, so this catches a broken package without
@@ -81,6 +81,23 @@ version-check:
test:
go test ./...
+# The crypto twin. AGENTS.md: "Crypto is byte-identical in Go and JS, pinned
+# by shared golden vectors." This runs the browser modules against the same
+# vectors/contra-v1.json the Go suite uses, so a change to one side that is
+# not made to the other fails here rather than in somebody's vault.
+#
+# A HARD gate, and it does not soften when node is missing. A security product
+# must not silently stop verifying that the browser's crypto matches the
+# server's: a skip would be indistinguishable from a pass on the one check
+# that stops a Mac opening a vault the browser cannot.
+test-js:
+ @command -v node >/dev/null 2>&1 || { \
+ echo "make test-js: node is not installed."; \
+ echo " This gate is not optional -- it is what proves the browser's"; \
+ echo " crypto matches the server's. Install node, do not skip it."; \
+ exit 1; }
+ node --test test/crypto.test.mjs
+
vet:
go vet ./...
@@ -106,4 +123,4 @@ migration-check:
# fails the build the moment models.go and migrations/ disagree,
# instead of at boot on whatever machine notices next. If the app
# declares manifest resources, also add: rastrillo generate --check
-ci: vet fmt-check test migration-check
+ci: vet fmt-check test test-js migration-check
diff --git a/internal/contra/static/crypto/argon2id.js b/internal/contra/static/crypto/argon2id.js
new file mode 100644
index 0000000..e332d0b
--- /dev/null
+++ b/internal/contra/static/crypto/argon2id.js
@@ -0,0 +1,292 @@
+// Argon2id (RFC 9106), in the browser, with no build step and no vendored
+// binary.
+//
+// The passphrase branch is the one derivation WebCrypto cannot do, and the
+// alternatives were vendoring somebody's .wasm — a third-party binary blob in
+// a password manager, unreadable in review — or this. This is ~250 lines you
+// can read against the RFC, and it is pinned byte-for-byte by
+// vectors/contra-v1.json and by the published PHC reference vectors.
+//
+// Speed lives in fillBlock, so the 64-bit arithmetic there is done as pairs
+// of 32-bit words rather than BigInt: a derivation runs about 200,000 block
+// compressions, and BigInt would turn a one-second unlock into minutes.
+// blake2b.js takes the opposite trade for the same reason in reverse.
+
+import { concat, u32le } from "./bytes.js";
+import { blake2b } from "./blake2b.js";
+
+const ARGON2ID = 2;
+const VERSION = 0x13;
+const BLOCK_U32 = 256; // 1024 bytes
+const SYNC_POINTS = 4;
+const ADDRESSES_PER_BLOCK = 128;
+
+// H', the variable-length hash of RFC 9106 §3.2.
+function hPrime(outLen, input) {
+ const prefixed = concat(u32le(outLen), input);
+ if (outLen <= 64) return blake2b(outLen, prefixed);
+
+ const r = Math.ceil(outLen / 32) - 2;
+ const out = new Uint8Array(outLen);
+ let v = blake2b(64, prefixed);
+ out.set(v.subarray(0, 32), 0);
+ for (let i = 1; i < r; i++) {
+ v = blake2b(64, v);
+ out.set(v.subarray(0, 32), i * 32);
+ }
+ out.set(blake2b(outLen - 32 * r, v), r * 32);
+ return out;
+}
+
+// The 16 64-bit registers the permutation works on, as (lo, hi) pairs. Module
+// scope so the hot loop allocates nothing.
+const v = new Uint32Array(32);
+
+// gb is Argon2's BlaMka variant of the BLAKE2b mixing function: the plain
+// additions gain a 2*lo(a)*lo(b) term, which is what makes the compression
+// latency-bound and so resistant to cheap parallel hardware.
+//
+// a, b, c and d are register numbers; each occupies v[2n] (low) and
+// v[2n+1] (high).
+function gb(a, b, c, d) {
+ a <<= 1; b <<= 1; c <<= 1; d <<= 1;
+ fma(a, b);
+ rot32(d, a);
+ fma2(c, d);
+ rot24(b, c);
+ fma(a, b);
+ rot16(d, a);
+ fma2(c, d);
+ rot63(b, c);
+}
+
+// fma: x = x + y + 2 * lo32(x) * lo32(y), modulo 2^64.
+function fma(x, y) {
+ const xl = v[x], yl = v[y];
+ const x0 = xl & 0xffff, x1 = xl >>> 16;
+ const y0 = yl & 0xffff, y1 = yl >>> 16;
+ const p00 = x0 * y0;
+ const p01 = x0 * y1;
+ const p10 = x1 * y0;
+ const mid = p10 + (p00 >>> 16) + (p01 & 0xffff);
+ // The doubled product, as (ml, mh).
+ let ml = (((mid & 0xffff) << 16) | (p00 & 0xffff)) >>> 0;
+ let mh = (x1 * y1 + ((mid / 0x10000) | 0) + (p01 >>> 16)) >>> 0;
+ mh = ((mh << 1) | (ml >>> 31)) >>> 0;
+ ml = (ml << 1) >>> 0;
+
+ let lo = xl + yl;
+ let hi = v[x + 1] + v[y + 1] + (lo > 0xffffffff ? 1 : 0);
+ lo = (lo >>> 0) + ml;
+ v[x] = lo >>> 0;
+ v[x + 1] = (hi + mh + (lo > 0xffffffff ? 1 : 0)) >>> 0;
+}
+
+// fma2 is fma under a different name, kept separate only so gb reads in the
+// order the RFC writes it. Inlining by hand here made no measurable
+// difference and cost the resemblance.
+const fma2 = fma;
+
+function rot32(x, y) {
+ const lo = (v[x] ^ v[y]) >>> 0;
+ const hi = (v[x + 1] ^ v[y + 1]) >>> 0;
+ v[x] = hi;
+ v[x + 1] = lo;
+}
+
+function rot24(x, y) {
+ const lo = (v[x] ^ v[y]) >>> 0;
+ const hi = (v[x + 1] ^ v[y + 1]) >>> 0;
+ v[x] = ((lo >>> 24) | (hi << 8)) >>> 0;
+ v[x + 1] = ((hi >>> 24) | (lo << 8)) >>> 0;
+}
+
+function rot16(x, y) {
+ const lo = (v[x] ^ v[y]) >>> 0;
+ const hi = (v[x + 1] ^ v[y + 1]) >>> 0;
+ v[x] = ((lo >>> 16) | (hi << 16)) >>> 0;
+ v[x + 1] = ((hi >>> 16) | (lo << 16)) >>> 0;
+}
+
+// rotr(x, 63) is rotl(x, 1).
+function rot63(x, y) {
+ const lo = (v[x] ^ v[y]) >>> 0;
+ const hi = (v[x + 1] ^ v[y + 1]) >>> 0;
+ v[x] = ((lo << 1) | (hi >>> 31)) >>> 0;
+ v[x + 1] = ((hi << 1) | (lo >>> 31)) >>> 0;
+}
+
+function permute() {
+ gb(0, 4, 8, 12); gb(1, 5, 9, 13); gb(2, 6, 10, 14); gb(3, 7, 11, 15);
+ gb(0, 5, 10, 15); gb(1, 6, 11, 12); gb(2, 7, 8, 13); gb(3, 4, 9, 14);
+}
+
+const R = new Uint32Array(BLOCK_U32);
+const Z = new Uint32Array(BLOCK_U32);
+
+// fillBlock is G: R = prev ^ ref, then the permutation over R's eight rows
+// and then its eight columns, then out = Z ^ R (XORed into out on later
+// passes rather than replacing it).
+function fillBlock(mem, prevOff, refOff, outOff, withXor) {
+ for (let i = 0; i < BLOCK_U32; i++) {
+ const x = (mem[prevOff + i] ^ mem[refOff + i]) >>> 0;
+ R[i] = x;
+ Z[i] = x;
+ }
+ // Rows: registers 8i..8i+7, contiguous as 32 words of 32 bits.
+ for (let i = 0; i < 8; i++) {
+ const o = i * 32;
+ for (let k = 0; k < 32; k++) v[k] = Z[o + k];
+ permute();
+ for (let k = 0; k < 32; k++) Z[o + k] = v[k];
+ }
+ // Columns: register i, i+8, i+16, ... — two 32-bit words each, striding 32.
+ for (let i = 0; i < 8; i++) {
+ const o = i * 4;
+ for (let k = 0; k < 8; k++) {
+ const s = o + k * 32;
+ v[k * 4] = Z[s];
+ v[k * 4 + 1] = Z[s + 1];
+ v[k * 4 + 2] = Z[s + 2];
+ v[k * 4 + 3] = Z[s + 3];
+ }
+ permute();
+ for (let k = 0; k < 8; k++) {
+ const s = o + k * 32;
+ Z[s] = v[k * 4];
+ Z[s + 1] = v[k * 4 + 1];
+ Z[s + 2] = v[k * 4 + 2];
+ Z[s + 3] = v[k * 4 + 3];
+ }
+ }
+ if (withXor) {
+ for (let i = 0; i < BLOCK_U32; i++) mem[outOff + i] ^= (Z[i] ^ R[i]) >>> 0;
+ } else {
+ for (let i = 0; i < BLOCK_U32; i++) mem[outOff + i] = (Z[i] ^ R[i]) >>> 0;
+ }
+}
+
+// mulHi32 is the high half of a 32x32 -> 64 multiply, which is all the
+// reference-index mapping needs.
+function mulHi32(a, b) {
+ const a0 = a & 0xffff, a1 = a >>> 16;
+ const b0 = b & 0xffff, b1 = b >>> 16;
+ const p00 = a0 * b0;
+ const p01 = a0 * b1;
+ const mid = a1 * b0 + (p00 >>> 16) + (p01 & 0xffff);
+ return (a1 * b1 + ((mid / 0x10000) | 0) + (p01 >>> 16)) >>> 0;
+}
+
+// argon2id derives a tag from a password and salt.
+//
+// Single-lane only, and that is not a shortcut: Argon2id's output depends on
+// p, WASM without SharedArrayBuffer cannot parallelise, and any other value
+// would break byte-identity with the Go and Swift sides. kdf.js's floor
+// refuses p != 1 before we ever get here; this is the belt.
+export function argon2id(password, salt, params, tagLen = 32) {
+ const { m_kib: mKiB, t, p } = params;
+ if (p !== 1) throw new Error("contra: argon2id is single-lane here (see DESIGN.md)");
+ if (t < 1) throw new Error("contra: argon2id needs at least one pass");
+
+ const blocks = 4 * Math.floor(mKiB / 4);
+ if (blocks < 8) throw new Error("contra: argon2id memory too small");
+ const laneLength = blocks;
+ const segmentLength = laneLength / SYNC_POINTS;
+
+ const h0 = blake2b(64, concat(
+ u32le(p), u32le(tagLen), u32le(mKiB), u32le(t), u32le(VERSION), u32le(ARGON2ID),
+ u32le(password.length), password,
+ u32le(salt.length), salt,
+ u32le(0), // secret
+ u32le(0), // associated data
+ ));
+
+ const mem = new Uint32Array(blocks * BLOCK_U32);
+ for (const j of [0, 1]) {
+ const b = hPrime(1024, concat(h0, u32le(j), u32le(0)));
+ for (let i = 0; i < BLOCK_U32; i++) {
+ mem[j * BLOCK_U32 + i] =
+ (b[i * 4] | (b[i * 4 + 1] << 8) | (b[i * 4 + 2] << 16) | (b[i * 4 + 3] << 24)) >>> 0;
+ }
+ }
+
+ const zero = new Uint32Array(BLOCK_U32);
+ const input = new Uint32Array(BLOCK_U32);
+ const address = new Uint32Array(BLOCK_U32);
+ const scratch = new Uint32Array(BLOCK_U32 * 3); // zero | input | address
+
+ const nextAddresses = () => {
+ // input.v[6]++ — the low word of the seventh 64-bit slot.
+ input[12] = (input[12] + 1) >>> 0;
+ if (input[12] === 0) input[13] = (input[13] + 1) >>> 0;
+ scratch.set(zero, 0);
+ scratch.set(input, BLOCK_U32);
+ fillBlock(scratch, 0, BLOCK_U32, BLOCK_U32 * 2, false);
+ address.set(scratch.subarray(BLOCK_U32 * 2, BLOCK_U32 * 3));
+ scratch.set(address, BLOCK_U32);
+ fillBlock(scratch, 0, BLOCK_U32, BLOCK_U32 * 2, false);
+ address.set(scratch.subarray(BLOCK_U32 * 2, BLOCK_U32 * 3));
+ };
+
+ for (let pass = 0; pass < t; pass++) {
+ for (let slice = 0; slice < SYNC_POINTS; slice++) {
+ // Argon2id is Argon2i for the first two slices of the first pass and
+ // Argon2d thereafter: data-independent while an attacker could still
+ // profit from a side channel, data-dependent once they could not.
+ const independent = pass === 0 && slice < 2;
+ if (independent) {
+ input.fill(0);
+ input[0] = pass;
+ input[2] = 0; // lane
+ input[4] = slice;
+ input[6] = blocks;
+ input[8] = t;
+ input[10] = ARGON2ID;
+ }
+
+ let i = 0;
+ if (pass === 0 && slice === 0) {
+ i = 2; // the two blocks h0 already filled
+ if (independent) nextAddresses();
+ }
+
+ for (; i < segmentLength; i++) {
+ const cur = slice * segmentLength + i;
+ const prev = cur === 0 ? laneLength - 1 : cur - 1;
+
+ let j1;
+ if (independent) {
+ if (i % ADDRESSES_PER_BLOCK === 0) nextAddresses();
+ j1 = address[(i % ADDRESSES_PER_BLOCK) * 2];
+ } else {
+ j1 = mem[prev * BLOCK_U32];
+ }
+
+ // index_alpha: the reference area is everything already written in
+ // this lane that is not the block we just used.
+ let refAreaSize;
+ if (pass === 0) {
+ refAreaSize = slice === 0 ? i - 1 : slice * segmentLength + i - 1;
+ } else {
+ refAreaSize = laneLength - segmentLength + i - 1;
+ }
+ let rel = mulHi32(j1, j1);
+ rel = refAreaSize - 1 - mulHi32(refAreaSize, rel);
+ const start = pass === 0 ? 0 : (slice === SYNC_POINTS - 1 ? 0 : (slice + 1) * segmentLength);
+ const ref = (start + rel) % laneLength;
+
+ fillBlock(mem, prev * BLOCK_U32, ref * BLOCK_U32, cur * BLOCK_U32, pass !== 0);
+ }
+ }
+ }
+
+ const last = new Uint8Array(1024);
+ for (let i = 0; i < BLOCK_U32; i++) {
+ const w = mem[(laneLength - 1) * BLOCK_U32 + i];
+ last[i * 4] = w & 255;
+ last[i * 4 + 1] = (w >>> 8) & 255;
+ last[i * 4 + 2] = (w >>> 16) & 255;
+ last[i * 4 + 3] = (w >>> 24) & 255;
+ }
+ return hPrime(tagLen, last);
+}
diff --git a/internal/contra/static/crypto/blake2b.js b/internal/contra/static/crypto/blake2b.js
new file mode 100644
index 0000000..3f8da36
--- /dev/null
+++ b/internal/contra/static/crypto/blake2b.js
@@ -0,0 +1,99 @@
+// BLAKE2b, as Argon2id needs it.
+//
+// Written with BigInt on purpose. Argon2 calls BLAKE2b only for H0, the two
+// initial blocks per lane, and the final tag — about sixty short calls in a
+// whole derivation. The 64 MiB of work lives in argon2id.js's compression
+// function, which is where the awkward 32-bit arithmetic goes and where it
+// earns its keep. Clarity is worth more than speed here, and being able to
+// read this against RFC 7693 is worth more than either.
+
+const MASK = (1n << 64n) - 1n;
+
+const IV = [
+ 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
+ 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
+];
+
+const SIGMA = [
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
+ [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
+ [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
+ [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
+ [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
+ [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
+ [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
+ [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
+ [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
+ [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
+ [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
+];
+
+const rotr = (x, n) => ((x >> n) | (x << (64n - n))) & MASK;
+
+function mix(v, a, b, c, d, x, y) {
+ v[a] = (v[a] + v[b] + x) & MASK;
+ v[d] = rotr(v[d] ^ v[a], 32n);
+ v[c] = (v[c] + v[d]) & MASK;
+ v[b] = rotr(v[b] ^ v[c], 24n);
+ v[a] = (v[a] + v[b] + y) & MASK;
+ v[d] = rotr(v[d] ^ v[a], 16n);
+ v[c] = (v[c] + v[d]) & MASK;
+ v[b] = rotr(v[b] ^ v[c], 63n);
+}
+
+function compress(h, block, counter, last) {
+ const v = [...h, ...IV];
+ v[12] ^= counter & MASK;
+ v[13] ^= (counter >> 64n) & MASK;
+ if (last) v[14] ^= MASK;
+
+ const m = new Array(16);
+ for (let i = 0; i < 16; i++) {
+ let w = 0n;
+ for (let j = 7; j >= 0; j--) w = (w << 8n) | BigInt(block[i * 8 + j]);
+ m[i] = w;
+ }
+
+ for (let r = 0; r < 12; r++) {
+ const s = SIGMA[r];
+ mix(v, 0, 4, 8, 12, m[s[0]], m[s[1]]);
+ mix(v, 1, 5, 9, 13, m[s[2]], m[s[3]]);
+ mix(v, 2, 6, 10, 14, m[s[4]], m[s[5]]);
+ mix(v, 3, 7, 11, 15, m[s[6]], m[s[7]]);
+ mix(v, 0, 5, 10, 15, m[s[8]], m[s[9]]);
+ mix(v, 1, 6, 11, 12, m[s[10]], m[s[11]]);
+ mix(v, 2, 7, 8, 13, m[s[12]], m[s[13]]);
+ mix(v, 3, 4, 9, 14, m[s[14]], m[s[15]]);
+ }
+ for (let i = 0; i < 8; i++) h[i] = h[i] ^ v[i] ^ v[i + 8];
+}
+
+// blake2b hashes input to outLen bytes (1..64), unkeyed — Argon2 never uses
+// the key parameter.
+export function blake2b(outLen, input) {
+ if (outLen < 1 || outLen > 64) throw new Error("contra: blake2b output length");
+
+ const h = IV.slice();
+ h[0] ^= 0x01010000n ^ BigInt(outLen);
+
+ let counter = 0n;
+ let off = 0;
+ // Every full block except the last is compressed as non-final; the tail
+ // (or a single empty block, for empty input) is zero-padded and final.
+ while (input.length - off > 128) {
+ counter += 128n;
+ compress(h, input.subarray(off, off + 128), counter, false);
+ off += 128;
+ }
+ const tail = new Uint8Array(128);
+ tail.set(input.subarray(off));
+ counter += BigInt(input.length - off);
+ compress(h, tail, counter, true);
+
+ const out = new Uint8Array(outLen);
+ for (let i = 0; i < outLen; i++) {
+ out[i] = Number((h[i >> 3] >> BigInt(8 * (i & 7))) & 0xffn);
+ }
+ return out;
+}
diff --git a/internal/contra/static/crypto/bytes.js b/internal/contra/static/crypto/bytes.js
new file mode 100644
index 0000000..881a19c
--- /dev/null
+++ b/internal/contra/static/crypto/bytes.js
@@ -0,0 +1,61 @@
+// Byte plumbing shared by the crypto modules. No key material logic here —
+// just the conversions every other module would otherwise repeat.
+
+export const enc = new TextEncoder();
+export const dec = new TextDecoder();
+
+export function toHex(b) {
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
+}
+
+export function fromHex(s) {
+ if (s.length % 2) throw new Error("contra: odd-length hex");
+ const out = new Uint8Array(s.length / 2);
+ for (let i = 0; i < out.length; i++) {
+ out[i] = parseInt(s.substr(i * 2, 2), 16);
+ }
+ return out;
+}
+
+export function concat(...parts) {
+ let n = 0;
+ for (const p of parts) n += p.length;
+ const out = new Uint8Array(n);
+ let o = 0;
+ for (const p of parts) {
+ out.set(p, o);
+ o += p.length;
+ }
+ return out;
+}
+
+export function u32be(n) {
+ return new Uint8Array([(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]);
+}
+
+export function u32le(n) {
+ return new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
+}
+
+// u64be takes a BigInt, because a version counter that silently loses
+// precision above 2^53 would break the AAD binding in a way nothing would
+// notice until a ciphertext failed to open.
+export function u64be(n) {
+ const v = BigInt(n);
+ const out = new Uint8Array(8);
+ for (let i = 7; i >= 0; i--) out[i] = Number((v >> BigInt(8 * (7 - i))) & 0xffn);
+ return out;
+}
+
+// equal is constant-time over the compared bytes. Length is not secret here
+// — every field this compares is fixed-width — but the contents are.
+export function equal(a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+export function randomBytes(n) {
+ return crypto.getRandomValues(new Uint8Array(n));
+}
diff --git a/internal/contra/static/crypto/code.js b/internal/contra/static/crypto/code.js
new file mode 100644
index 0000000..c1cdb57
--- /dev/null
+++ b/internal/contra/static/crypto/code.js
@@ -0,0 +1,165 @@
+// The printed credentials: Crockford Base32, the checksum, and the repair
+// that makes a mistyped character survivable.
+//
+// This is what a person reads off aged paper at the worst moment they will
+// ever use this product. Pinned by vectors/contra-v1.json.
+
+import { sha256 } from "./kdf.js";
+
+// Crockford's alphabet: no I, L, O or U. Decoding maps O to 0 and I and L to
+// 1 — the exact confusions humans and OCR make reading print.
+export const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+
+export const TAG_ACCOUNT_KEY = "A1";
+export const TAG_RECOVERY = "C1";
+
+const DATA_SYMBOLS = 26;
+const CHECK_SYMBOLS = 4;
+const CODE_SYMBOLS = 2 + DATA_SYMBOLS + CHECK_SYMBOLS;
+
+function symbolValue(ch) {
+ let c = ch.toUpperCase();
+ if (c === "O") c = "0";
+ if (c === "I" || c === "L") c = "1";
+ const i = ALPHABET.indexOf(c);
+ return c === "" ? -1 : i;
+}
+
+// normalize strips hyphens and whitespace, uppercases, and applies the
+// O/I/L mappings. The two-character tag survives it unchanged, so callers
+// normalise first and match the tag afterwards.
+export function normalize(s) {
+ let out = "";
+ for (const ch of s) {
+ if (ch === "-" || /\s/.test(ch)) continue;
+ const v = symbolValue(ch);
+ if (v < 0) throw new Error("contra: character outside the Crockford alphabet");
+ out += ALPHABET[v];
+ }
+ return out;
+}
+
+// base32Encode packs bytes MSB-first and left-aligned, so 16 bytes become 26
+// symbols whose last one carries two zero pad bits.
+//
+// Byte packing rather than Crockford's big-integer form: no language should
+// need a bignum to read a recovery code, and this module is capped at 300
+// lines by test.
+export function base32Encode(b) {
+ const n = Math.ceil((b.length * 8) / 5);
+ let out = "";
+ for (let i = 0; i < n; i++) {
+ const bit = i * 5;
+ const by = bit >> 3;
+ const off = bit & 7;
+ let v = b[by] << 8;
+ if (by + 1 < b.length) v |= b[by + 1];
+ out += ALPHABET[(v >> (11 - off)) & 0x1f];
+ }
+ return out;
+}
+
+// base32Decode is strict: it rejects a non-zero pad in the final symbol, so
+// every value has exactly one encoding. Without that, two different strings
+// decode to one key and the checksum stops meaning what it says.
+export function base32Decode(sym, nbytes) {
+ if (sym.length !== Math.ceil((nbytes * 8) / 5)) {
+ throw new Error("contra: wrong number of symbols");
+ }
+ const out = new Uint8Array(nbytes);
+ let acc = 0;
+ let bits = 0;
+ let o = 0;
+ for (const ch of sym) {
+ const v = symbolValue(ch);
+ if (v < 0) throw new Error("contra: character outside the Crockford alphabet");
+ acc = (acc << 5) | v;
+ bits += 5;
+ if (bits >= 8) {
+ bits -= 8;
+ out[o++] = (acc >> bits) & 0xff;
+ }
+ }
+ if ((acc & ((1 << bits) - 1)) !== 0) {
+ throw new Error("contra: non-zero pad bits in the final symbol");
+ }
+ return out;
+}
+
+// checksum20 is the first 20 bits, MSB-first, of SHA-256 over the raw
+// payload bytes — not over the string. Twenty bits is enough to catch a
+// mistyped character and small enough to fit four symbols on a printed line.
+export async function checksum20(payload) {
+ const d = await sha256(payload);
+ return (d[0] << 12) | (d[1] << 4) | (d[2] >> 4);
+}
+
+function checksumSymbols(c) {
+ let out = "";
+ for (let i = 0; i < CHECK_SYMBOLS; i++) out += ALPHABET[(c >> (15 - 5 * i)) & 0x1f];
+ return out;
+}
+
+export async function normalizedCode(tag, payload) {
+ return tag + base32Encode(payload) + checksumSymbols(await checksum20(payload));
+}
+
+// formatCode renders what goes on the paper:
+// TAG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX.
+export async function formatCode(tag, payload) {
+ const norm = await normalizedCode(tag, payload);
+ let out = tag;
+ for (let i = 2; i < norm.length; i++) {
+ if ((i - 2) % 5 === 0) out += "-";
+ out += norm[i];
+ }
+ return out;
+}
+
+async function parseNormalized(tag, norm) {
+ if (norm.length !== CODE_SYMBOLS) throw new Error("contra: wrong number of symbols");
+ if (norm.slice(0, 2) !== tag) throw new Error("contra: wrong tag");
+ const payload = base32Decode(norm.slice(2, 2 + DATA_SYMBOLS), 16);
+ if (checksumSymbols(await checksum20(payload)) !== norm.slice(2 + DATA_SYMBOLS)) {
+ throw new Error("contra: checksum does not match");
+ }
+ return payload;
+}
+
+// parseCode normalises, checks the tag and the checksum, and returns the
+// payload. A checksum failure is where the caller reaches for repairCode.
+export async function parseCode(tag, s) {
+ return parseNormalized(tag, normalize(s));
+}
+
+// repairCode brute-forces every single-character substitution over the 30
+// payload positions — about a thousand candidates — and returns every one
+// whose checksum passes, in position-then-alphabet order.
+//
+// It deliberately does not pick a winner. Twenty bits collides roughly once
+// in eleven hundred attempts, so the caller tries each candidate against the
+// KCV, which does not collide. Choosing here would sometimes choose wrong
+// and hand back the wrong key without saying so.
+export async function repairCode(tag, s) {
+ const norm = normalize(s);
+ if (norm.length !== CODE_SYMBOLS) throw new Error("contra: wrong number of symbols");
+ const out = [];
+ const buf = norm.split("");
+ for (let pos = 2; pos < CODE_SYMBOLS; pos++) {
+ const orig = buf[pos];
+ for (const ch of ALPHABET) {
+ if (ch === orig) continue;
+ buf[pos] = ch;
+ const candidate = buf.join("");
+ try {
+ await parseNormalized(tag, candidate);
+ out.push(candidate);
+ } catch {
+ // Not a repair. The overwhelming majority of the ~930 candidates
+ // land here; a throw is the expected outcome, not an error path.
+ }
+ }
+ buf[pos] = orig;
+ }
+ return out;
+}
diff --git a/internal/contra/static/crypto/kdf.js b/internal/contra/static/crypto/kdf.js
new file mode 100644
index 0000000..7b9c7ab
--- /dev/null
+++ b/internal/contra/static/crypto/kdf.js
@@ -0,0 +1,117 @@
+// The key hierarchy's derivations, browser side.
+//
+// This is the JS half of the contract in vectors/contra-v1.json. Go, this,
+// and Swift must produce identical bytes for every label below; the vector
+// suite is what proves it, and cross-platform KDF drift is the bug class
+// behind the 1Password PRF mismatch that shaped our recovery design.
+//
+// Changing anything here is changing the protocol.
+
+import { concat, enc } from "./bytes.js";
+
+// The versioned HKDF labels. Every derivation carries one, so holding the
+// output of one branch says nothing about any other.
+export const LABEL_LOGIN = "contra/v1/login";
+export const LABEL_KEK = "contra/v1/kek";
+export const LABEL_RC_LOGIN = "contra/v1/rc-login";
+export const LABEL_RC_KEK = "contra/v1/rc-kek";
+export const LABEL_PRF_KEK = "contra/v1/prf-kek";
+export const LABEL_KCV = "contra/v1/kcv";
+export const LABEL_EC_P256 = "contra/v1/ec-p256";
+export const LABEL_AAD = "contra/v1/aad";
+
+// The client's hard floor on KDF parameters, from DESIGN.md.
+//
+// Without it a malicious server serves m=8KiB, t=1, harvests one challenge
+// signature, and cracks the passphrase cheaply offline. The floor is the
+// defence, not the ceremony — so it is enforced here, on parameters the
+// server sent, before a single expensive byte is derived.
+export const ARGON2_MIN_MEMORY_KIB = 64 * 1024;
+export const ARGON2_MIN_TIME = 3;
+export const ARGON2_LANES = 1;
+
+export class WeakParametersError extends Error {}
+
+// checkParams refuses anything below the floor, and refuses lanes != 1
+// outright: Argon2id's output depends on p, and WASM without
+// SharedArrayBuffer cannot parallelise, so any other value is a set this
+// browser could not reproduce byte-identically even if it wanted to.
+export function checkParams(p) {
+ if (!p || p.alg !== "argon2id") {
+ throw new WeakParametersError(`contra: unknown KDF ${p && p.alg}`);
+ }
+ if (!(p.m_kib >= ARGON2_MIN_MEMORY_KIB)) {
+ throw new WeakParametersError(
+ `contra: server offered m=${p.m_kib}KiB, below the floor of ${ARGON2_MIN_MEMORY_KIB}KiB`,
+ );
+ }
+ if (!(p.t >= ARGON2_MIN_TIME)) {
+ throw new WeakParametersError(
+ `contra: server offered t=${p.t}, below the floor of ${ARGON2_MIN_TIME}`,
+ );
+ }
+ if (p.p !== ARGON2_LANES) {
+ throw new WeakParametersError(`contra: server offered p=${p.p}, only 1 is reproducible`);
+ }
+ return p;
+}
+
+// hkdf is RFC 5869 extract-then-expand over SHA-256 — the one mode this
+// protocol uses. An absent salt is a zero-length salt, which RFC 5869 turns
+// into HashLen zero bytes; WebCrypto does the same, so an empty Uint8Array
+// is the honest spelling of "no salt".
+export async function hkdf(ikm, salt, info, length = 32) {
+ const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
+ const bits = await crypto.subtle.deriveBits(
+ {
+ name: "HKDF",
+ hash: "SHA-256",
+ salt: salt ?? new Uint8Array(0),
+ info: typeof info === "string" ? enc.encode(info) : info,
+ },
+ key,
+ length * 8,
+ );
+ return new Uint8Array(bits);
+}
+
+export async function hmacSha256(key, message) {
+ const k = await crypto.subtle.importKey(
+ "raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
+}
+
+// kcv is the 16-byte key-check value every wrap row carries.
+//
+// AES-GCM is not key-committing, so this is the commitment check. It also
+// distinguishes "wrong key" from "corrupted blob", which is what lets a
+// passkey provider returning a mismatched PRF be treated as *fast unlock
+// unavailable* rather than reported as an authentication failure — the
+// 1Password-class failure DESIGN.md requires us to tolerate.
+export async function kcv(kek) {
+ return (await hmacSha256(kek, enc.encode(LABEL_KCV))).slice(0, 16);
+}
+
+export async function sha256(b) {
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", b));
+}
+
+// blindIndex is the identity anchor: HMAC of the normalised address under a
+// pepper. It proves identity for sign-in and can never decrypt anything.
+//
+// The browser only ever computes this when the server hands it the pepper,
+// which it does not — so this exists for tests and for the daemon. The
+// instance computes the index; nothing stores the address.
+export async function blindIndex(pepper, email) {
+ return hmacSha256(pepper, enc.encode(normalizeEmail(email)));
+}
+
+// normalizeEmail is deliberately minimal: trim and lowercase, nothing more.
+// Anything cleverer (stripping dots, cutting +tags) would silently merge two
+// addresses a user believes are separate accounts, and there is no recovery
+// path from having merged two vaults.
+export function normalizeEmail(email) {
+ return email.trim().toLowerCase();
+}
+
+export { concat };
diff --git a/internal/contra/static/crypto/p256.js b/internal/contra/static/crypto/p256.js
new file mode 100644
index 0000000..63458e8
--- /dev/null
+++ b/internal/contra/static/crypto/p256.js
@@ -0,0 +1,101 @@
+// The deterministic P-256 login keypair, and the signed challenge.
+//
+// Sign-in is a signature, not a verifier: the client derives this keypair
+// from the login branch and the server stores only the public half. A fully
+// compromised server therefore holds something that cannot decrypt, cannot
+// be replayed to impersonate, and adds no offline attack surface it did not
+// already have via the wraps.
+
+import { concat } from "./bytes.js";
+import { hkdf, LABEL_EC_P256 } from "./kdf.js";
+
+// The P-256 group order n, big-endian. Used for the range check only — a
+// byte-wise comparison, so nothing here needs a bignum.
+const N = new Uint8Array([
+ 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff,
+ 0xff, 0xff, 0xff, 0xff, 0xff, 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17,
+ 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
+]);
+
+// PKCS#8 PrivateKeyInfo for an EC key on prime256v1, with the public half
+// omitted — WebCrypto recomputes it. Fixed length, so the whole prefix is a
+// constant and the scalar is simply appended.
+const PKCS8_PREFIX = new Uint8Array([
+ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
+ 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
+ 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
+]);
+
+// inRange is the rejection test: a candidate is usable only when
+// 1 <= d <= n-1. A uniform 32-byte string is not a uniform scalar mod n, and
+// clamping instead of rejecting would bias the key.
+function inRange(c) {
+ let nonzero = false;
+ for (const b of c) {
+ if (b) {
+ nonzero = true;
+ break;
+ }
+ }
+ if (!nonzero) return false;
+ for (let i = 0; i < 32; i++) {
+ if (c[i] < N[i]) return true;
+ if (c[i] > N[i]) return false;
+ }
+ return false; // exactly n
+}
+
+function b64urlToBytes(s) {
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
+ const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
+ const out = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+}
+
+// deriveP256 turns a 32-byte seed into the keypair, rejection-sampling
+// exactly as the Go and Swift sides do. A rejection is a ~2^-32 event, so
+// the loop past i=0 is only ever reached by the searched seed in the vector
+// file — which is why that seed exists.
+export async function deriveP256(seed) {
+ for (let i = 0; i < 256; i++) {
+ const info = new Uint8Array(LABEL_EC_P256.length + 1);
+ for (let k = 0; k < LABEL_EC_P256.length; k++) info[k] = LABEL_EC_P256.charCodeAt(k);
+ info[LABEL_EC_P256.length] = i;
+
+ const candidate = await hkdf(seed, null, info, 32);
+ if (!inRange(candidate)) continue;
+
+ const privateKey = await crypto.subtle.importKey(
+ "pkcs8",
+ concat(PKCS8_PREFIX, candidate),
+ { name: "ECDSA", namedCurve: "P-256" },
+ true,
+ ["sign"],
+ );
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey);
+ const publicKey = concat(
+ new Uint8Array([0x04]),
+ b64urlToBytes(jwk.x),
+ b64urlToBytes(jwk.y),
+ );
+ return { scalar: candidate, privateKey, publicKey, candidatesTried: i + 1 };
+ }
+ throw new Error("contra: no valid P-256 scalar in 256 candidates");
+}
+
+// signChallenge produces the ECDSA-SHA-256 signature the server verifies
+// against the stored public key. WebCrypto emits the raw r||s form, which is
+// what the Go side expects — not the ASN.1 wrapper.
+export async function signChallenge(privateKey, challenge) {
+ const sig = await crypto.subtle.sign(
+ { name: "ECDSA", hash: "SHA-256" }, privateKey, challenge);
+ return new Uint8Array(sig);
+}
+
+export async function verifyChallenge(publicKey, challenge, signature) {
+ const key = await crypto.subtle.importKey(
+ "raw", publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
+ return crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" }, key, signature, challenge);
+}
diff --git a/internal/contra/static/crypto/sealed.js b/internal/contra/static/crypto/sealed.js
new file mode 100644
index 0000000..b30a9ca
--- /dev/null
+++ b/internal/contra/static/crypto/sealed.js
@@ -0,0 +1,90 @@
+// AEAD: the AAD binding, AES-256-GCM, and the size quantisation that keeps
+// item lengths out of the bucket.
+//
+// Pinned by vectors/contra-v1.json. The Go side is internal/contra/crypto.
+
+import { concat, enc, u32be, u64be } from "./bytes.js";
+import { LABEL_AAD } from "./kdf.js";
+
+export const OBJECT_WRAP_PW = "wrap.pw";
+export const OBJECT_WRAP_RC = "wrap.rc";
+export const OBJECT_WRAP_PRF = "wrap.prf";
+export const OBJECT_VAULT_KEYSET = "vault.keyset";
+export const OBJECT_ITEM = "item";
+
+// aad binds (scope, object type, object id, version) into every AEAD, so a
+// malicious server cannot transplant a ciphertext between slots or between
+// users.
+//
+// The scope id is the account id for account-scoped objects and the vault id
+// for vault-scoped ones. Length-prefixed because plain concatenation is not
+// injective — without the lengths, different tuples produce identical AAD,
+// which is the exact transplant the binding exists to prevent — and
+// domain-tagged so AAD from this protocol cannot be replayed into another.
+export function aad(scopeId, objectType, objectId, version) {
+ const type = enc.encode(objectType);
+ return concat(
+ enc.encode(LABEL_AAD),
+ new Uint8Array([0]),
+ u32be(scopeId.length), scopeId,
+ u32be(type.length), type,
+ u32be(objectId.length), objectId,
+ u64be(version),
+ );
+}
+
+async function gcmKey(raw, usage) {
+ return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [usage]);
+}
+
+// seal returns ciphertext with the 128-bit tag appended — the layout the
+// wrap rows and item rows both store.
+export async function seal(key, nonce, additionalData, plaintext) {
+ const k = await gcmKey(key, "encrypt");
+ const out = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 }, k, plaintext);
+ return new Uint8Array(out);
+}
+
+// open reverses seal. A failure is either the wrong key or a tampered blob;
+// the wrap's KCV is what tells those apart, and callers on an unlock path
+// should check it first so they can say which.
+export async function open(key, nonce, additionalData, sealed) {
+ const k = await gcmKey(key, "decrypt");
+ const out = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 }, k, sealed);
+ return new Uint8Array(out);
+}
+
+// PAD_BUCKET is the size item plaintexts are quantised into before sealing.
+//
+// A1 tolerates size residue; that is no reason to hand over item-length
+// fingerprints for free. An unpadded vault publishes the length of every
+// password and every note to anyone holding the bucket, and lengths are
+// enough to pick the interesting rows out of a pile of ciphertext.
+export const PAD_BUCKET = 256;
+
+// pad uses ISO/IEC 7816-4: one 0x80 byte, then zeros. It always adds at
+// least one byte, so an input that is already bucket-aligned is not a
+// special case somebody's second implementation gets wrong.
+export function pad(b) {
+ let n = b.length + 1;
+ const r = n % PAD_BUCKET;
+ if (r !== 0) n += PAD_BUCKET - r;
+ const out = new Uint8Array(n);
+ out.set(b);
+ out[b.length] = 0x80;
+ return out;
+}
+
+export function unpad(b) {
+ if (b.length === 0 || b.length % PAD_BUCKET !== 0) {
+ throw new Error("contra: padded length is not a whole number of buckets");
+ }
+ for (let i = b.length - 1; i >= 0; i--) {
+ if (b[i] === 0x00) continue;
+ if (b[i] === 0x80) return b.slice(0, i);
+ break;
+ }
+ throw new Error("contra: malformed padding bytes");
+}
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go
new file mode 100644
index 0000000..55432d3
--- /dev/null
+++ b/internal/contratest/jsmodules_test.go
@@ -0,0 +1,88 @@
+package contratest
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// The JS module cap, from AGENTS.md: "small self-contained ES modules, no
+// build step, go:embed, 300 lines per module enforced by test, caps ratchet
+// down only."
+//
+// The cap is not tidiness. This is the code that holds the user's keys in the
+// one place we cannot sign or pin — a browser — and the only real defence is
+// that a person can read all of it. A 900-line module does not get read.
+const jsModuleCap = 300
+
+// jsHighWater is the ratchet: each module's current length, and the most it
+// may ever be again. Shrinking a module means lowering its number here;
+// growing one is a conversation, not an edit.
+//
+// A module missing from this map fails the test rather than defaulting to the
+// cap — a new module in the crypto path is exactly the thing that should not
+// arrive without somebody noticing.
+var jsHighWater = map[string]int{
+ "argon2id.js": 292,
+ "blake2b.js": 99,
+ "bytes.js": 61,
+ "code.js": 165,
+ "kdf.js": 117,
+ "p256.js": 101,
+ "sealed.js": 90,
+}
+
+func TestJSModulesStaySmall(t *testing.T) {
+ dir := filepath.Join("..", "contra", "static", "crypto")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("read %s: %v", dir, err)
+ }
+
+ seen := map[string]bool{}
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".js") {
+ continue
+ }
+ seen[name] = true
+
+ raw, err := os.ReadFile(filepath.Join(dir, name))
+ if err != nil {
+ t.Errorf("read %s: %v", name, err)
+ continue
+ }
+ lines := bytes.Count(raw, []byte("\n"))
+ if !bytes.HasSuffix(raw, []byte("\n")) {
+ lines++
+ }
+
+ limit, declared := jsHighWater[name]
+ if !declared {
+ t.Errorf("%s is not in jsHighWater. A new module in the browser crypto "+
+ "path needs a deliberate line budget, not a default one — add it "+
+ "with its current length (%d).", name, lines)
+ continue
+ }
+ if lines > jsModuleCap {
+ t.Errorf("%s is %d lines, over the %d-line cap", name, lines, jsModuleCap)
+ }
+ if lines > limit {
+ t.Errorf("%s grew from %d to %d lines. Caps ratchet down only: split it, "+
+ "or make the case for a bigger budget.", name, limit, lines)
+ }
+ if lines < limit {
+ t.Errorf("%s shrank from %d to %d lines — good. Lower its jsHighWater "+
+ "entry to %d so the ratchet keeps the ground you gained.",
+ name, limit, lines, lines)
+ }
+ }
+
+ for name := range jsHighWater {
+ if !seen[name] {
+ t.Errorf("jsHighWater lists %s, which no longer exists", name)
+ }
+ }
+}
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs
new file mode 100644
index 0000000..6a3bfaf
--- /dev/null
+++ b/test/crypto.test.mjs
@@ -0,0 +1,296 @@
+// The JS half of the crypto contract, against vectors/contra-v1.json.
+//
+// AGENTS.md: "Crypto is byte-identical in Go and JS, pinned by shared golden
+// vectors. Change one side and you must change the other." This suite is
+// what makes that enforceable rather than aspirational — it runs the same
+// file internal/contra/crypto's Go tests run, and the file was generated by
+// neither of them (vectors/gen/gen.c, against OpenSSL).
+//
+// This is a HARD gate in CI. A security product must not quietly stop
+// checking that the browser's crypto matches the server's, so `make test-js`
+// fails when node is missing rather than skipping.
+//
+// node --test test/
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name);
+
+const { toHex, fromHex } = await import(mod("bytes.js"));
+const kdf = await import(mod("kdf.js"));
+const code = await import(mod("code.js"));
+const sealedMod = await import(mod("sealed.js"));
+const { deriveP256, signChallenge, verifyChallenge } = await import(mod("p256.js"));
+const { argon2id } = await import(mod("argon2id.js"));
+
+const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8"));
+assert.equal(V.format, "contra-golden-vectors");
+assert.equal(V.version, 1);
+
+test("HKDF: every contra/v1 label", async () => {
+ const seen = new Set();
+ for (const c of V.hkdf.cases) {
+ const salt = c.salt_hex ? fromHex(c.salt_hex) : null;
+ const got = await kdf.hkdf(fromHex(c.ikm_hex), salt, c.info, c.l);
+ assert.equal(toHex(got), c.okm_hex, `HKDF ${c.name} (${c.info})`);
+ seen.add(c.info);
+ }
+ for (const label of [
+ kdf.LABEL_LOGIN, kdf.LABEL_KEK, kdf.LABEL_RC_LOGIN,
+ kdf.LABEL_RC_KEK, kdf.LABEL_PRF_KEK,
+ ]) {
+ assert.ok(seen.has(label), `no vector covers HKDF label ${label}`);
+ }
+});
+
+test("KCV", async () => {
+ for (const c of V.kcv.cases) {
+ assert.equal(toHex(await kdf.kcv(fromHex(c.kek_hex))), c.kcv_hex, c.name);
+ }
+});
+
+test("P-256 from a seed, including the rejection branch", async () => {
+ let sawRejection = false;
+ for (const c of V.p256_from_seed.cases) {
+ const key = await deriveP256(fromHex(c.seed_hex));
+ assert.equal(toHex(key.scalar), c.d_hex, `${c.name} scalar`);
+ assert.equal(toHex(key.publicKey), c.public_uncompressed_hex, `${c.name} public key`);
+ assert.equal(key.candidatesTried, c.candidates_tried, `${c.name} candidates tried`);
+ if (c.candidates_tried > 1) sawRejection = true;
+ }
+ // Without a rejecting case the loop past i=0 is dead code here too, and
+ // the JS side would agree with Go right up until a real user hit it.
+ assert.ok(sawRejection, "no vector exercises the rejection branch");
+});
+
+test("signed challenge round-trips against the derived public key", async () => {
+ const c = V.p256_from_seed.cases[0];
+ const key = await deriveP256(fromHex(c.seed_hex));
+ const challenge = new TextEncoder().encode("contra/v1/challenge/test");
+ const sig = await signChallenge(key.privateKey, challenge);
+ assert.ok(await verifyChallenge(key.publicKey, challenge, sig));
+ challenge[0] ^= 1;
+ assert.equal(await verifyChallenge(key.publicKey, challenge, sig), false);
+});
+
+test("Crockford: alphabet, encoding, normalisation, strict decoding", async () => {
+ assert.equal(code.ALPHABET, V.crockford.alphabet);
+
+ for (const c of V.crockford.encode_cases) {
+ assert.equal(code.base32Encode(fromHex(c.bytes_hex)), c.symbols);
+ }
+ for (const c of V.crockford.normalize_cases) {
+ if (c.error) {
+ assert.throws(() => code.normalize(c.input), undefined, `${c.input} must not normalise`);
+ } else {
+ assert.equal(code.normalize(c.input), c.normalized, c.input);
+ }
+ }
+ for (const c of V.crockford.decode_cases) {
+ if (c.error) {
+ assert.throws(() => code.base32Decode(c.symbols, 16), undefined, c.symbols);
+ } else {
+ assert.equal(toHex(code.base32Decode(c.symbols, 16)), c.bytes_hex);
+ }
+ }
+});
+
+test("the printed code, and the O/I/L confusions it has to survive", async () => {
+ for (const c of V.printed_code.cases) {
+ const payload = fromHex(c.payload_hex);
+ assert.equal(await code.checksum20(payload), c.checksum20, `${c.name} checksum`);
+ assert.equal(await code.normalizedCode(c.tag, payload), c.normalized, `${c.name} normalized`);
+ assert.equal(await code.formatCode(c.tag, payload), c.printed, `${c.name} printed`);
+
+ assert.equal(toHex(await code.parseCode(c.tag, c.printed)), c.payload_hex);
+
+ // What aged paper and OCR actually hand back.
+ const confused = c.printed.toLowerCase().replace(/0/g, "o").replace(/1/g, "l");
+ assert.equal(toHex(await code.parseCode(c.tag, confused)), c.payload_hex, confused);
+ }
+});
+
+test("repair recovers one substituted character", async () => {
+ const r = V.printed_code.repair;
+ await assert.rejects(() => code.parseCode(code.TAG_RECOVERY, r.typo_normalized));
+ const got = await code.repairCode(code.TAG_RECOVERY, r.typo_normalized);
+ assert.deepEqual(got, r.candidates);
+ assert.ok(got.includes(r.correct_normalized), "repair lost the correct code");
+});
+
+test("AEAD: the AAD binding and every wrap", async () => {
+ const accountId = fromHex(V.aead.account_id_hex);
+ const rk = fromHex(V.aead.rk_hex);
+
+ for (const w of V.aead.wraps) {
+ const a = sealedMod.aad(accountId, w.object_type, fromHex(w.wrap_id_hex), w.version);
+ assert.equal(toHex(a), w.aad_hex, `${w.name} AAD`);
+ assert.equal(toHex(await kdf.kcv(fromHex(w.kek_hex))), w.kcv_hex, `${w.name} KCV`);
+
+ const out = await sealedMod.seal(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, rk);
+ assert.equal(toHex(out.slice(0, out.length - 16)), w.ciphertext_hex, `${w.name} ciphertext`);
+ assert.equal(toHex(out.slice(out.length - 16)), w.tag_hex, `${w.name} tag`);
+
+ const back = await sealedMod.open(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, out);
+ assert.equal(toHex(back), V.aead.rk_hex, `${w.name} did not open to RK`);
+ }
+});
+
+test("AEAD: the item, scoped to its vault", async () => {
+ const it = V.aead.item;
+ const a = sealedMod.aad(
+ fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version);
+ assert.equal(toHex(a), it.aad_hex);
+
+ const out = await sealedMod.seal(
+ fromHex(it.k_vault_hex), fromHex(it.nonce_hex), a, fromHex(it.plaintext_hex));
+ assert.equal(toHex(out.slice(0, out.length - 16)), it.ciphertext_hex);
+ assert.equal(toHex(out.slice(out.length - 16)), it.tag_hex);
+});
+
+// The binding is only worth anything if breaking it breaks decryption.
+test("a transplanted ciphertext does not open", async () => {
+ const it = V.aead.item;
+ const key = fromHex(it.k_vault_hex);
+ const nonce = fromHex(it.nonce_hex);
+ const good = sealedMod.aad(
+ fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version);
+ const out = await sealedMod.seal(key, nonce, good, fromHex(it.plaintext_hex));
+
+ const otherItem = fromHex(it.item_id_hex);
+ otherItem[0] ^= 1;
+ const bad = [
+ ["different item", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, otherItem, it.version)],
+ ["different version", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version + 1)],
+ ["different type", sealedMod.aad(fromHex(it.vault_id_hex), "wrap.pw", fromHex(it.item_id_hex), it.version)],
+ ];
+ for (const [name, a] of bad) {
+ await assert.rejects(() => sealedMod.open(key, nonce, a, out), undefined,
+ `${name}: transplant succeeded, so the AAD binding is not holding`);
+ }
+});
+
+test("padding quantises into 256-byte buckets and round-trips", () => {
+ for (const n of [0, 1, 100, 255, 256, 257, 1000]) {
+ const b = new Uint8Array(n).fill(0x41);
+ const p = sealedMod.pad(b);
+ assert.equal(p.length % sealedMod.PAD_BUCKET, 0, `pad(${n}) is not bucket-aligned`);
+ assert.ok(p.length > n, `pad(${n}) must always add at least one byte`);
+ assert.equal(toHex(sealedMod.unpad(p)), toHex(b), `unpad(pad(${n}))`);
+ }
+});
+
+test("the KDF parameter floor refuses what a malicious server would offer", () => {
+ const ok = { alg: "argon2id", m_kib: 65536, t: 3, p: 1 };
+ assert.deepEqual(kdf.checkParams(ok), ok);
+ assert.deepEqual(kdf.checkParams({ alg: "argon2id", m_kib: 131072, t: 4, p: 1 }).t, 4);
+
+ // The attack this exists to stop: cheap parameters, one harvested
+ // challenge signature, offline cracking.
+ for (const bad of [
+ { alg: "argon2id", m_kib: 8, t: 1, p: 1 },
+ { alg: "argon2id", m_kib: 65535, t: 3, p: 1 },
+ { alg: "argon2id", m_kib: 65536, t: 2, p: 1 },
+ { alg: "argon2id", m_kib: 65536, t: 3, p: 4 },
+ { alg: "pbkdf2", m_kib: 65536, t: 3, p: 1 },
+ null,
+ ]) {
+ assert.throws(() => kdf.checkParams(bad), kdf.WeakParametersError,
+ `accepted ${JSON.stringify(bad)}`);
+ }
+});
+
+test("Argon2id matches the published PHC reference vector", async () => {
+ // Anchored outside this repo before anything of ours is checked. If this is
+ // wrong, agreeing with our own vectors would only mean two of our
+ // implementations are wrong in the same way.
+ const p = new TextEncoder().encode("password");
+ const s = new TextEncoder().encode("somesalt");
+ assert.equal(
+ toHex(await argon2id(p, s, { m_kib: 65536, t: 2, p: 1 }, 32)),
+ "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7",
+ "PHC Argon2id t=2 m=65536 p=1",
+ );
+});
+
+// The parameters CONTRA runs at are one point in a large space, and the
+// corners are where a hand-written Argon2 goes wrong: a segment shorter than
+// one address block, a pass count above two, an empty password, a tag longer
+// than 32 bytes. Each of these was cross-checked against OpenSSL 3.5 when it
+// was written; pinning them here means a refactor cannot quietly break a path
+// the 64 MiB case does not reach.
+test("Argon2id at the corners the 64 MiB case does not reach", async () => {
+ const enc = new TextEncoder();
+ for (const [pw, salt, m, t, len, want] of [
+ ["password", "somesalt", 8, 1, 32,
+ "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3"],
+ ["password", "somesalt", 256, 2, 32,
+ "9dfeb910e80bad0311fee20f9c0e2b12c17987b4cac90c2ef54d5b3021c68bfe"],
+ ["password", "somesalt", 512, 3, 32,
+ "da62c0cfaa06b86aa353ebb1fbab1d0a4c997fb6d1e8e5a1f2fe536753284d7a"],
+ ["password", "somesalt", 1024, 4, 32,
+ "b0f18b5aca2c3df1b819c16c26369f14e92fddf90bcf160f4bc1abb3f6624174"],
+ ["password", "somesalt", 4096, 2, 32,
+ "8da7091d0f8fa01b2d07f66854d93cadedecd72cd500166e8dc7868b054d2679"],
+ ]) {
+ assert.equal(
+ toHex(await argon2id(enc.encode(pw), enc.encode(salt), { m_kib: m, t, p: 1 }, len)),
+ want, `m=${m} t=${t} len=${len}`);
+ }
+});
+
+test("Argon2id matches the golden vector at the parameters DESIGN.md pins", async () => {
+ const c = V.argon2id.cases[0];
+ const got = await argon2id(
+ new TextEncoder().encode(c.passphrase_utf8),
+ fromHex(c.salt_hex),
+ { m_kib: V.argon2id.params.m_kib, t: V.argon2id.params.t, p: V.argon2id.params.p },
+ V.argon2id.params.tag_len,
+ );
+ assert.equal(toHex(got), c.out_hex, c.name);
+});
+
+// The whole of DESIGN.md's "The shape", browser side, so a divergence can be
+// bisected rather than stared at.
+test("the account chain, end to end", async () => {
+ const c = V.account_chain;
+ const sPW = await argon2id(
+ new TextEncoder().encode(c.passphrase_utf8),
+ fromHex(c.salt_pw_hex),
+ { m_kib: 65536, t: 3, p: 1 },
+ 32,
+ );
+ assert.equal(toHex(sPW), c.s_pw_hex, "S_pw");
+
+ const seedLogin = await kdf.hkdf(sPW, null, kdf.LABEL_LOGIN, 32);
+ assert.equal(toHex(seedLogin), c.seed_login_hex, "seed_login");
+ assert.equal(toHex((await deriveP256(seedLogin)).publicKey), c.lk_pw_public_hex, "LK_pw");
+
+ const kekPW = await kdf.hkdf(sPW, fromHex(c.account_key_hex), kdf.LABEL_KEK, 32);
+ assert.equal(toHex(kekPW), c.kek_pw_hex, "KEK_pw");
+
+ const rc = fromHex(c.recovery_code_hex);
+ const seedRC = await kdf.hkdf(rc, null, kdf.LABEL_RC_LOGIN, 32);
+ assert.equal(toHex(seedRC), c.seed_rc_login_hex, "seed_rc_login");
+ assert.equal(toHex((await deriveP256(seedRC)).publicKey), c.lk_rc_public_hex, "LK_rc");
+ assert.equal(toHex(await kdf.hkdf(rc, null, kdf.LABEL_RC_KEK, 32)), c.kek_rc_hex, "KEK_rc");
+
+ const kekPRF = await kdf.hkdf(
+ fromHex(c.prf_output_hex), fromHex(c.cred_salt_hex), kdf.LABEL_PRF_KEK, 32);
+ assert.equal(toHex(kekPRF), c.kek_prf_hex, "KEK_prf");
+
+ // The three wraps are peers: each opens RK on its own.
+ for (const w of V.aead.wraps) {
+ const a = sealedMod.aad(
+ fromHex(V.aead.account_id_hex), w.object_type, fromHex(w.wrap_id_hex), w.version);
+ const back = await sealedMod.open(
+ fromHex(w.kek_hex), fromHex(w.nonce_hex), a,
+ fromHex(w.ciphertext_hex + w.tag_hex));
+ assert.equal(toHex(back), c.rk_hex, `${w.name} did not open to RK`);
+ }
+});