🤖 Done. Branch web/auth at f63ef12, pushed and deployed.
Canary: https://contra.bdj.oncarlos.com — /api/version reports f63ef12. make ci green: vet, gofmt, go test ./..., the node crypto suite, rastrillo migration check.
Verified on the canary, with the real browser modules
Not a mock and not a Go stand-in — crypto/account.js imported unmodified, fetch pointed at the deployment:
signup 1.98s (Argon2id 64MiB t=3, in the browser) sign-in 1.91s wrong passphrase refused kit round-trips through its printed form true unknown address still gets a salt and parameters true
/vault 303s a stranger to /signin; every module in the graph serves.
The browser half of the contract
This item needed Argon2id in the browser, which WebCrypto cannot do. The choices were vendoring somebody's .wasm or writing it. A binary blob is not reviewable and "no build step" is a stated constraint that vendoring quietly breaks — so it is written out, ~290 lines readable against RFC 9106.
It agrees with OpenSSL 3.5 on nine parameter sets and with the published PHC reference vector. 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 on purpose — BigInt, because Argon2 calls it about sixty times per derivation while the compression function runs two hundred thousand. Matches RFC 7693.
P-256 needs no elliptic-curve maths of ours: a raw scalar imports through WebCrypto as a 67-byte PKCS#8 with the public half omitted. Rejection sampling is a byte-wise compare against n, so no bignum anywhere.
The whole account chain passes the golden vectors in node, so Go and the browser are byte-identical, and make test-js is a hard gate that fails rather than skips when node is missing.
The 300-line module cap is enforced, with a per-file ratchet that only ever goes down.
What the item asked for, point by point
- RK in the browser, never server-side. Three peer wraps built there.
W_prf has its slot but no ceremony — passkeys are their own item. - Signed challenge. Server stores only the public key. Challenges are single-use, spent whether or not the signature was good, and the branch is bound into the signed bytes rather than checked beside them, so an
rc challenge cannot be spent on pw. - The KDF floor, client-side. Checked on parameters the server sent, before a single expensive byte. Tests on both sides; the server keeps its own copy so a client bug cannot persist weak parameters a later honest client would trust.
- Blind index. No address column, no provider user-id. The salt endpoint answers every address with the same shape; an unknown one gets a deterministic decoy, because a prober who asks twice and gets two answers has already learned there is no account.
The greps still pass, and now drive the real HTTP ceremony with a canary address and a canary passphrase — so the value that would be most catastrophic to find on disk is one of the things searched for.
A real bug, found the only way it could have been
The server minted its own wrap id while the client had already bound its id into the AAD. Every wrap it stored was bound to identifiers the client had never seen: a row that decrypts for nobody, ever, and only fails at the moment somebody tries to unlock.
The Go tests did not catch it because they built the request struct by hand and agreed with themselves. It surfaced the first time the real browser module talked to the real server — which is the argument for doing that before calling an item done.
Both ids now come from the client, like every other id in this protocol. That also removed the zero-filled account-id placeholder signup was using, so the AAD binding is real from the first byte. TestStoredWrapsStillOpen asserts the property rather than the plumbing.
Three things I decided — flag any of them
Client-minted account and wrap ids. Forced by the above: they are bound into ciphertext before the server sees it, so there is no other side of the wire they could come from. Collisions are refused by the primary key. Filed on #19 with the signed-message format.
The pepper is refused, not invented, when missing. An ephemeral pepper would change every account's index on the next restart and strand every account there is. It is set on the canary as a platform secret, generated so that the value never entered this session or the ledger.
Signup does not verify the address. AGENTS.md decision 3 says email + magic link is the direction of travel; that is not built, so today anyone can claim any address's index. The harm is bounded — the index decrypts nothing, so the cost is denial of registration — but it is a real gap and it is not mine to close in this item.
Not in scope, and worth naming
The W_prf slot exists but nothing enrols a passkey; the Emergency Kit renders as text and prints, but the two PDFs are #10; there is no rate limiting beyond what the platform does.
⚠️ The stack is now five unmerged branches: shared/golden-vectors → web/bootstrap → web/sealed-schema → web/auth. Everything above rests on the encodings in #19, and amadan ci status still cannot go green because no runner is attached (see #7).