| 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 |
| |