carlos / contra Public

web: the Emergency Kit — two PDFs, generated in the browser, checked by tools that are not ours

I said last night I would not write a QR encoder, because there was no
decoder here to check one against and a recovery QR that is subtly wrong is a
lost vault. Paul installed one. So the objection is gone and the work is
takeable on the same terms as everything else: an independent oracle, or it
does not land.

Two oracles, in fact, and the second matters more than the first. ZXing
decodes every symbol back to its payload, which answers "will it scan?".
Python's `qrcode` produces the same matrix bit-for-bit at the same version,
EC level and mask, which answers "is it the RIGHT symbol?" — the question
decoding cannot answer, because error correction exists to make damaged
symbols readable and would happily hide a real mistake behind its own
redundancy, right up until somebody's print was also slightly faded.

224 symbols: 7 payloads across all four EC levels and all eight masks. All
scan, all identical to the reference. The encoder is deliberately narrow —
alphanumeric mode, versions 1 to 6 — because a CONTRA code is uppercase
Crockford plus hyphens and needs version 3, and stopping below 7 means no
version information block. Anything outside that throws rather than guessing.

Writing it found one bug and the oracle placed it exactly: 12 modules wrong,
all of them in the format information, none in the data. The format value was
right and my bit ordering was reversed — the two copies of it run in opposite
directions, which is the kind of detail a decode-only check would have caught
as "does not scan" with no idea where to look.

The PDF writer is written rather than vendored for the reason the QR encoder
is: these documents are generated in the browser and must never touch the
server, so the code that makes them ships to every user, and a PDF library is
tens of thousands of lines nobody here would read. It supports the base-14
fonts, text, rectangles and lines. That is the kit's whole vocabulary.

Two files, not one page with a fold. The Account Key alone is nearly
harmless; the recovery code alone is full takeover; and the risk being
managed is storage, because people save PDFs and one file puts both in the
same folder forever.

The kit tests render the actual documents and scan them, which is a different
question again from whether the matrix is right: between a correct matrix and
a scannable page sit the coordinate flip, the module size, the quiet zone and
the grid alignment. They also assert that neither document contains the
other's secret, since two files that both carry everything would defeat the
entire reason there are two.

Looking at the rendered output caught what no test I had written would have:
the card's code ran straight through the dotted line somebody is meant to cut
along. It is now under the QR at full card width, and `cardCodeFits` is an
exported invariant the test asserts rather than a number I promise to keep in
step.

Signup offers both documents in one gesture and does not open the way onward
until the person confirms they have them. Walking past that screen without
the recovery code is the one mistake in this product that cannot be undone
later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev cda6877a05082359aa0ffb25424abe78b4f52d8c parent 1e27375
12 files changed, +1317 −5
  • internal/contra/static/crypto/kit.js +203 −0
  • internal/contra/static/crypto/pdf.js +180 −0
  • internal/contra/static/crypto/qr.js +297 −0
  • internal/contra/static/crypto/qrgf.js +59 −0
  • internal/contra/static/crypto/qrtables.js +44 −0
  • internal/contra/static/pages/auth.js +47 −4
  • internal/contratest/browser_test.go +76 −0
  • internal/contratest/jsmodules_test.go +6 −1
  • test/kit.test.mjs +106 −0
  • test/pdforacle.py +71 −0
  • test/qr.test.mjs +110 −0
  • test/qroracle.py +118 −0
diff --git a/internal/contra/static/crypto/kit.js b/internal/contra/static/crypto/kit.js
new file mode 100644
index 0000000..7feb369
--- /dev/null
+++ b/internal/contra/static/crypto/kit.js
@@ -0,0 +1,203 @@
+// The Emergency Kit: two documents, generated here, never sent anywhere.
+//
+// Two files rather than one page with a fold, because the two values have
+// different blast radii and the risk being managed is *storage*. The Account
+// Key alone is nearly harmless — one input to the passphrase branch, useless
+// without the passphrase. The recovery code alone is full takeover. People
+// save PDFs, and one file puts both in the same folder forever; two files can
+// go to two places.
+//
+// The wording is DESIGN.md's, close to verbatim, and it does not lean on the
+// adjectives. "Store securely" and "store somewhere safe" are near-synonyms
+// in ordinary English and nobody derives two standards of care from them, so
+// the concrete places and behaviours do the work.
+//
+// ASCII only: the base-14 fonts have no glyph for a curly quote here, and a
+// document that renders "don't" as "donOt" is not one to hand somebody at the
+// worst moment of their year. pdf.js refuses anything else.
+
+import { encode } from "./qr.js";
+import { A4, CARD, document as pdfDocument, Page, textWidth } from "./pdf.js";
+
+// EC-Q, 25% recovery. Higher than the usual default on purpose: this is
+// print, kept for years, read off paper that may be creased, faded or
+// photocopied. The extra four modules across cost nothing on a page.
+const EC = "Q";
+
+const MARGIN = 56;
+const COLUMN = A4.width - MARGIN * 2;
+
+// handle identifies which account a sheet belongs to, without being useful to
+// anyone who finds it.
+//
+// DESIGN.md says the "blind-index handle", but the blind index is an HMAC
+// under a pepper the server holds and the browser deliberately never sees —
+// so the browser cannot compute one. The account id is already a random UUID
+// that identifies nothing by itself, so its first eight characters do the
+// same job. Noted on discussion 19 rather than quietly substituted.
+export function handle(accountIdHex) {
+ return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2");
+}
+
+function today(now) {
+ const d = now ?? new Date();
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-` +
+ String(d.getUTCDate()).padStart(2, "0");
+}
+
+// codeBlock draws a printed code as monospace text with its QR beside it.
+function codeBlock(page, x, y, code, moduleSize, size = 11) {
+ const qr = encode(code, { ec: EC });
+ page.qr(qr.modules, x, y - qr.modules.length * moduleSize, moduleSize);
+ const qrWidth = qr.modules.length * moduleSize;
+ page.text(x + qrWidth + 18, y - 14, code, { font: "mono", size });
+ return { bottom: y - qr.modules.length * moduleSize, qrWidth, version: qr.version };
+}
+
+// CARD_PAD is the margin inside the cut line. The code is set to fit within
+// it on one line: splitting a recovery-style code across two lines invites
+// somebody to lose the join, and shrinking it below 9pt invites a misread
+// between 8 and B on a photocopy.
+const CARD_PAD = 12;
+export const CARD_CODE_SIZE = 9;
+
+// cardCodeFits is the invariant the layout depends on, exported so the test
+// asserts it rather than trusting the numbers above to stay in step.
+export function cardCodeFits(code) {
+ return textWidth(code, { font: "mono", size: CARD_CODE_SIZE })
+ <= CARD.width - CARD_PAD * 2;
+}
+
+// accountKeyCard is document 1: wallet-sized, and meant to stay reachable.
+//
+// It is drawn as a cut-out on an A4 page rather than as a card-sized page,
+// because home printers handle A4 and do not handle 85.6 x 54 mm.
+export function accountKeyCard(accountIdHex, printedAccountKey, now) {
+ const page = new Page();
+ let y = A4.height - MARGIN;
+
+ page.text(MARGIN, y, "CONTRA Account Key card", { font: "bold", size: 18 });
+ y -= 26;
+ page.text(MARGIN, y, "Store somewhere safe.", { font: "bold", size: 12 });
+ y -= 20;
+
+ y = page.wrap(MARGIN, y, COLUMN,
+ "Keep it where you can find it and nobody else goes - a drawer, a wallet, " +
+ "a folder with your documents. It is useless to anyone who does not also " +
+ "know your passphrase.", { size: 11, leading: 15 });
+ y -= 6;
+ y = page.wrap(MARGIN, y, COLUMN,
+ "A device you have already used does not need this. A brand-new one does: " +
+ "it asks for this key before it will open your vault.", { size: 11, leading: 15 });
+ y -= 10;
+
+ // The card itself, on a cut line.
+ //
+ // The code runs UNDER the QR at full card width rather than beside it. Set
+ // beside a 29-module symbol there is nowhere near enough room for 38
+ // monospaced characters, and the first version of this document proved it
+ // by printing them straight through the dotted line somebody is meant to
+ // cut along.
+ if (!cardCodeFits(printedAccountKey)) {
+ throw new Error("kit: the Account Key does not fit inside the card's cut line");
+ }
+ const cardY = y - CARD.height;
+ const inner = MARGIN + CARD_PAD;
+ // The content is about 115pt tall in a 153pt card; this centres it rather
+ // than hanging it from the top edge with the slack all at the bottom.
+ const cardTop = cardY + CARD.height - 14;
+ page.dashedRect(MARGIN, cardY, CARD.width, CARD.height);
+
+ page.text(inner, cardTop - 17, "CONTRA Account Key", { font: "bold", size: 9 });
+
+ const qr = encode(printedAccountKey, { ec: EC });
+ // 2.2pt a module is about 0.78mm on paper. Bigger than it needs to be on a
+ // clean print, and that is the point: this gets scanned off a card that has
+ // been in a drawer, by whatever phone is to hand.
+ const moduleSize = 2.2;
+ const qrSide = qr.modules.length * moduleSize;
+ page.qr(qr.modules, inner, cardTop - 26 - qrSide, moduleSize);
+
+ page.text(inner + qrSide + 12, cardTop - 34, `Account ${handle(accountIdHex)}`,
+ { size: 8, gray: 0.35 });
+
+ // The code and the one-line reassurance run the full width of the card,
+ // under the symbol. Set in the column beside it they wrap, and a sentence
+ // that wraps is one the text layer no longer contains in one piece — which
+ // is how this test caught it.
+ page.text(inner, cardTop - 34 - qrSide, printedAccountKey,
+ { font: "mono", size: CARD_CODE_SIZE });
+ page.text(inner, cardTop - 48 - qrSide, "Opens nothing without your passphrase.",
+ { size: 8, gray: 0.35 });
+
+ y = cardY - 26;
+ page.text(MARGIN, y, "Cut along the dotted line.", { size: 9, gray: 0.45 });
+ y -= 22;
+ page.wrap(MARGIN, y, COLUMN,
+ "Do not photograph this. It is the second factor protecting the copy of " +
+ "your vault we hold, and passphrases get phished - a picture of it in a " +
+ "cloud photo library quietly undoes the protection it exists to provide. " +
+ "The square is for scanning at setup, not for archiving to a camera roll.",
+ { size: 10, leading: 13, gray: 0.25 });
+
+ page.text(MARGIN, 48, `Printed ${today(now)} - CONTRA`, { size: 8, gray: 0.5 });
+ return pdfDocument([page], { title: "CONTRA Account Key card" });
+}
+
+// recoverySheet is document 2: archival, and the higher bar of the two.
+export function recoverySheet(accountIdHex, printedRecoveryCode, now) {
+ const page = new Page();
+ let y = A4.height - MARGIN;
+
+ page.text(MARGIN, y, "CONTRA Recovery sheet", { font: "bold", size: 18 });
+ y -= 26;
+ page.text(MARGIN, y, "Store securely.", { font: "bold", size: 12 });
+ y -= 20;
+
+ y = page.wrap(MARGIN, y, COLUMN,
+ "Keep it where you'd keep a passport or a will: a safe, a deposit box, a " +
+ "trusted person's house. Never photograph it. You should not need to " +
+ "touch this again.", { size: 11, leading: 15 });
+ y -= 16;
+
+ page.rect(MARGIN, y - 4, COLUMN, 1, { gray: 0.75 });
+ y -= 26;
+
+ const block = codeBlock(page, MARGIN, y, printedRecoveryCode, 3.2);
+ page.text(MARGIN + block.qrWidth + 18, y - 34,
+ `Account ${handle(accountIdHex)}`, { size: 9, gray: 0.35 });
+ page.text(MARGIN + block.qrWidth + 18, y - 48, `Printed ${today(now)}`,
+ { size: 9, gray: 0.35 });
+ y = block.bottom - 26;
+
+ page.rect(MARGIN, y + 8, COLUMN, 1, { gray: 0.75 });
+ y -= 12;
+
+ page.text(MARGIN, y, "What this sheet is", { font: "bold", size: 12 });
+ y -= 18;
+ y = page.wrap(MARGIN, y, COLUMN,
+ "This sheet alone can open your vault. It is the one way back in if you " +
+ "forget your passphrase.", { size: 11, leading: 15 });
+ y -= 6;
+ y = page.wrap(MARGIN, y, COLUMN,
+ "Nobody can reissue it. We do not have it - only a sealed copy of your " +
+ "vault key that it opens - so if you lose this sheet and forget your " +
+ "passphrase, your data is gone. There is no reset link, no support " +
+ "override, and no way for us to let you back in. That is the product, " +
+ "not a gap in it: a server that could give your vault back is a server " +
+ "that could give it to someone else.", { size: 11, leading: 15 });
+ y -= 6;
+ y = page.wrap(MARGIN, y, COLUMN,
+ "If you print a new one, this one stops working. Reprinting the recovery " +
+ "code generates a new code - the old sheet becomes waste paper, and you " +
+ "should destroy it.", { size: 11, leading: 15 });
+ y -= 6;
+ page.wrap(MARGIN, y, COLUMN,
+ "Anyone holding this sheet can reach your vault, so treat it as the thing " +
+ "itself rather than as a note about it. Using it online tells every device " +
+ "you have enrolled, and the account address, that it was used.",
+ { size: 11, leading: 15 });
+
+ page.text(MARGIN, 48, `Printed ${today(now)} - CONTRA`, { size: 8, gray: 0.5 });
+ return pdfDocument([page], { title: "CONTRA Recovery sheet" });
+}
diff --git a/internal/contra/static/crypto/pdf.js b/internal/contra/static/crypto/pdf.js
new file mode 100644
index 0000000..0bce970
--- /dev/null
+++ b/internal/contra/static/crypto/pdf.js
@@ -0,0 +1,180 @@
+// A very small PDF writer: enough for the Emergency Kit and nothing more.
+//
+// Written rather than vendored for the same reason the QR encoder is. These
+// two documents are generated in the browser and must never touch the server,
+// so the code that makes them ships to every user — and a PDF library is tens
+// of thousands of lines nobody in this repo would ever read.
+//
+// What it supports: the base-14 fonts (so nothing is embedded), text, filled
+// rectangles, and lines. That is the whole vocabulary the kit needs. Anything
+// else belongs in a different file with a different argument behind it.
+
+const enc = new TextEncoder();
+
+// PDF strings are parenthesised, so those and the escape character have to be
+// escaped. Everything else stays literal, which is why the kit's copy is kept
+// to ASCII — a base-14 font has no glyph for a curly quote in this encoding,
+// and a document that renders "don’t" as "donÕt" is not one to hand somebody
+// at the worst moment of their year.
+function pdfString(s) {
+ return "(" + s.replace(/[\\()]/g, (c) => "\\" + c) + ")";
+}
+
+export function assertASCII(s, where) {
+ for (const ch of s) {
+ if (ch.charCodeAt(0) > 126 || ch.charCodeAt(0) < 32) {
+ throw new Error(`pdf: ${where} contains a character the base-14 fonts cannot draw: ` +
+ JSON.stringify(ch));
+ }
+ }
+ return s;
+}
+
+// textWidth is a deliberately crude estimate of a drawn string's width. The
+// base-14 metrics are per-glyph and we do not ship the tables, so Helvetica is
+// taken at 0.5 em and Courier is exact at 0.6 em, being monospaced.
+//
+// It errs wide for Helvetica, which is the safe direction: a layout that
+// trusts it leaves a margin rather than running off the edge. It exists so
+// that "does this fit inside the cut line?" is a test rather than something
+// somebody spots in a screenshot — which is how the Account Key card first
+// rendered with its code hanging over the edge.
+export function textWidth(s, { font = "regular", size = 11 } = {}) {
+ return s.length * size * (font === "mono" ? 0.6 : 0.5);
+}
+
+export const FONTS = {
+ regular: "Helvetica",
+ bold: "Helvetica-Bold",
+ mono: "Courier-Bold",
+};
+
+// A4 in points, and the ISO card the Account Key is cut down to.
+export const A4 = { width: 595.28, height: 841.89 };
+export const CARD = { width: 242.65, height: 153.07 }; // 85.6 x 54 mm
+
+// Page collects drawing operations. Coordinates are PDF's: origin at the
+// bottom left, y upward — kept rather than flipped, so that anyone reading
+// this against a PDF reference is reading the same numbers.
+export class Page {
+ constructor(width = A4.width, height = A4.height) {
+ this.width = width;
+ this.height = height;
+ this.ops = [];
+ }
+
+ text(x, y, s, { font = "regular", size = 11, gray = 0 } = {}) {
+ assertASCII(s, "page text");
+ this.ops.push(
+ `BT ${gray} g /${font} ${size} Tf 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm ` +
+ `${pdfString(s)} Tj ET`);
+ return this;
+ }
+
+ // wrap lays out a paragraph at a fixed width, measuring in the crude way
+ // the base-14 metrics allow: Helvetica averages about 0.5 em. It errs
+ // narrow, so a line may end early but never runs off the page.
+ wrap(x, y, maxWidth, s, { font = "regular", size = 10, leading = 13, gray = 0 } = {}) {
+ const perChar = size * (font === "mono" ? 0.6 : 0.5);
+ const perLine = Math.max(8, Math.floor(maxWidth / perChar));
+ const words = assertASCII(s, "paragraph").split(/\s+/);
+ let line = "";
+ let at = y;
+ for (const word of words) {
+ if (line && (line + " " + word).length > perLine) {
+ this.text(x, at, line, { font, size, gray });
+ at -= leading;
+ line = word;
+ } else {
+ line = line ? line + " " + word : word;
+ }
+ }
+ if (line) {
+ this.text(x, at, line, { font, size, gray });
+ at -= leading;
+ }
+ return at;
+ }
+
+ rect(x, y, w, h, { gray = 0 } = {}) {
+ this.ops.push(`${gray} g ${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re f`);
+ return this;
+ }
+
+ // A dashed rectangle, for the cut line around the card.
+ dashedRect(x, y, w, h, { gray = 0.6, dash = 4 } = {}) {
+ this.ops.push(
+ `${gray} G [${dash} ${dash}] 0 d 0.7 w ` +
+ `${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re S [] 0 d`);
+ return this;
+ }
+
+ // qr draws a module matrix as filled squares. One rectangle per dark
+ // module: verbose in the file and exactly right on paper, which is the
+ // trade to make for something that has to scan in ten years.
+ qr(modules, x, y, moduleSize) {
+ const n = modules.length;
+ for (let r = 0; r < n; r++) {
+ for (let c = 0; c < n; c++) {
+ if (!modules[r][c]) continue;
+ // Row 0 is the top of the symbol; PDF's y grows upward.
+ this.rect(x + c * moduleSize, y + (n - 1 - r) * moduleSize,
+ moduleSize, moduleSize);
+ }
+ }
+ return this;
+ }
+
+ get content() { return this.ops.join("\n"); }
+}
+
+// document assembles pages into a PDF file, as bytes.
+export function document(pages, { title = "" } = {}) {
+ const objects = [];
+ const add = (body) => { objects.push(body); return objects.length; }; // 1-based
+
+ const catalogId = objects.length + 1;
+ add(null); // reserved: catalog, written once the pages tree id is known
+ const pagesId = add(null);
+
+ const fontIds = {};
+ for (const [name, base] of Object.entries(FONTS)) {
+ fontIds[name] = add(`<< /Type /Font /Subtype /Type1 /BaseFont /${base} ` +
+ `/Encoding /WinAnsiEncoding >>`);
+ }
+
+ const resources = "<< /Font << " +
+ Object.entries(fontIds).map(([n, id]) => `/${n} ${id} 0 R`).join(" ") + " >> >>";
+
+ const pageIds = [];
+ for (const page of pages) {
+ const stream = page.content;
+ const contentId = add(
+ `<< /Length ${enc.encode(stream).length} >>\nstream\n${stream}\nendstream`);
+ pageIds.push(add(
+ `<< /Type /Page /Parent ${pagesId} 0 R ` +
+ `/MediaBox [0 0 ${page.width.toFixed(2)} ${page.height.toFixed(2)}] ` +
+ `/Resources ${resources} /Contents ${contentId} 0 R >>`));
+ }
+
+ objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
+ objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((i) => `${i} 0 R`).join(" ")}] ` +
+ `/Count ${pageIds.length} >>`;
+
+ const infoId = add(`<< /Title ${pdfString(assertASCII(title, "title"))} ` +
+ `/Producer (CONTRA) >>`);
+
+ let out = "%PDF-1.4\n";
+ const offsets = [];
+ for (let i = 0; i < objects.length; i++) {
+ offsets.push(enc.encode(out).length);
+ out += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
+ }
+ const xref = enc.encode(out).length;
+ out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+ for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
+ out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R ` +
+ `/Info ${infoId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+
+ return enc.encode(out);
+}
diff --git a/internal/contra/static/crypto/qr.js b/internal/contra/static/crypto/qr.js
new file mode 100644
index 0000000..2708a24
--- /dev/null
+++ b/internal/contra/static/crypto/qr.js
@@ -0,0 +1,297 @@
+// A QR encoder, alphanumeric mode, versions 1–6.
+//
+// This is here for one artifact: the Emergency Kit. The Account Key card and
+// the Recovery sheet each carry their code as a QR so a phone can read it off
+// paper years later, and that is the moment nothing may be wrong.
+//
+// Deliberately narrow. Alphanumeric mode only, because a CONTRA code is
+// `A1-`/`C1-` plus Crockford Base32 — uppercase letters, digits and hyphens,
+// all inside QR's 45-character alphanumeric set. Versions 1–6 only, because a
+// 38-character code needs version 3 and stopping below 7 means no version
+// information block. Anything outside that throws rather than guessing.
+//
+// It is checked two ways in CI, not one: every symbol it produces is decoded
+// back by ZXing, and every matrix is compared bit-for-bit against Python's
+// `qrcode` at the same version, EC level and mask. Neither of those is ours.
+
+import { remainder } from "./qrgf.js";
+import {
+ ALIGNMENT, ALNUM, BLOCKS, dataCapacity, EC_LEVELS, MASKS, size,
+} from "./qrtables.js";
+
+export { EC_LEVELS };
+
+class Bits {
+ constructor() { this.bits = []; }
+ push(value, length) {
+ for (let i = length - 1; i >= 0; i--) this.bits.push((value >> i) & 1);
+ }
+ get length() { return this.bits.length; }
+}
+
+// encodeAlphanumeric packs characters in pairs — 11 bits for two, 6 for a
+// leftover one — which is the whole reason to use this mode over bytes.
+function encodeAlphanumeric(text, version, ec) {
+ const bits = new Bits();
+ bits.push(0b0010, 4); // mode: alphanumeric
+ bits.push(text.length, 9); // character count, 9 bits for versions 1–9
+ for (let i = 0; i < text.length; i += 2) {
+ const a = ALNUM.indexOf(text[i]);
+ if (a < 0) throw new Error(`qr: ${JSON.stringify(text[i])} is not alphanumeric`);
+ if (i + 1 < text.length) {
+ const b = ALNUM.indexOf(text[i + 1]);
+ if (b < 0) throw new Error(`qr: ${JSON.stringify(text[i + 1])} is not alphanumeric`);
+ bits.push(a * 45 + b, 11);
+ } else {
+ bits.push(a, 6);
+ }
+ }
+
+ const capacity = dataCapacity(version, ec) * 8;
+ if (bits.length > capacity) {
+ throw new Error(`qr: ${text.length} characters do not fit in version ${version}${ec}`);
+ }
+ // Terminator, then to a byte boundary, then the two alternating pad bytes.
+ bits.push(0, Math.min(4, capacity - bits.length));
+ while (bits.length % 8 !== 0) bits.bits.push(0);
+
+ const bytes = [];
+ for (let i = 0; i < bits.length; i += 8) {
+ let b = 0;
+ for (let j = 0; j < 8; j++) b = (b << 1) | bits.bits[i + j];
+ bytes.push(b);
+ }
+ for (let i = 0; bytes.length < capacity / 8; i++) bytes.push(i % 2 ? 0x11 : 0xec);
+ return bytes;
+}
+
+// interleave splits the data into the version's blocks, computes each block's
+// error correction, and reads the whole lot back column-wise — which is what
+// spreads a coffee stain across every block instead of destroying one.
+function interleave(bytes, version, ec) {
+ const [, ecLen, groups] = BLOCKS[version][ec];
+ const data = [];
+ let at = 0;
+ for (const [count, words] of groups) {
+ for (let i = 0; i < count; i++) {
+ data.push(bytes.slice(at, at + words));
+ at += words;
+ }
+ }
+ const ecc = data.map((block) => remainder(block, ecLen));
+
+ const out = [];
+ const longest = Math.max(...data.map((b) => b.length));
+ for (let i = 0; i < longest; i++) {
+ for (const block of data) if (i < block.length) out.push(block[i]);
+ }
+ for (let i = 0; i < ecLen; i++) for (const block of ecc) out.push(block[i]);
+ return out;
+}
+
+// The fixed patterns: finders, separators, timing, alignment, and the dark
+// module. `reserved` marks every cell data must not be written into.
+function skeleton(version) {
+ const n = size(version);
+ const m = Array.from({ length: n }, () => new Array(n).fill(0));
+ const reserved = Array.from({ length: n }, () => new Array(n).fill(false));
+
+ const put = (r, c, v) => {
+ if (r >= 0 && r < n && c >= 0 && c < n) { m[r][c] = v; reserved[r][c] = true; }
+ };
+
+ for (const [top, left] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
+ for (let r = -1; r <= 7; r++) {
+ for (let c = -1; c <= 7; c++) {
+ const onRing = r === 0 || r === 6 || c === 0 || c === 6;
+ const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
+ const inside = r >= 0 && r <= 6 && c >= 0 && c <= 6;
+ put(top + r, left + c, inside && (onRing || inCore) ? 1 : 0);
+ }
+ }
+ }
+
+ for (let i = 8; i < n - 8; i++) {
+ put(6, i, i % 2 === 0 ? 1 : 0);
+ put(i, 6, i % 2 === 0 ? 1 : 0);
+ }
+
+ const centres = ALIGNMENT[version];
+ for (const r of centres) {
+ for (const c of centres) {
+ // Never over a finder.
+ if ((r <= 8 && c <= 8) || (r <= 8 && c >= n - 9) || (r >= n - 9 && c <= 8)) continue;
+ for (let dr = -2; dr <= 2; dr++) {
+ for (let dc = -2; dc <= 2; dc++) {
+ const edge = Math.abs(dr) === 2 || Math.abs(dc) === 2;
+ put(r + dr, c + dc, edge || (dr === 0 && dc === 0) ? 1 : 0);
+ }
+ }
+ }
+ }
+
+ put(n - 8, 8, 1); // the dark module
+
+ // Format information areas, reserved now and written after masking.
+ for (let i = 0; i < 9; i++) {
+ if (!reserved[8][i]) put(8, i, 0);
+ if (!reserved[i][8]) put(i, 8, 0);
+ }
+ for (let i = 0; i < 8; i++) {
+ if (!reserved[8][n - 1 - i]) put(8, n - 1 - i, 0);
+ if (!reserved[n - 1 - i][8]) put(n - 1 - i, 8, 0);
+ }
+ return { m, reserved, n };
+}
+
+
+// Data goes in two-module columns, right to left, snaking up and down, and
+// skipping the vertical timing column.
+function place(grid, codewords) {
+ const { m, reserved, n } = grid;
+ let bit = 0;
+ const total = codewords.length * 8;
+ for (let right = n - 1; right > 0; right -= 2) {
+ if (right === 6) right = 5; // the timing column is not a data column
+ for (let step = 0; step < n; step++) {
+ const up = ((n - 1 - right) & 2) === 0;
+ const row = up ? n - 1 - step : step;
+ for (const col of [right, right - 1]) {
+ if (reserved[row][col] || bit >= total) continue;
+ m[row][col] = (codewords[bit >> 3] >> (7 - (bit & 7))) & 1;
+ bit++;
+ }
+ }
+ }
+}
+
+function penalty(m, n) {
+ let score = 0;
+ const run = (get) => {
+ for (let a = 0; a < n; a++) {
+ let length = 1;
+ for (let b = 1; b < n; b++) {
+ if (get(a, b) === get(a, b - 1)) {
+ length++;
+ } else {
+ if (length >= 5) score += length - 2;
+ length = 1;
+ }
+ }
+ if (length >= 5) score += length - 2;
+ }
+ };
+ run((r, c) => m[r][c]);
+ run((c, r) => m[r][c]);
+
+ for (let r = 0; r < n - 1; r++) {
+ for (let c = 0; c < n - 1; c++) {
+ const v = m[r][c];
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
+ }
+ }
+
+ const A = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
+ const B = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
+ const matches = (get, a, b) =>
+ A.every((v, i) => get(a, b + i) === v) || B.every((v, i) => get(a, b + i) === v);
+ for (let a = 0; a < n; a++) {
+ for (let b = 0; b + 11 <= n; b++) {
+ if (matches((x, y) => m[x][y], a, b)) score += 40;
+ if (matches((x, y) => m[y][x], a, b)) score += 40;
+ }
+ }
+
+ let dark = 0;
+ for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += m[r][c];
+ score += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
+ return score;
+}
+
+// BCH(15,5) over the 5 format bits, then the specification's fixed XOR.
+function formatBits(ec, mask) {
+ let value = (EC_LEVELS[ec] << 3) | mask;
+ let rem = value;
+ for (let i = 0; i < 10; i++) {
+ rem <<= 1;
+ if (rem & 0x400) rem ^= 0x537;
+ }
+ return ((value << 10) | rem) ^ 0x5412;
+}
+
+// The 15 format bits go in twice, and the two copies run in opposite
+// directions — which is the detail worth stating rather than deriving from a
+// loop, because getting it backwards produces a symbol that is correct
+// everywhere else and scans nowhere.
+//
+// Bit 14 is the most significant. Copy A starts at the top-left corner and
+// reads MSB-first along row 8, then continues MSB-first *up* column 8. Copy B
+// starts at the bottom of column 8 and runs MSB-first upward, then continues
+// along row 8 towards the right edge. (8,6) and (6,8) are timing and are
+// skipped; (n-8,8) is the dark module and is not a format cell.
+function writeFormat(m, n, ec, mask) {
+ const bits = formatBits(ec, mask);
+ const at = (i) => (bits >> i) & 1;
+
+ // Copy A: bits 14…7 along the top row, then bits 6…0 up the left column.
+ const rowA = [0, 1, 2, 3, 4, 5, 7, 8];
+ rowA.forEach((c, i) => { m[8][c] = at(14 - i); });
+ const colA = [7, 5, 4, 3, 2, 1, 0];
+ colA.forEach((r, i) => { m[r][8] = at(6 - i); });
+
+ // Copy B: bits 14…8 up from the bottom, then bits 7…0 to the right edge.
+ for (let i = 0; i < 7; i++) m[n - 1 - i][8] = at(14 - i);
+ for (let i = 0; i < 8; i++) m[8][n - 8 + i] = at(7 - i);
+
+ m[n - 8][8] = 1; // the dark module, restated
+}
+
+// smallestVersion is the smallest symbol the text fits at this EC level.
+export function smallestVersion(text, ec) {
+ const bits = 4 + 9 + Math.floor(text.length / 2) * 11 + (text.length % 2) * 6;
+ for (let v = 1; v <= 6; v++) {
+ if (bits <= dataCapacity(v, ec) * 8) return v;
+ }
+ throw new Error(`qr: ${text.length} characters need a version above 6`);
+}
+
+// encode returns the module matrix: 1 is dark, 0 is light, no quiet zone.
+//
+// `mask` is normally chosen by penalty score, exactly as the specification
+// says; passing one forces it, which is what lets CI compare our matrix
+// bit-for-bit against another encoder's.
+export function encode(text, { ec = "Q", version = null, mask = null } = {}) {
+ if (!EC_LEVELS.hasOwnProperty(ec)) throw new Error(`qr: unknown EC level ${ec}`);
+ const v = version ?? smallestVersion(text, ec);
+ if (!BLOCKS[v]) throw new Error(`qr: version ${v} is outside 1–6`);
+
+ const codewords = interleave(encodeAlphanumeric(text, v, ec), v, ec);
+
+ const build = (candidate) => {
+ const grid = skeleton(v);
+ place(grid, codewords);
+ for (let r = 0; r < grid.n; r++) {
+ for (let c = 0; c < grid.n; c++) {
+ if (!grid.reserved[r][c] && MASKS[candidate](r, c)) grid.m[r][c] ^= 1;
+ }
+ }
+ writeFormat(grid.m, grid.n, ec, candidate);
+ return grid;
+ };
+
+ if (mask !== null) return { modules: build(mask).m, version: v, ec, mask };
+
+ let best = null;
+ let bestScore = Infinity;
+ let bestMask = 0;
+ for (let candidate = 0; candidate < 8; candidate++) {
+ const grid = build(candidate);
+ const score = penalty(grid.m, grid.n);
+ if (score < bestScore) {
+ bestScore = score;
+ best = grid;
+ bestMask = candidate;
+ }
+ }
+ return { modules: best.m, version: v, ec, mask: bestMask };
+}
diff --git a/internal/contra/static/crypto/qrgf.js b/internal/contra/static/crypto/qrgf.js
new file mode 100644
index 0000000..a723c9a
--- /dev/null
+++ b/internal/contra/static/crypto/qrgf.js
@@ -0,0 +1,59 @@
+// GF(256) and Reed–Solomon, for the QR encoder.
+//
+// Split from qr.js only because of the 300-line cap, but the split is honest:
+// this half is pure finite-field arithmetic with no knowledge of QR at all,
+// and it is the half that is easiest to check against a textbook.
+//
+// The field is GF(2^8) with the QR primitive polynomial 0x11D, generator 2.
+
+const EXP = new Uint8Array(512);
+const LOG = new Uint8Array(256);
+
+(function buildTables() {
+ let x = 1;
+ for (let i = 0; i < 255; i++) {
+ EXP[i] = x;
+ LOG[x] = i;
+ x <<= 1;
+ if (x & 0x100) x ^= 0x11d;
+ }
+ // The second half repeats the first, so a product's exponent never needs a
+ // modulo in the hot path.
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
+})();
+
+export function mul(a, b) {
+ if (a === 0 || b === 0) return 0;
+ return EXP[LOG[a] + LOG[b]];
+}
+
+// generatorPoly returns the degree-n generator, (x - 2^0)(x - 2^1)…, whose
+// roots are what the decoder checks against.
+export function generatorPoly(n) {
+ let g = [1];
+ for (let i = 0; i < n; i++) {
+ const next = new Array(g.length + 1).fill(0);
+ for (let j = 0; j < g.length; j++) {
+ next[j] ^= g[j];
+ next[j + 1] ^= mul(g[j], EXP[i]);
+ }
+ g = next;
+ }
+ return g;
+}
+
+// remainder is the error-correction codewords for one block: polynomial
+// division of the data by the generator, keeping the remainder.
+export function remainder(data, ecLen) {
+ const gen = generatorPoly(ecLen);
+ const out = new Uint8Array(ecLen);
+ for (const byte of data) {
+ const factor = byte ^ out[0];
+ out.copyWithin(0, 1);
+ out[ecLen - 1] = 0;
+ if (factor !== 0) {
+ for (let i = 0; i < ecLen; i++) out[i] ^= mul(gen[i + 1], factor);
+ }
+ }
+ return out;
+}
diff --git a/internal/contra/static/crypto/qrtables.js b/internal/contra/static/crypto/qrtables.js
new file mode 100644
index 0000000..bbaa191
--- /dev/null
+++ b/internal/contra/static/crypto/qrtables.js
@@ -0,0 +1,44 @@
+// The QR specification's tables, versions 1–6.
+//
+// Separated from qr.js because they are data, not logic: nothing here is
+// derived or clever, and a mistake in it is a transcription mistake rather
+// than a reasoning one. Both kinds happen, and they are found differently —
+// these numbers are checked by comparing whole symbols against another
+// encoder (test/qr.test.mjs), which is the only way to catch a wrong row.
+
+export const ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
+
+// EC level codes as they appear in the format information.
+export const EC_LEVELS = { L: 0b01, M: 0b00, Q: 0b11, H: 0b10 };
+
+// [total codewords, ec codewords per block, [blocks, data codewords]...]
+export const BLOCKS = {
+ 1: { L: [26, 7, [[1, 19]]], M: [26, 10, [[1, 16]]], Q: [26, 13, [[1, 13]]], H: [26, 17, [[1, 9]]] },
+ 2: { L: [44, 10, [[1, 34]]], M: [44, 16, [[1, 28]]], Q: [44, 22, [[1, 22]]], H: [44, 28, [[1, 16]]] },
+ 3: { L: [70, 15, [[1, 55]]], M: [70, 26, [[1, 44]]], Q: [70, 18, [[2, 17]]], H: [70, 22, [[2, 13]]] },
+ 4: { L: [100, 20, [[1, 80]]], M: [100, 18, [[2, 32]]], Q: [100, 26, [[2, 24]]], H: [100, 16, [[4, 9]]] },
+ 5: { L: [134, 26, [[1, 108]]], M: [134, 24, [[2, 43]]], Q: [134, 18, [[2, 15], [2, 16]]], H: [134, 22, [[2, 11], [2, 12]]] },
+ 6: { L: [172, 18, [[2, 68]]], M: [172, 16, [[4, 27]]], Q: [172, 24, [[4, 19]]], H: [172, 28, [[4, 15]]] },
+};
+
+// Alignment pattern centres. Version 1 has none.
+export const ALIGNMENT = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34] };
+
+// The eight data masks. A symbol is drawn eight times and the one with the
+// lowest penalty wins, which is what stops a payload producing a field of
+// stripes a scanner reads as a finder pattern.
+export const MASKS = [
+ (r, c) => (r + c) % 2 === 0,
+ (r) => r % 2 === 0,
+ (r, c) => c % 3 === 0,
+ (r, c) => (r + c) % 3 === 0,
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
+ (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
+ (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
+];
+
+export const size = (version) => version * 4 + 17;
+
+export const dataCapacity = (version, ec) =>
+ BLOCKS[version][ec][2].reduce((n, [blocks, words]) => n + blocks * words, 0);
diff --git a/internal/contra/static/pages/auth.js b/internal/contra/static/pages/auth.js
index 1349cd2..663928c 100644
--- a/internal/contra/static/pages/auth.js
+++ b/internal/contra/static/pages/auth.js
@@ -7,7 +7,9 @@
import { signIn, signup } from "../crypto/account.js";
import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js";
+import { toHex } from "../crypto/bytes.js";
import { net } from "../crypto/net.js";
+import { accountKeyCard, recoverySheet } from "../crypto/kit.js";
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
@@ -30,11 +32,25 @@ function busy(form, on) {
for (const c of form.querySelectorAll("input, button")) c.disabled = on;
}
+// download hands the browser a file. Both documents are built here and the
+// bytes never leave: a kit that touched the server would put the recovery
+// code somewhere it must never be, for the length of one request.
+function download(bytes, filename) {
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
+ const a = el("a", { href: url, download: filename });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ // Revoked on the next tick rather than immediately: some browsers have not
+ // started reading the blob by the time click() returns.
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
+}
+
// The kit is shown exactly once, here, and never again — the recovery code is
// not stored by anyone, including us, so this is the only moment it exists
// outside the person's hands. The copy says so plainly rather than leaving it
// to be discovered.
-function showKit(form, accountKeyPrinted, recoveryPrinted) {
+function showKit(form, accountKeyPrinted, recoveryPrinted, accountIdHex) {
const box = form.querySelector("[data-status]");
box.replaceChildren(
el("h2", {}, "Your Emergency Kit"),
@@ -58,10 +74,36 @@ function showKit(form, accountKeyPrinted, recoveryPrinted) {
"Print both before you continue. If you lose the kit and forget your " +
"passphrase, the data is gone — there is no reset link and no way for " +
"us to let you back in."),
- el("button", { type: "button", "data-print": "" }, "Print both"),
- el("p", {}, el("a", { href: "/vault" }, "Continue to your vault")),
+ el("p", {},
+ el("button", { type: "button", "data-save-kit": "" }, "Save both documents"),
+ " ",
+ el("button", { type: "button", "data-print": "" }, "Print this page")),
+ el("p", {},
+ el("label", {},
+ el("input", { type: "checkbox", "data-confirmed": "" }),
+ " I have saved both documents somewhere I can find them.")),
+ el("p", { "data-onward": "", hidden: "hidden" },
+ el("a", { href: "/vault" }, "Continue to your vault")),
);
+
+ // Two files, downloaded by one gesture. One file would put both secrets in
+ // the same folder forever, which is the whole reason there are two — and
+ // the risk being managed is storage, not printing.
+ box.querySelector("[data-save-kit]").addEventListener("click", () => {
+ download(accountKeyCard(accountIdHex, accountKeyPrinted),
+ `contra-account-key-${accountIdHex.slice(0, 8)}.pdf`);
+ download(recoverySheet(accountIdHex, recoveryPrinted),
+ `contra-recovery-sheet-${accountIdHex.slice(0, 8)}.pdf`);
+ });
box.querySelector("[data-print]").addEventListener("click", () => window.print());
+
+ // No way onward until the person says they have the documents. The
+ // recovery code exists nowhere else, so walking past this screen without it
+ // is the one mistake that cannot be undone later.
+ const onward = box.querySelector("[data-onward]");
+ box.querySelector("[data-confirmed]").addEventListener("change", (e) => {
+ onward.hidden = !e.target.checked;
+ });
}
function wireSignup(form) {
@@ -81,7 +123,8 @@ function wireSignup(form) {
(m) => status(form, m + "…"));
showKit(form,
await formatCode(TAG_ACCOUNT_KEY, kit.accountKey),
- await formatCode(TAG_RECOVERY, kit.recoveryCode));
+ await formatCode(TAG_RECOVERY, kit.recoveryCode),
+ toHex(kit.accountId));
} catch (err) {
status(form, err.message, "error");
busy(form, false);
diff --git a/internal/contratest/browser_test.go b/internal/contratest/browser_test.go
index f3cb71f..1938b2d 100644
--- a/internal/contratest/browser_test.go
+++ b/internal/contratest/browser_test.go
@@ -30,11 +30,18 @@ import (
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
contra "github.com/carlosframework/contra/internal/contra"
)
+// awaitPromise lets chromedp.Evaluate wait on an async import rather than
+// returning the pending promise.
+func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+ return p.WithAwaitPromise(true)
+}
+
func newRig(t *testing.T) *harness.Rig {
t.Helper()
return harness.New(t, func(origin string) http.Handler {
@@ -199,3 +206,72 @@ func TestBrowserVaultRoundTrip(t *testing.T) {
)
rig.Screen("[data-vault]", "empty vault")
}
+
+// The Emergency Kit, in a real engine.
+//
+// The node suite renders the two PDFs and scans them, which proves the
+// documents are right. This proves the screen is: that signup actually builds
+// them in the browser, that both are offered by one gesture, and — the part
+// that only a real engine can show — that there is no way past this screen
+// until the person says they have them.
+//
+// Walking past this screen without the recovery code is the one mistake in
+// the whole product that cannot be undone later, so the gate is worth a test
+// rather than a comment.
+func TestBrowserEmergencyKit(t *testing.T) {
+ rig := newRig(t)
+
+ const email = "kit-walk@example.invalid"
+ const passphrase = "a generated test passphrase 8f3a1c"
+
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/signup"),
+ chromedp.SendKeys(`input[name=email]`, email, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name=passphrase]`, passphrase, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name=passphrase_again]`, passphrase, chromedp.ByQuery),
+ chromedp.Click(`button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-kit="recovery"]`, chromedp.ByQuery),
+ )
+
+ // Both printed values are on screen, and they are different values.
+ var accountKey, recovery string
+ rig.Run(
+ chromedp.Text(`[data-kit="account-key"]`, &accountKey, chromedp.ByQuery),
+ chromedp.Text(`[data-kit="recovery"]`, &recovery, chromedp.ByQuery),
+ )
+ if accountKey[:3] != "A1-" || recovery[:3] != "C1-" || accountKey == recovery {
+ t.Fatalf("kit values look wrong: %q and %q", accountKey, recovery)
+ }
+
+ // The way onward is closed until the person confirms.
+ var onwardHidden bool
+ rig.Run(chromedp.Evaluate(
+ `document.querySelector('[data-onward]').hidden`, &onwardHidden))
+ if !onwardHidden {
+ t.Error("the vault was reachable before the kit was confirmed")
+ }
+
+ // Building both documents must not throw. Clicking the real button would
+ // start two downloads Chromium has nowhere to put, so this calls the same
+ // module the button calls and checks the bytes are a PDF.
+ var sizes []int
+ rig.Run(chromedp.Evaluate(`(async () => {
+ const { accountKeyCard, recoverySheet } = await import("/static/crypto/kit.js");
+ const id = "b22c6a5eae1be9d6a773f474fd0532b0";
+ const a = accountKeyCard(id, document.querySelector('[data-kit="account-key"]').textContent);
+ const b = recoverySheet(id, document.querySelector('[data-kit="recovery"]').textContent);
+ const head = (x) => new TextDecoder().decode(x.slice(0, 5));
+ if (head(a) !== "%PDF-" || head(b) !== "%PDF-") throw new Error("not a PDF");
+ return [a.length, b.length];
+ })()`, &sizes, awaitPromise))
+ if len(sizes) != 2 || sizes[0] < 2000 || sizes[1] < 2000 {
+ t.Errorf("the kit documents look wrong: %v bytes", sizes)
+ }
+
+ // Confirming opens it.
+ rig.Run(
+ chromedp.Click(`[data-confirmed]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[data-onward] a`, chromedp.ByQuery),
+ )
+ rig.Screen("[data-status]", "emergency kit confirmed")
+}
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go
index 6598b02..aac00e0 100644
--- a/internal/contratest/jsmodules_test.go
+++ b/internal/contratest/jsmodules_test.go
@@ -33,14 +33,19 @@ var jsHighWater = map[string]int{
"code.js": 165,
"items.js": 97,
"kdf.js": 117,
+ "kit.js": 203,
"net.js": 35,
"p256.js": 114,
+ "pdf.js": 180,
+ "qr.js": 297,
+ "qrgf.js": 59,
+ "qrtables.js": 44,
"sealed.js": 90,
"vaultkeys.js": 148,
// Page controllers. Thin on purpose: every decision that matters lives in
// a crypto module, and these only move text between a form and one.
- "pages/auth.js": 116,
+ "pages/auth.js": 159,
"pages/vault.js": 170,
}
diff --git a/test/kit.test.mjs b/test/kit.test.mjs
new file mode 100644
index 0000000..24d8cf4
--- /dev/null
+++ b/test/kit.test.mjs
@@ -0,0 +1,106 @@
+// The Emergency Kit, rendered and read back.
+//
+// The matrix being right (test/qr.test.mjs) does not mean the document is
+// right. Between a correct matrix and a scannable page sit the coordinate
+// flip, the module size, the quiet zone, and whether the squares land on the
+// same grid — and every one of those can be wrong in a document whose matrix
+// is perfect. So this renders the actual PDF and scans it.
+//
+// Needs python3 with pymupdf, zxing-cpp, pypdf, pillow and numpy. A HARD
+// gate: a skip here is indistinguishable from a pass, on the one artifact
+// that has to work years from now with nothing else left.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+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 { accountKeyCard, recoverySheet, handle } = await import(mod("kit.js"));
+const { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } = await import(mod("code.js"));
+const { fromHex } = await import(mod("bytes.js"));
+
+// Generated fixtures, from the golden vector file. Nothing here is real.
+const ACCOUNT_ID = "b22c6a5eae1be9d6a773f474fd0532b0";
+const ACCOUNT_KEY = fromHex("f60fb281e8c72f6725add4cbd40e7b08");
+const RECOVERY = fromHex("70da5ffc983510bba4672030946b9eed");
+const WHEN = new Date(Date.UTC(2026, 8, 2));
+
+function ask(cases) {
+ const out = execFileSync("python3", [join(here, "pdforacle.py")], {
+ input: JSON.stringify(cases), encoding: "utf8", maxBuffer: 1 << 28,
+ });
+ return JSON.parse(out);
+}
+
+const b64 = (bytes) => Buffer.from(bytes).toString("base64");
+
+test("both documents render, scan, and read back", async () => {
+ const cardCode = await formatCode(TAG_ACCOUNT_KEY, ACCOUNT_KEY);
+ const sheetCode = await formatCode(TAG_RECOVERY, RECOVERY);
+
+ const verdicts = ask([
+ {
+ name: "Account Key card",
+ pdf_base64: b64(accountKeyCard(ACCOUNT_ID, cardCode, WHEN)),
+ expect_code: cardCode,
+ expect_text: [
+ "Store somewhere safe.",
+ "Opens nothing without your passphrase.",
+ `Account ${handle(ACCOUNT_ID)}`,
+ "Do not photograph this.",
+ ],
+ },
+ {
+ name: "Recovery sheet",
+ pdf_base64: b64(recoverySheet(ACCOUNT_ID, sheetCode, WHEN)),
+ expect_code: sheetCode,
+ expect_text: [
+ "Store securely.",
+ "This sheet alone can open your vault.",
+ "Nobody can reissue it.",
+ `Account ${handle(ACCOUNT_ID)}`,
+ "Printed 2026-09-02",
+ ],
+ },
+ ]);
+
+ for (const v of verdicts) {
+ assert.ok(v.qr_scans,
+ `${v.name}: the QR in the rendered page did not scan (found ${JSON.stringify(v.scanned)})`);
+ assert.ok(v.one_qr, `${v.name}: expected exactly one QR, found ${v.scanned.length}`);
+ assert.ok(v.code_in_text, `${v.name}: the code is not in the extractable text`);
+ assert.deepEqual(v.missing_text, [], `${v.name}: missing copy`);
+ assert.equal(v.pages, 1, `${v.name}: should be a single page`);
+ }
+});
+
+// The two documents exist to be two files. If either ever carried the other's
+// secret, the whole reason for splitting them would be gone — so it is
+// checked rather than assumed.
+test("neither document carries the other's secret", async () => {
+ const cardCode = await formatCode(TAG_ACCOUNT_KEY, ACCOUNT_KEY);
+ const sheetCode = await formatCode(TAG_RECOVERY, RECOVERY);
+
+ const card = Buffer.from(accountKeyCard(ACCOUNT_ID, cardCode, WHEN)).toString("latin1");
+ const sheet = Buffer.from(recoverySheet(ACCOUNT_ID, sheetCode, WHEN)).toString("latin1");
+
+ assert.ok(card.includes(cardCode), "the card does not contain its own code");
+ assert.ok(sheet.includes(sheetCode), "the sheet does not contain its own code");
+ assert.ok(!card.includes(sheetCode), "the Account Key card carries the recovery code");
+ assert.ok(!sheet.includes(cardCode), "the Recovery sheet carries the Account Key");
+});
+
+// The base-14 fonts have no glyph for a curly quote in this encoding, so the
+// writer refuses one rather than drawing a wrong character into the document
+// somebody is reading at the worst moment of their year.
+test("the writer refuses characters it cannot draw", async () => {
+ const { Page } = await import(mod("pdf.js"));
+ assert.throws(() => new Page().text(10, 10, "don’t"), /cannot draw/);
+ assert.throws(() => new Page().wrap(10, 10, 200, "an em — dash"), /cannot draw/);
+ // And the copy that ships is already clean, which is the point of the check.
+ assert.doesNotThrow(() => new Page().text(10, 10, "Keep it where you'd keep a passport"));
+});
diff --git a/test/pdforacle.py b/test/pdforacle.py
new file mode 100644
index 0000000..4bec2e2
--- /dev/null
+++ b/test/pdforacle.py
@@ -0,0 +1,71 @@
+"""The Emergency Kit oracle: read the PDF back the way a person would.
+
+Two questions, and the second is the one that matters:
+
+ 1. Does the text extract? A document whose code cannot be selected or
+ searched is one somebody has to retype by hand from a photograph.
+ 2. **Does the QR inside the rendered page actually scan?** Not the matrix
+ the encoder produced — the page as a reader draws it. Between those two
+ sit the coordinate flip, the module size, the quiet zone and whether the
+ squares land on the same grid. Every one of those can be wrong in a
+ document whose matrix is perfect.
+
+PyMuPDF renders, ZXing decodes, pypdf extracts. None of them is ours.
+
+Reads {pdf_base64, expect_code, expect_text[]} on stdin, writes a verdict.
+
+Needs: pip install --user pymupdf zxing-cpp pypdf pillow numpy
+"""
+
+import base64
+import io
+import json
+import sys
+
+import numpy as np
+import pymupdf
+import zxingcpp
+from PIL import Image
+from pypdf import PdfReader
+
+
+def check(case):
+ raw = base64.b64decode(case["pdf_base64"])
+ out = {"name": case.get("name", "")}
+
+ # 1. Structure and text, through a second reader.
+ reader = PdfReader(io.BytesIO(raw))
+ out["pages"] = len(reader.pages)
+ text = "\n".join(page.extract_text() or "" for page in reader.pages)
+ out["missing_text"] = [s for s in case.get("expect_text", []) if s not in text]
+ out["code_in_text"] = case["expect_code"] in text.replace("\n", "")
+
+ # 2. Render at print resolution and scan it, exactly as a phone would.
+ doc = pymupdf.open(stream=raw, filetype="pdf")
+ found = []
+ for page in doc:
+ pix = page.get_pixmap(dpi=case.get("dpi", 200))
+ img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples).convert("L")
+ for r in zxingcpp.read_barcodes(np.array(img)):
+ found.append(r.text)
+ out["scanned"] = found
+ out["qr_scans"] = case["expect_code"] in found
+
+ # Exactly one QR per document: a second one would mean the two secrets had
+ # ended up on one sheet, which is the whole thing these documents are two
+ # of in order to prevent.
+ out["one_qr"] = len(found) == 1
+
+ out["ok"] = bool(
+ out["qr_scans"] and out["one_qr"] and out["code_in_text"] and not out["missing_text"]
+ )
+ return out
+
+
+def main():
+ cases = json.load(sys.stdin)
+ print(json.dumps([check(c) for c in cases]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/qr.test.mjs b/test/qr.test.mjs
new file mode 100644
index 0000000..1c777b6
--- /dev/null
+++ b/test/qr.test.mjs
@@ -0,0 +1,110 @@
+// The QR encoder, against two implementations that are not ours.
+//
+// This is the artifact that has to be right years after it was printed, on a
+// phone we have never seen, when the person holding it has already lost
+// everything else. So it is checked twice on every run:
+//
+// 1. ZXing decodes each symbol back to its payload — "will it scan?"
+// 2. Python's `qrcode` produces the same matrix, bit-for-bit, at the same
+// version, EC level and mask — "is it the RIGHT symbol?"
+//
+// The second question is the one decoding cannot answer. Error correction
+// exists to make a damaged symbol readable, which means a symbol with a real
+// mistake in it can still scan — and would, right up until the day somebody's
+// print was slightly faded too. Reed–Solomon would hide our bug behind its
+// own redundancy.
+//
+// Needs python3 with zxing-cpp, qrcode, pillow and numpy:
+//
+// pip install --user zxing-cpp qrcode pillow numpy
+//
+// A HARD gate. If the oracle is missing this fails rather than skipping: a
+// skip on this test is indistinguishable from a pass, on the one artifact
+// where being wrong is unrecoverable.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const { encode, smallestVersion } = await import(
+ join(here, "..", "internal", "contra", "static", "crypto", "qr.js"));
+
+// Real printed forms, the extremes of the alphabet, and the boundaries where
+// a version rolls over — which is where a capacity table is wrong if it is
+// wrong anywhere.
+const PAYLOADS = [
+ "C1-E3D5Z-Z4R6M-8BQ93-740R9-8TWYX-MHGE0",
+ "A1-YR7V5-0F8RW-QPE9D-DTK5X-83KV1-0GJC8",
+ "A1-00000-00000-00000-00000-00000-00000",
+ "C1-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ",
+ "0",
+ "HELLO WORLD",
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:",
+];
+
+function ask(cases) {
+ const out = execFileSync("python3", [join(here, "qroracle.py")], {
+ input: JSON.stringify(cases),
+ encoding: "utf8",
+ maxBuffer: 1 << 28,
+ });
+ return JSON.parse(out);
+}
+
+test("every symbol scans, and is the symbol another encoder would have made", () => {
+ const cases = [];
+ for (const text of PAYLOADS) {
+ for (const ec of ["L", "M", "Q", "H"]) {
+ for (let mask = 0; mask < 8; mask++) {
+ const r = encode(text, { ec, mask });
+ cases.push({ text, ec, mask, version: r.version, modules: r.modules });
+ }
+ }
+ }
+
+ const verdicts = ask(cases);
+ assert.equal(verdicts.length, cases.length);
+
+ const bad = verdicts.filter((v) => !v.ok);
+ const describe = (v) =>
+ `${v.ec} mask=${v.mask} v${v.version} ${JSON.stringify(v.text).slice(0, 30)}: ` +
+ `scans=${v.scans} identical=${v.identical}` +
+ (v.differing_modules ? ` differing=${v.differing_modules}` : "") +
+ (v.reference_error ? ` refErr=${v.reference_error}` : "");
+ assert.deepEqual(bad.map(describe), [], `${bad.length} of ${cases.length} symbols wrong`);
+});
+
+// The mask the encoder picks on its own must also be right — the sweep above
+// forces every mask, which would not catch a broken penalty score.
+test("the automatically chosen mask still scans", () => {
+ const cases = PAYLOADS.map((text) => {
+ const r = encode(text, { ec: "Q" });
+ return { text, ec: "Q", mask: r.mask, version: r.version, modules: r.modules };
+ });
+ for (const v of ask(cases)) {
+ assert.ok(v.scans, `${JSON.stringify(v.text)} did not scan at its chosen mask`);
+ assert.ok(v.identical, `${JSON.stringify(v.text)} differs from the reference`);
+ }
+});
+
+// A CONTRA code at the EC level the kit prints at. Pinned so a change to
+// either is a deliberate one.
+test("a printed code is a version 3 symbol at EC-Q", () => {
+ const code = "C1-E3D5Z-Z4R6M-8BQ93-740R9-8TWYX-MHGE0";
+ assert.equal(code.length, 38);
+ assert.equal(smallestVersion(code, "Q"), 3);
+ const r = encode(code, { ec: "Q" });
+ assert.equal(r.version, 3);
+ assert.equal(r.modules.length, 29, "a version 3 symbol is 4*3+17 = 29 modules across");
+});
+
+// The encoder refuses rather than guessing, on both edges.
+test("it refuses what it cannot encode", () => {
+ assert.throws(() => encode("lower case"), /not alphanumeric/);
+ assert.throws(() => encode("héllo"), /not alphanumeric/);
+ assert.throws(() => encode("Z".repeat(200), { ec: "H" }), /version above 6/);
+ assert.throws(() => encode("SHORT", { ec: "X" }), /unknown EC level/);
+});
diff --git a/test/qroracle.py b/test/qroracle.py
new file mode 100644
index 0000000..b29e2ee
--- /dev/null
+++ b/test/qroracle.py
@@ -0,0 +1,118 @@
+"""The QR oracle: two independent implementations, neither of them ours.
+
+The Emergency Kit's QR codes are the one artifact in this product that has to
+be right years after it was printed, on a phone we have never seen, when the
+person holding it has already lost everything else. "It looks like a QR code"
+is not verification of that.
+
+So every symbol the JS encoder produces is checked twice here:
+
+ 1. Decoded back to its payload by **ZXing** (zxing-cpp), which is the
+ library most phone scanners are descended from. This answers "will it
+ scan?".
+ 2. Compared **bit-for-bit** against Python's `qrcode` at the same version,
+ EC level and mask. This answers "is it the right symbol?", which
+ decoding alone does not — error correction can hide a real mistake in a
+ symbol that still reads.
+
+Neither tool is ours, and they were checked against each other before either
+was trusted (test/qr.test.mjs's fixtures come from this file).
+
+Reads a JSON list of {text, ec, version, mask, modules} on stdin; writes a
+JSON verdict per case on stdout.
+
+Needs: pip install --user zxing-cpp qrcode pillow numpy
+"""
+
+import json
+import sys
+
+import numpy as np
+import qrcode
+import zxingcpp
+from PIL import Image
+
+EC = {
+ "L": qrcode.constants.ERROR_CORRECT_L,
+ "M": qrcode.constants.ERROR_CORRECT_M,
+ "Q": qrcode.constants.ERROR_CORRECT_Q,
+ "H": qrcode.constants.ERROR_CORRECT_H,
+}
+
+
+def to_image(modules, scale=8, quiet=4):
+ """Render a module matrix the way a printer would: dark on white, with the
+ quiet zone the specification requires. Without the quiet zone a correct
+ symbol simply does not decode, which is a real failure mode and not one we
+ want to discover on paper."""
+ n = len(modules)
+ side = (n + quiet * 2) * scale
+ img = Image.new("L", (side, side), 255)
+ px = img.load()
+ for r in range(n):
+ for c in range(n):
+ if modules[r][c]:
+ for y in range(scale):
+ for x in range(scale):
+ px[(c + quiet) * scale + x, (r + quiet) * scale + y] = 0
+ return img
+
+
+def reference_matrix(text, ec, mask):
+ """The reference symbol, forced to alphanumeric mode.
+
+ python-qrcode picks the most efficient mode for the data, so an all-digit
+ payload comes out in numeric mode — a different, equally valid symbol that
+ decodes to the same string. Our encoder is alphanumeric-only by design, so
+ comparing against an auto-selected reference would report a difference
+ that is not a defect. Forcing the mode compares like with like.
+
+ Real CONTRA codes begin "A1-"/"C1-" and are never all digits, so this only
+ affects synthetic payloads in the sweep — but a test that is wrong on the
+ easy cases teaches you to ignore it on the hard ones."""
+ q = qrcode.QRCode(error_correction=EC[ec], mask_pattern=mask, border=0)
+ q.add_data(qrcode.util.QRData(text.encode("ascii"),
+ mode=qrcode.util.MODE_ALPHA_NUM))
+ q.make(fit=True)
+ return [[1 if cell else 0 for cell in row] for row in q.get_matrix()], q.version
+
+
+def check(case):
+ text, ec, mask = case["text"], case["ec"], case["mask"]
+ modules = case["modules"]
+ out = {"text": text, "ec": ec, "mask": mask, "version": case["version"]}
+
+ # 1. Does it scan?
+ decoded = zxingcpp.read_barcode(np.array(to_image(modules)))
+ out["decoded"] = None if decoded is None else decoded.text
+ out["scans"] = decoded is not None and decoded.text == text
+
+ # 2. Is it the same symbol another encoder would have made?
+ try:
+ ref, ref_version = reference_matrix(text, ec, mask)
+ out["reference_version"] = ref_version
+ out["same_size"] = len(ref) == len(modules)
+ out["identical"] = out["same_size"] and all(
+ ref[r][c] == modules[r][c] for r in range(len(ref)) for c in range(len(ref))
+ )
+ if out["same_size"] and not out["identical"]:
+ out["differing_modules"] = sum(
+ ref[r][c] != modules[r][c]
+ for r in range(len(ref))
+ for c in range(len(ref))
+ )
+ except Exception as exc: # noqa: BLE001 - the verdict carries the reason
+ out["identical"] = False
+ out["reference_error"] = str(exc)
+
+ out["ok"] = bool(out["scans"] and out.get("identical"))
+ return out
+
+
+def main():
+ cases = json.load(sys.stdin)
+ print(json.dumps([check(c) for c in cases]))
+
+
+if __name__ == "__main__":
+ main()