carlos / contra Public

web/6 — Vault and items: the client-shape console Feature Request open

Depends on web/3.

The vault surface is client-shape by force — sealing has to happen where the keys are, so a server-rendered vault cannot exist. Marketing and settings pages stay server-shape beside it.

Deliverable: create, read, update and delete items in a personal vault, all sealing and unsealing in the browser; items AES-256-GCM under K_vault with AAD binding (vault_id, item_id, version).

The JS discipline is not negotiable and is enforced by test:

  • Small self-contained ES modules, one file one concern, own state, no globals, no build step, go:embed.
  • 300 lines per module, enforced by a test, caps ratchet down only.
  • Never innerHTML untrusted content — build DOM nodes.
  • Storage and network passed in, never reached for, so unit tests hand in fakes.

The family's evidence for why: Eleven's hand-rolled shell reached 8,722 lines with one render function called from 31 places. VanJS (vendored, ~140 lines) is the sanctioned reactive shell if one is wanted; anything heavier is a redesign, not a choice.

Done when: items round-trip on the canary, the greps still pass, the line-cap test is wired into go test, and a WebKit console check is clean against the deployed canary — a JS syntax error takes the whole client down and only a real engine sees it.

Branch: web/vault.

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

web/3 (#9) landed on web/auth and is deployed; this branch takes it as its base.

Taking this rather than #10 (the Emergency Kit PDFs), which is the other item #9 unblocks — see my note on that thread. Short version: #10 needs a QR encoder, there is no QR decoder on this machine to verify one against, and a recovery QR that is subtly wrong is a lost vault. Not something to write unattended.

Will reply here when I stop, finished or not.

🤖 Done. Branch web/vault at 3ab64a3, pushed and deployed.

Canary: https://contra.bdj.oncarlos.com — /api/version reports 3ab64a3. make ci green.

Verified on the canary, with the real modules

signup + provision 2020ms unlock 1737ms (1 vault) create / list / update / delete all OK, version bumps to 2 the server's own response carries nothing readable ciphertexts quantised into 256-byte buckets: true

Signing in is not unlocking

The difference is the whole design, and the console makes it visible: a session proves who you are, and then the vault asks for the passphrase again, because the key comes from a wrap the server cannot open. The chain runs in the browser — Argon2id → Spw → HKDF salted with the Account Key → KEKpw → RK → the account keyset → K_vault.

The KCV is checked before any decryption is attempted. AES-GCM is not key-committing, so without it "wrong Account Key" and "corrupted vault" are the same error — and the unlock paths need to tell those apart constantly.

The Account Key is cached per device at signup, which is DESIGN.md's everyday case. A device that does not have it is told so plainly and told the ladder that would bring it there is not built — that is web/5 (#11), and this branch does not pretend otherwise.

Items

One blob per item, every field present whether or not it was filled in, so the ciphertext does not publish which fields somebody used. Quantised into 256-byte buckets, and that is now enforced in three places rather than promised in one: the client pads, the API refuses, the store refuses. A node test proves a one-character item and a 180-character item produce the same ciphertext length.

The server side is a filter and nothing else

Every handler hands out or takes in ciphertext, and none of them is a refactor away from being able to read one — there is no key on that side.

The owner filter lives in the SQL, not in a check beside it, so dropping a line cannot turn a read into an IDOR. A vault that is not yours answers 404, never 403: a 403 confirms the row exists, and that is a fact about somebody else's vault.

It deliberately does not use sessions.UserID. That returns an int64 and CONTRA's subject is a hex account id, so it answers (0, false) — and dropping its ok, exactly the mistake the framework's own notes warn about, would scope every query to a nonexistent account and quietly return nothing.

Three layers of test, each proving what the others cannot

  • node, against a fake network — the whole item lifecycle with no socket, and the assertion that matters: nothing readable reaches the network. That assertion is only possible because storage and network are passed in rather than reached for, which is the discipline this item asks for.
  • Go, two accounts — neither can list, write into, or delete from the other's vault, and the victim's item survives a foreign delete.
  • A real engine — sign up, unlock, add, reload, watch the item survive, edit, delete. The reload is the point: it proves the item really went to the server sealed and came back and opened, rather than sitting in a JS variable.

The line-cap test now covers every module this app wrote, page controllers included, with a per-file ratchet.

The WebKit gap, named

This item asks for a WebKit console check. WebKit binaries are in the Playwright cache on this machine but Playwright itself is not installed, and adding it is a dependency decision rather than something to do unattended. So the rig here is Chromium, via chromedp — which the scaffold already depends on and which the framework's own harness is built around.

That is a real gap and not a small one for this product: DESIGN.md picks 64 MiB precisely because of iOS Safari's WASM heap, and Safari is the engine most likely to differ on the WebCrypto PKCS#8 import the login keypair depends on. Worth installing Playwright and re-running before anyone trusts this on iOS.

Decisions, flag either

The vault keyset is sealed directly under RK, not wrapped to the member's account public key by ECDH. That is the right shape for a vault with one member and the wrong shape for a shared one — sharing is #14, the vault_members rows already exist for it, and it will decide whether the owner's copy moves there too. Recorded in the model so the next person meets a decision rather than an inconsistency.

CreateVault and PutItem take the client's id, for the same reason PutWrap does (#19, item 8): the id is bound into the AAD before the server sees the ciphertext.

Not built here

Vault renaming and multiple vaults; search (which needs a design that does not leak); the drop box, whose keypair is generated and stored but which nothing writes to yet. No rate limiting beyond the platform's.

⚠️ The stack is now five unmerged branches: shared/golden-vectors → web/bootstrap → web/sealed-schema → web/auth → web/vault, and amadan ci status still cannot go green because no runner is attached (#7).