carlos / contra Public

design: the kit prints the Account Key too — settled

Passphrase-only unlock would have made the security of the replicated
bucket equal to the strength of whatever phrase the user typed, which is a
ceiling on the whole product set by its weakest input. A 128-bit Account
Key mixed into the passphrase's KEK branch removes it: after this, a
passive leak of the database and the bucket has no offline dictionary
target left at all, because every wrap is keyed by at least 128 bits of
full entropy.

Rejected: passphrase-only with compensating controls. A control that
depends on the user choosing a good phrase is a hope with a progress bar.

The kit is a client-generated PDF that never reaches the server, laid out
in two separated panels with a fold line — which is security, not
decoration. The Account Key is handled routinely because every new device
wants it; the recovery code should ideally never leave the paper. Adjacent
on one panel, routine handling of the first exposes the second.

The cost is real and stated without softening: a fresh device now needs
the kit or a handoff from an enrolled one, and "passphrase known, kit
lost, every device gone" is unrecoverable where it was not before. The
mitigation is UX rather than crypto — a three-rung ladder that never
dead-ends, a prominent reprint flow, and nagging people to enrol a second
device. The README explains the trade rather than burying it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev feff5b3c4748ce05239ad77475df59fe3c077539 parent e601a84
3 files changed, +160 −48
  • AGENTS.md +15 −6
  • DESIGN.md +119 −36
  • README.md +26 −6
diff --git a/AGENTS.md b/AGENTS.md
index 2e0a00a..1ab689e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,12 +92,21 @@ because paper is the only medium that is not the thing we are replacing.
Secondary reason, not the deciding one: the 1Password Emergency Kit means
this pattern is already understood by the audience.
-The key hierarchy is specified in `DESIGN.md`. **One decision remains open
-there** — whether a 128-bit Account Key is printed beside the recovery
-code — because it would change the promise that a passphrase alone gets
-you back in. Discussion 5. It does not block anything: the KEK branch is
-built with the input present and constant, so adopting it later is a
-re-wrap, not a redesign.
+**Settled the same day (Paul, 2026-09-01): the kit prints an Account Key
+too.** A 128-bit Account Key is mixed into the passphrase's KEK branch and
+printed on the same client-generated PDF as the recovery code, because
+passphrase-only unlock would have made the security of the replicated
+bucket equal to the strength of whatever the user typed. Rejected: Option
+B, passphrase-only plus compensating controls — a control that depends on
+user behaviour is a hope with a progress bar.
+
+The cost is honest and accepted: a **fresh** device needs the kit, or a
+handoff from an enrolled one, and "passphrase known, kit lost, every
+device gone" is now unrecoverable. The everyday case is unchanged — a
+device that has the Account Key cached unlocks with the passphrase alone.
+The mitigation is UX, not crypto: a three-rung ladder that never
+dead-ends, and a prominent reprint flow. `DESIGN.md` carries the kit
+layout, the ladder, and the full loss table.
### 3. Email exists only as a blind index (Paul, 2026-09-01)
diff --git a/DESIGN.md b/DESIGN.md
index 03451a7..31ec530 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -1,8 +1,8 @@
# 🤖 DESIGN — CONTRA's protocol and key hierarchy
-Status: **approved design — not yet built.** Reviewed 2026-09-01. One
-decision is open and marked as such; everything else here is binding, and
-if a decision proves wrong in practice, stop and flag it rather than
+Status: **approved design — not yet built.** Reviewed 2026-09-01; the
+Account Key decision settled the same day. Everything here is binding: if
+a decision proves wrong in practice, stop and flag it rather than
redesigning in place.
`AGENTS.md` is the one rule and the settled decisions. This is the spec.
@@ -17,9 +17,12 @@ derived from another.
passphrase ──Argon2id(m=64MiB, t=3, p=1, salt_pw)──► S_pw (32B)
S_pw ──HKDF-SHA-256, info="contra/v1/login"──► seed ──► P-256 keypair LK_pw
(server stores the PUBLIC key only)
- S_pw ──HKDF-SHA-256, info="contra/v1/kek"────► KEK_pw
+ S_pw ──HKDF-SHA-256, salt=account_key, info="contra/v1/kek"──► KEK_pw
W_pw = AES-256-GCM(KEK_pw, RK)
+account key (128-bit random, printed once on the kit, cached per device)
+ mixes into KEK_pw above — so W_pw is not an offline target without it
+
recovery code (128-bit random, printed once)
──HKDF, info="contra/v1/rc-login"──► P-256 keypair LK_rc (public key to server)
──HKDF, info="contra/v1/rc-kek"────► KEK_rc W_rc = AES-GCM(KEK_rc, RK)
@@ -184,46 +187,126 @@ items can still be sealed to `vP_j` with no unlock at all.
| Compromise | What the attacker gets | What they do not |
|---|---|---|
-| **Server DB + bucket (passive)** | Every ciphertext and wrap; the sharing graph (wrap rows reveal vault membership and public keys); object sizes and timestamps; the pepper, so email addresses can be confirmed offline; one offline target — `W_pw`, at 64 MiB of Argon2id per guess | Anything at all, absent a successful passphrase guess. `W_rc` and `W_prf` are keyed by full-entropy randomness: dead ends |
+| **Server DB + bucket (passive)** | Every ciphertext and wrap; the sharing graph (wrap rows reveal vault membership and public keys); object sizes and timestamps; the pepper, so email addresses can be confirmed offline | **Nothing.** All three wraps are now keyed by at least 128 bits of full-entropy randomness — `W_pw` through the Account Key, `W_rc` and `W_prf` through their own inputs. There is no offline dictionary target left |
| **Server, actively malicious** | Everything, for any account signing in through the **web console** while compromised — because the server serves the JS. This is the honest boundary of A1 for browser clients | Native app and daemon sessions, where the code is signed and pinned |
| **Signed-in browser (XSS, hostile extension)** | RK and unlocked vault keys in memory: full read and write. Non-extractable WebCrypto keys stop raw-key exfiltration, not plaintext exfiltration | Other accounts; anything after the keys are dropped |
| **Stolen unlocked laptop** | Everything until auto-lock. After lock the disk is equivalent to a bucket leak | RK after lock, without passphrase, paper, or biometric |
-| **The printed code alone** | Full takeover — that is its job — but only once the online recovery flow releases the blobs, which notifies every device and the account email | Nothing offline. No ciphertexts in hand, no server cooperation, zero |
-| **The passphrase alone** | The full account: sign in, fetch wraps, decrypt | Nothing offline without ciphertexts |
+| **The kit alone (both printed values)** | Full takeover — that is its job — but only once the online recovery flow releases the blobs, which notifies every device and the account email | Nothing offline. No ciphertexts in hand, no server cooperation, zero |
+| **The Account Key alone** (photographed while being typed into a new device) | Nothing by itself. It is one input to the passphrase branch, useless without the passphrase | RK, the vault, or any wrap — which is why it is the panel designed to be photographed |
+| **The passphrase alone** | Sign-in, on a device that already caches the Account Key. On any other device: nothing | The vault, from a fresh device, without the kit or a handoff from an enrolled one |
+
+## The Emergency Kit — settled (Paul, 2026-09-01)
-## OPEN DECISION — the Account Key
+**Decided: we print the Account Key too, on the same client-generated PDF
+as the recovery code, and layer UX on top of it.** Discussion 5, closed.
-**This is the one thing in this document that is not settled, and it needs
-Paul.**
+### Why
With passphrase-only unlock, `W_pw` sitting in the replicated bucket is an
offline dictionary target bounded only by ~1 second of Argon2id per guess.
-A human-typical passphrase falls to that. **The security of the bucket
-equals the strength of the passphrase**, and no amount of good design
-elsewhere changes it.
-
-The proven fix is the other half of 1Password's Emergency Kit: a **128-bit
-Account Key printed on the same sheet as the recovery code**, mixed into
-the KEK branch (`HKDF(S_pw, account_key)`), cached per enrolled device and
-required once per new device.
-
-**Option A — Account Key, on (the reviewer's recommendation).** The bucket
-becomes uncrackable regardless of passphrase quality. The costs are real:
-a genuinely fresh browser needs the sheet, or a QR handoff from an enrolled
-device — a stricter reading of "a passphrase signs you in". And a new fatal
-loss mode appears: *all devices lost, paper lost, passphrase remembered*
-becomes unrecoverable, which is not what the current promise says.
-
-**Option B — no Account Key.** The passphrase alone gets you in from
-anywhere, which is what was originally specified. Then these compensating
-controls are **mandatory, not optional**: a generated-passphrase
-suggestion the UI pushes hard, an enforced zxcvbn floor, and a published
-threat model that states plainly that bucket security equals passphrase
-strength.
-
-Until this is decided, build the KEK branch with the account-key input
-present and set to a constant — so switching to Option A later is a
-re-wrap, not a redesign.
+A human-typical passphrase falls to that, which would have made **bucket
+security equal to passphrase strength** — the ceiling on how strong the
+product can ever be, set by the weakest thing a user types.
+
+A 128-bit **Account Key**, mixed into the KEK branch, removes that ceiling
+outright. The bucket becomes uncrackable regardless of how bad the
+passphrase is. This is the half of 1Password's Emergency Kit that actually
+protects the data at rest, and our audience already understands the shape.
+
+**Rejected: Option B, passphrase-only with compensating controls** (a
+pushed generated passphrase, an enforced zxcvbn floor, and a published
+admission that the bucket is only as strong as the passphrase). Rejected
+because a compensating control that depends on user behaviour is not a
+control — it is a hope with a progress bar.
+
+### The derivation change
+
+ KEK_pw = HKDF-SHA-256(S_pw, salt=account_key, info="contra/v1/kek")
+
+The account-key input was already specified as present-and-constant so
+this would be a re-wrap rather than a redesign. It now carries the real
+128-bit value. Nothing else in the hierarchy moves: `W_rc` and `W_prf`
+remain peer wraps of the same RK, keyed by their own full-entropy inputs
+and **not** gated on the Account Key.
+
+That last point is deliberate. The recovery code alone still reaches RK.
+Gating recovery on the Account Key as well would add nothing against the
+threat that matters — both values live on the same sheet, so anything that
+compromises one compromises the other — while adding a second thing to go
+wrong on the worst day of a user's life.
+
+### What the kit is
+
+A **client-generated PDF**, produced in the browser at signup, which never
+touches the server in any form. One page:
+
+- The **Account Key** — 128-bit, Crockford Base32, `A1-XXXXX-…`, with its
+ own QR.
+- The **recovery code** — 128-bit, `C1-XXXXX-…`, with its own QR, in a
+ visually separated panel (see the handling note below).
+- The account's blind-index handle, so the sheet says which account it
+ opens without printing an email address.
+- The date printed, and one paragraph in plain language: what each value
+ does, that nobody can reissue them, and that losing this sheet **and**
+ every enrolled device means the data is gone.
+
+**Handling note, and it drives the layout:** the Account Key is touched
+routinely — every new device needs it — while the recovery code should
+ideally never leave the paper. Printing them adjacent means routine
+handling of one exposes the other to every shoulder and phone camera in
+the room. So the two occupy clearly separated panels with a fold or cut
+line between them, and the copy tells the user they may separate the
+sheet. The Account Key panel is the one designed to be photographed; the
+recovery code panel is the one designed not to be.
+
+### The UX ladder for a fresh device
+
+The Account Key's whole cost is that a new device needs it. Every screen
+that needs one offers all available rungs and **never dead-ends**, cheapest
+first:
+
+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
+ already-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 and we reuse
+ its shape rather than inventing one.
+2. **Scan the QR** on the kit with the device's camera.
+3. **Type it**, Crockford Base32 with the self-healing checksum, as the
+ last resort.
+
+Once a device holds the Account Key it is cached locally, and from then on
+**the passphrase alone unlocks that device** — which is the everyday case
+and is unchanged from the original promise.
+
+### Reprinting
+
+If the sheet is lost while at least one device is still enrolled, the user
+must be able to produce a new one, and the flow must be obvious rather
+than buried. What it can and cannot restore:
+
+- The **Account Key can be reprinted exactly**, because enrolled devices
+ cache it.
+- The **recovery code cannot** — only its wrap is stored, never the value.
+ Reprinting therefore *regenerates* it: a new code, a new `W_rc`, the old
+ wrap deleted. The old printed code stops working, and the copy says so
+ before the user commits.
+
+### The loss modes, stated plainly
+
+| Situation | Outcome |
+|---|---|
+| Passphrase, on an enrolled device | Fine — the everyday case |
+| Passphrase, fresh device, kit to hand | Fine — scan or type the Account Key |
+| Passphrase, fresh device, no kit, but another device still enrolled | Fine — handoff |
+| Passphrase forgotten, kit to hand | Fine — the recovery code |
+| Kit lost, passphrase known, a device still enrolled | Fine — and the app should push hard to reprint |
+| **Passphrase known, kit lost, every device gone** | **Data is gone.** New, and caused by this decision |
+| Kit lost and passphrase forgotten | Data is gone — unchanged |
+
+The sixth row is the price of the decision and it is a real one. It is why
+the reprint flow has to be prominent and why enrolling a second device is
+worth nagging about. The README states it without softening.
## Further rulings from the review, adopted
diff --git a/README.md b/README.md
index a720a60..6d6f662 100644
--- a/README.md
+++ b/README.md
@@ -23,15 +23,30 @@ the passwords are, because a name is a secret too.
This is the part most password managers put in a support article. It goes
here instead.
-- **If you lose your passphrase *and* your recovery codes, your data is
+- **If you lose your Emergency Kit *and* your passphrase, your data is
gone.** Permanently. We cannot help you, and neither can anyone running
a CONTRA server. There is no reset link, no support override, no
"verify your identity and we'll restore it." That is not a gap in the
product — it is the product. A server that could give your vault back
is a server that could give it to someone else.
-- **Your recovery codes are printed once and never shown again.** Print
- them, put them somewhere physical. They cannot live in a password
- manager, because *this* is the password manager.
+- **There is a second way to lose everything, and it is less obvious.**
+ If you lose the kit *and* every device you have already set up, then
+ knowing your passphrase is not enough — a brand-new device needs the
+ Account Key, and only the kit and your existing devices have it. So:
+ keep the kit, and set up a second device. The app will nag you about
+ both, and this is why.
+- **The kit is printed once and never shown again.** Print it, keep it
+ physical. It cannot live in a password manager, because *this* is the
+ password manager. If you lose it while you still have a device signed
+ in, reprint it immediately — that works, and it stops working the day
+ your last device does.
+- **Why we ask this of you.** We could have let a passphrase alone open
+ the vault from anywhere. Then the encrypted copy of your data would only
+ ever be as strong as the phrase you chose, and most chosen phrases are
+ guessable by a patient machine. The Account Key means that no matter
+ what you pick, the stored copy cannot be broken into. We would rather
+ ask you to keep a piece of paper than quietly cap how safe your vault
+ can be.
- **Removing someone from a shared vault does not un-give what they
already have.** Their access dies immediately and the vault's key
rotates forward, so they see nothing new. What they already downloaded
@@ -74,8 +89,13 @@ yet.
## How you get in
- A **passphrase** signs you in to the web console.
-- **Recovery codes**, printed once at signup, are the way back if the
- passphrase is lost.
+- An **Emergency Kit** — one page, printed once at signup, generated in
+ your browser and never sent to us. It carries two things: an **Account
+ Key** that a new device needs once, and a **recovery code** that is the
+ way back if you forget the passphrase.
+- On a device you have already set up, the passphrase alone is enough.
+ Setting up a *new* one takes the Account Key — scan it from the kit, or
+ let a device you already use hand it over.
- A **passkey** on a device you trust is a fast unlock on top — a
convenience, never the only door. The passphrase is offered beside it on
every screen, because passkey ceremonies fail in ways that are not the