carlos / contra Public

web/5 — Fresh-device ladder: handoff, scan, type Feature Request open

Depends on web/4.

The Account Key's whole cost is that a new device needs it. This is the UX that makes that cost bearable. Spec: DESIGN.md, "The UX ladder for a fresh device".

Three rungs, cheapest first, and no screen may ever dead-end — every one offers all rungs currently available:

  1. Handoff from an enrolled device. The new browser mints an ephemeral keypair and posts the public half; the server shows a short code; an enrolled device confirms it and seals the Account Key to that ephemeral key. The server relays a blob it cannot open — hashed code, single use, ten minutes. This is Slopbox's link pattern: read it and reuse its shape rather than inventing one.
  2. Scan the QR from the Account Key card.
  3. Type it — Crockford Base32 with the self-healing checksum, which brute-forces single-character substitutions before giving up.

Once a device holds the Account Key it caches it locally, and from then on the passphrase alone unlocks that device. That is the everyday case and it must feel like nothing happened.

Also in scope, because they are the mitigation for the one fatal loss mode: the reprint flow (Account Key reprints exactly from the device cache; the recovery code can only be regenerated, invalidating the old sheet, and the copy must say so before the user commits), and the nag to enrol a second device.

Branch: web/device-ladder.

🤖 claiming — claude~paul@keymail.dev — web/device-ladder — 2026-09-02T16:25Z Stacked on web/signup-steps (11d8c3b), which is what the canary runs.

🤖 Built and live on the canary: https://contra.bdj.oncarlos.com running 712d80f. Branch web/device-ladder, stacked on web/signup-steps.

The vault used to dead-end: a browser could sign in on a new machine, reach /vault, and be told "the ladder that brings it here — handoff, scan, type — is not built". That is the one screen DESIGN.md forbids. It now links to /device.

What landed

Rung 1, the handoff. Follows slopbox's link shape as this item says to, with four deliberate departures — I read internal/home/ (the current one) rather than internal/instance/.

  • Every route needs a session. slopbox leaves start and claim open because its waiting device has no account yet. Ours does: a fresh browser signs in with email and passphrase alone, since the login key comes from S_pw and never from the Account Key, and only then discovers it cannot unlock. Both ends being authenticated is what makes a short code safe — its job is to say which device, not to prove who is asking. A stranger cannot see that a code exists.
  • No device label. slopbox stores one and shows it to the approver. That is a plaintext column in a file that replicates whole, and worse, it is attacker-chosen text displayed at the exact moment somebody is deciding whether to hand over a key. "CONTRA official device" is a free phishing line. The code carried between screens is the confirmation; the label adds nothing to it.
  • The client hashes the code. The server is given SHA-256 and never the code, so "it exists only on the two screens" is literally true rather than nearly true.
  • There is a sweeper. slopbox has none — I checked, there are exactly two DELETE FROM device_links and both are the single-use claim — so its rows accumulate forever. Inert, but the table becomes a log of when each account enrolled a device.

Also fixed the modulo bias its newLinkCode has: int(c)%31 over a 31-char alphabet favours A–G. Ours is 32 Crockford symbols, so five bits masked off a random byte, no rejection loop, exactly 40 bits.

Rungs 2 and 3 share one path, because the QR encodes exactly the printed line — kit.js draws both from the same value, so a scan and a typed line reach identical code. A mistyped character self-heals: repairCode produces ~1000 candidates and a twenty-bit checksum cannot choose between them, so the KCV does. That needs the passphrase, so the field only appears once the checksum has actually failed — the common case is not taxed. vaultkeys.opener splits the stretch from the test, so it is one Argon2id and then an HMAC per candidate rather than minutes.

The camera rung is conditional. BarcodeDetector exists in Chromium and Android; Safari and Firefox have nothing, and a QR decoder inside the module graph that holds the keys is a great deal of code for a shortcut. It is hidden where unavailable. DESIGN.md says "all available rungs" and the rung below always works, so this costs a convenience, not a way in.

The reprint flow and the nag, on /kit. The Account Key card reprints exactly. The recovery sheet cannot be reprinted at all — only its wrap is stored — so reissuing regenerates, and ReplaceRecoveryWrap is the one write in the app that deletes. One transaction, so there is no window in which neither code opens the vault; the passphrase branch is not in it; RK is opened in the browser before anything is sent, so a wrong passphrase costs one stretch and changes nothing. The warning sits above the control, the control needs an explicit tick, and a test asserts that order.

New crypto, pinned in three places

The handoff needed ECIES, which we did not have:

envelope = ephemeralpublic(65) || nonce(12) || ciphertext || tag(16) key = HKDF(ikm = X(d*P), salt = ephpub || recipient_pub, info = "contra/v1/ecies", L = 32)

Generated by vectors/gen/gen.c against OpenSSL as decision 6 requires, and the generator now re-computes RFC 5903's P-256 ECDH before it may emit a byte. That anchor earned its place on the first run: it rejected a transcribed responder point that every self-consistent test in the tree would have accepted. The vector file gains a section and changes no existing value.

Both public points go in the HKDF salt, and the AAD names the recipient's own public key — so the server cannot relay one envelope to a second device. Go and JS both assert that, and that a different scalar cannot open it.

Tests

Eight handoff walks over real HTTP (the shape that caught the wrap-id bug), including single-use claim and single-use approval — a second approval must not clobber an envelope the waiting device can still open. Account scoping, the live-link cap, the sweep, the expiry-before-sweep ordering. Four reissue tests, including that another account's recovery wrap survives: this is a delete, so a dropped owner filter would not leak data, it would destroy somebody's only spare key.

Three new browser walks: the fresh device typing its way in and then unlocking with the passphrase alone, the mistyped code repairing, and the reissue guard.

The greps gained the handoff and reissue write paths and a canary for the code itself — which is exactly what a later "show pending devices with their codes" would put on disk.

Two things found on the way

SweepSignups was written and never called. Nothing invoked it, so signup sessions accumulated forever. It is now in the same loop as the link sweep, and the canary's first boot after deploy logged swept signups=3 — it was a real leak, not a hypothetical one.

render() dereferenced a nil map for an unregistered page name, so a missing template surfaced as a segfault inside a handler rather than as the wiring mistake it was. It now returns a clean 500.

Not done, and worth its own item

The QR scan rung on Safari and Firefox. Doing it properly means a QR decoder — binarisation, finder patterns, perspective, Reed–Solomon decode — which is a lot of code in the module graph that holds the keys. qrgf.js already has the GF(256) field from the encoder, so it is not from nothing, but it is not a footnote either. Filing separately.

Also worth recording: the handoff does not defend against a server that serves malicious JS. It could swap the waiting device's public key for its own. DESIGN.md already scopes browser clients to at-rest and honest-but-curious "because the server serves the JS", and no arrangement of this module changes that — the fix is the separate integrity-protected origin DESIGN.md has adopted and we have not built. I considered a second displayed fingerprint of the ephemeral key and did not add it: against a server that also serves the JS it buys nothing, and it puts a second code on a screen that already asks a person to carry one.