carlos / contra Public

web/4 — The Emergency Kit: two client-generated PDFs Feature Request open

Depends on web/3.

Spec: DESIGN.md, "What the kit is — two documents, not two panels".

Two separate PDFs, both generated in the browser, neither ever touching the server. They are separate because the values have different blast radii — the Account Key alone opens nothing without the passphrase, while the recovery code alone is full takeover — and because the risk being managed is storage: people save PDFs, and one file puts both secrets in the same folder forever.

Document 1 — Account Key card. Wallet-sized. The 128-bit key as A1-XXXXX-…, its QR, the blind-index handle, one line saying it opens nothing without the passphrase. Instruction: store somewhere safe.

Document 2 — Recovery sheet. Archival. The 128-bit code as C1-XXXXX-…, its QR, the handle, the date, plain language that this sheet alone opens the vault and nobody can reissue it. Instruction: store securely, and never photograph it.

Signup generates both, offers one print both action, and does not let the user past until both are confirmed.

On the copy — this matters more than the layout. "Store securely" and "store somewhere safe" are near-synonyms; a reader derives no difference in care from the adjectives alone. Concrete places do the work, and both strings are already drafted in DESIGN.md — use them:

  • Recovery sheet: "Keep it where you'd keep a passport or a will…"
  • Account Key card: "Keep it where you can find it and nobody else goes…"

Do not encourage photographing the Account Key card. Alone it opens nothing, but it is still the second factor protecting the at-rest copy, and passphrases get phished. The QR is for scanning at setup, not for archiving to a camera roll.

Branch: web/emergency-kit.

🤖 Not claiming this — leaving a note so the next agent does not walk into it blind, and so the decision it needs is on the record.

web/3 (#9) is done and deployed, so this item is unblocked. Signup already generates both 128-bit values and returns them; crypto/code.js already renders A1-XXXXX-… and C1-XXXXX-… and parses them back through the self-healing checksum. The kit currently shows on screen as text, with a print both button, and the copy is taken from DESIGN.md's own wording. So the remaining work is the two PDFs and the QR codes.

Why I stopped rather than building it

The QR encoder has nothing to verify it against on this machine. No qrencode, no zbarimg, no qrcode, no cv2, no jsqr. Writing a QR encoder is a few hundred lines of Reed–Solomon and mask selection, and the failure mode is not a crash — it is a code that scans wrong or does not scan on the one device someone tries it on, years later, holding the only copy.

Every other thing I wrote tonight had an independent oracle: Argon2id had OpenSSL and the PHC vectors, BLAKE2b had RFC 7693, P-256 had the golden vectors. This has none, and "it looks like a QR code" is not verification of the artifact that stands between a person and their vault. Writing it unattended would be exactly the wrong turn #18 warns about.

The decision you need to make

  1. Vendor a QR library, or write one? Writing it fits the no-build-step rule and keeps the review surface readable, but only if there is a decoder in CI to check it against — otherwise the rule that guarantees correctness everywhere else in this repo simply does not apply here. Vendoring means a third-party blob in the one artifact that must never be wrong.
  2. Either way, what is the oracle? My suggestion: whichever route is taken, CI decodes every QR it generates and compares the payload to the input, over a spread of the actual code lengths. Without that, this feature is untested by the standards of the rest of the repo.
  3. The PDF writer is the easier half and I would write it: a minimal PDF is a few hundred lines, no build step, and the oracle is that the two files open and their text extracts back — checkable.

There is also a WebKit gap worth knowing about, since the kit is the thing people print from Safari: WebKit binaries are in the Playwright cache on this machine but Playwright itself is not installed, so the browser rig here is Chromium (chromedp, already a dependency). Adding Playwright is a dependency decision, not mine to make overnight — but see #12, which asks for a WebKit console check specifically.

Taking #12 instead, which #9 also unblocks and which needs no oracle I do not have.

🤖 claiming — claude~paul@keymail.dev (remote agent, session ad0bdb88) — branch web/kit — 2026-09-02T01:10:00Z

Reversing my note above. I said I would not write a QR encoder because there was no decoder on this machine to verify one against. Paul has since said to install one, so there now is:

  • zxingcpp — the ZXing C++ decoder, an independent implementation
  • qrcode — a Python encoder, a second independent implementation

Both agree with each other across EC levels L/M/Q/H on real CONTRA code strings, so the oracle the objection was about exists. A real code is a version-2 symbol at EC-M, 33×33 modules.

So the reasoning that stopped me is gone, and the item is takeable on the same terms as everything else tonight: an independent oracle, or it does not land. Base is web/vault (#12).

Will reply here when I stop, finished or not.

🤖 Done. Branch web/kit at cda6877, pushed and deployed.

Canary: https://contra.bdj.oncarlos.com — signup builds both documents in the browser and both scan. make ci green, 29 JS tests, four browser walks.

The objection is gone, so here is the work

I said above I would not write a QR encoder without a decoder to check it against. There is one now, and two in fact — which turned out to matter.

ZXing decodes every symbol back to its payload: will it scan? 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, so a symbol with a real mistake in it can still scan — and would, right up until the day somebody's print was also slightly faded. Reed–Solomon would have hidden our bug behind its own redundancy.

224 symbols — 7 payloads across all four EC levels and all eight masks — all scan and all match the reference.

It found a bug, and placed it exactly

Twelve modules wrong, every one of them in the format information, none in the data. So encoding, Reed–Solomon, interleaving, placement and masking were all correct, and the format value was correct too: my bit ordering was reversed. The two copies of the format bits run in opposite directions.

A decode-only check would have said "does not scan" and left me to find that by bisection. The matrix comparison pointed at twelve coordinates.

Written, not vendored — same argument both times

The QR encoder is alphanumeric-only, versions 1–6, and throws outside that: a CONTRA code is uppercase Crockford plus hyphens, needs version 3, and staying below 7 means no version-information block. The PDF writer supports the base-14 fonts, text, rectangles and lines, which is the kit's whole vocabulary.

Both ship to every user, so both have to be readable. A PDF library is tens of thousands of lines nobody in this repo would ever read, and a .wasm is worse — unreadable by construction, in the one artifact that must never be wrong.

Two files, and a test that they stay two

The Account Key alone is nearly harmless; the recovery code alone is full takeover; the risk being managed is storage. So a test asserts neither document contains the other's secret — two files that both carried everything would defeat the entire reason there are two.

The kit tests render the actual PDFs and scan those, 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.

Looking at the output caught what the tests did not

The card's code ran straight through the dotted line somebody is meant to cut along. Every test passed: the matrix was right, the PDF scanned, the text extracted. It was wrong in the only way none of them looked at.

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. textWidth exists for that check. Worth remembering that the screenshot, not the suite, is what found both this and the stale front page tonight.

Decisions, flag any

EC-Q, not the usual M. 25% recovery, because this is print, kept for years, read off paper that may be creased, faded or photocopied. Costs four modules across.

The card is a cut-out on an A4 page, not an 85.6×54mm page. Home printers handle A4 and do not handle card stock sizes.

The handle is the account id's first eight characters, not the blind index. DESIGN.md says the kit carries "the account's blind-index handle", but the blind index is an HMAC under a pepper the server holds and the browser deliberately never sees — the browser cannot compute one. The account id is already a random UUID that identifies nothing by itself, so it does the same job. Filing on #19 rather than substituting quietly.

ASCII only in the documents. The base-14 fonts have no glyph for a curly quote in this encoding, and pdf.js refuses one rather than drawing the wrong character into a document somebody reads at the worst moment of their year. That is why the kit copy uses - and '.

Still not built

Reprinting. DESIGN.md is explicit that the Account Key can be reprinted exactly while the recovery code can only be regenerated — new code, new W_rc, old wrap deleted, old sheet dead — and that the flow must be prominent rather than buried. None of that exists; today the kit is offered once at signup. That plus the fresh-device ladder (#11) are what stand between this and somebody actually relying on it.