carlos / contra Public

design: one root key, three peer wraps β€” and two admissions in the README

A crypto design review settles the hierarchy. The passphrase both derives
and authenticates, as two provably independent HKDF branches off one
Argon2id call; sign-in is a signed challenge against a public key the
server holds, so a stolen database contains nothing that can decrypt and
nothing that can impersonate. The printed code and each passkey PRF output
are peer wraps of the same root β€” independently sufficient, never chained
β€” which is what lets a PRF mismatch degrade to "fast unlock unavailable"
instead of stranding somebody.

Two findings changed user-facing copy, both because the honest version is
worse than the version we would have shipped by default. A browser is
handed its JavaScript by the server, so the "compromise the running server
and get nothing" claim is only fully true for signed native apps; the
at-rest claim is untouched. And wrap rows reveal that two accounts share a
vault, though never what is in it. Both are now in the README's trade-offs
rather than left for someone to find.

One decision is deliberately left open for Paul: whether to print a
128-bit Account Key beside the recovery code. It makes the replicated
bucket uncrackable regardless of passphrase quality, and it costs a fresh
browser the sheet. The KEK branch is specified with the input present and
constant so the choice stays a re-wrap, not a redesign.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 14ca156340be580b5e1b30739b000647ed5b5f80 parent 6611061
2 files changed, +265 −0
  • DESIGN.md +248 −0
  • README.md +17 −0
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..03451a7
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,248 @@
+# πŸ€– 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
+redesigning in place.
+
+`AGENTS.md` is the one rule and the settled decisions. This is the spec.
+
+## The shape
+
+One root key, three peer wraps. The wraps are **peers, not a chain** β€”
+each is independently sufficient to reach the root, and none can be
+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
+ W_pw = AES-256-GCM(KEK_pw, RK)
+
+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)
+
+passkey PRF output (32B, UV required)
+ ──HKDF, salt=cred_salt, info="contra/v1/prf-kek"──► KEK_prf[i]
+ W_prf[i] = AES-GCM(KEK_prf[i], RK)
+
+RK 256-bit random, generated client-side at signup β€” the root
+ ──AES-GCM──► account keyset { d_acct : P-256 private key
+ K_meta : AES-256 key for account metadata }
+
+vault j keyset { K_vault : AES-256 bulk key
+ (vd_j, vP_j) : P-256 drop-box keypair }
+ wrapped to each member: ephemeral-static ECDH(P-256) β†’ HKDF β†’ AES-GCM,
+ sealed to that member's account public key
+
+items: AES-256-GCM under K_vault, AAD = (vault_id, item_id, version)
+drop-box writes (no unlock needed): ECIES-seal to vP_j, re-encrypted under
+ K_vault by the next writer who is unlocked
+```
+
+Every wrap row carries a format version, the KDF parameters (authenticated
+as AAD), a 16-byte key-check value `KCV = HMAC-SHA-256(KEK,
+"contra/v1/kcv")[..16]`, a 96-bit random nonce, and the ciphertext.
+
+Every AEAD's AAD binds `(account_id, object_type, object_id, version)` so
+a malicious server cannot transplant a ciphertext between slots or between
+users.
+
+**All object identifiers are random UUIDs, never content hashes.** No
+convergent encryption and no dedup: a content-derived identifier at rest
+is both an A1 violation and an equality oracle.
+
+## Sign-in is a signature, not a verifier
+
+One expensive Argon2id call, then HKDF-Expand into two subkeys under
+distinct `info` labels. Independence is provable from HKDF's PRF property:
+holding the login branch says nothing about the KEK branch without
+inverting HMAC-SHA-256.
+
+The client derives a deterministic P-256 signing keypair from the login
+branch and the server stores **only the public key**, authenticating by
+signed challenge. Better than a stored password verifier in three ways: a
+fully compromised server holds something that cannot decrypt, cannot be
+replayed to impersonate, and adds no offline attack surface it did not
+already have via `W_pw`. It is also one auth mechanism shared by the
+browser, the Mac app and the daemon.
+
+**The client pins a hard floor on KDF parameters β€” m β‰₯ 64 MiB, t β‰₯ 3 β€” and
+refuses anything weaker the server offers.** Without the floor a malicious
+server serves `m=8KiB, t=1`, harvests one challenge signature, and cracks
+the passphrase cheaply offline. The floor is the defence, not the
+ceremony.
+
+## Parameters, and what they cost
+
+- **Argon2id m=64 MiB, t=3, p=1**, 32-byte tag. 64 MiB is the largest
+ allocation that reliably succeeds in iOS Safari's WASM heap under memory
+ pressure. `p=1` is forced, not chosen: Argon2id's output depends on `p`,
+ WASM without `SharedArrayBuffer` cannot parallelise, so `p>1` would
+ break byte-identity across Go/JS/Swift. What we traded: multicore
+ speedup on native, and the last factor of 2–4 of memory-hardness versus
+ a native-only product. Parameters are versioned in the wrap header and
+ authenticated as AAD, so they can be raised later with transparent
+ re-wrap on the next successful unlock.
+- **Salt:** 16 random bytes, client-generated at signup and at every
+ passphrase change, served pre-auth. The salt endpoint answers an unknown
+ email with a deterministic fake salt `HMAC(pepper, email)`, so account
+ existence does not leak from it.
+- **HKDF-SHA-256 everywhere**, every derivation carrying an explicit
+ versioned `info` label.
+- Golden vectors cover Argon2id, every HKDF label, Crockford
+ normalisation and the checksum. Cross-platform KDF drift is exactly the
+ bug class behind the 1Password PRF mismatch.
+- AES-GCM is not key-committing, so the per-wrap KCV is the commitment
+ check β€” and, just as usefully, it distinguishes "wrong key" from
+ "corrupted blob", a distinction the unlock paths need constantly.
+
+## The recovery code
+
+**128 bits of CSPRNG entropy, Crockford Base32, 26 data characters plus a
+4-character checksum**, printed as `C1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`
+with a QR of the same payload on the sheet.
+
+- 128 bits because it must resist the replicated bucket forever. More buys
+ nothing.
+- Crockford Base32 because it is case-insensitive and decodes O→0 and
+ I/L→1 — the exact confusions humans and OCR make off aged paper.
+- The checksum is 20 bits of `SHA-256(payload)`. On entry the client
+ normalises, checks, and on failure brute-forces every single-character
+ substitution (~30 positions Γ— 32 characters β‰ˆ 10Β³ candidates) against
+ the checksum and then the KCV. Typos self-heal with no exotic
+ error-correcting code. Chosen over BIP39 words: language-neutral, and
+ word lists localise badly.
+- **HKDF, not Argon2id.** The input is already full-entropy, so
+ memory-hardness adds nothing, and the recovery path must be the
+ simplest, least-rotted code in the product. No server-supplied
+ parameters on this path at all β€” immune to downgrade by construction.
+- The crypto cannot tell the owner from a burglar holding the sheet, so
+ **policy must**: the online recovery flow notifies every enrolled device
+ and the account email, with an optional user-configurable delay.
+
+## Passkey PRF fast unlock
+
+A third, additive wrap β€” never a shortcut around RK. Enrolment creates the
+credential then immediately asserts with `prf.eval.first = cred_salt` (32
+random non-secret bytes, stored server-side);
+`KEK_prf = HKDF(prf_output, salt=cred_salt, info="contra/v1/prf-kek")`. One
+assertion both signs the login challenge and returns the PRF output, so
+one touch signs in and unlocks β€” through RK, not around it.
+
+Tolerating the 1Password-class mismatch, where a provider returns a
+different PRF for the same credential:
+
+- Pin `userVerification: "required"` in every ceremony. CTAP2's
+ hmac-secret keeps distinct with-UV and without-UV secrets, and mixed UV
+ policy is a known cause of the mismatch.
+- **Check the KCV before attempting decryption.** A mismatch means "this
+ provider returns a different PRF here" β€” treat it as *fast unlock
+ unavailable*, never as an authentication failure, since the assertion
+ signature is still valid for sign-in. Fall back to the passphrase
+ silently, then offer to enrol the newly observed output as an
+ *additional* wrap. Several `W_prf` rows per credential, keyed by
+ provider fingerprint, are harmless: each is an independent ciphertext of
+ the same RK.
+- PRF wraps are pure convenience. Their loss, mismatch or deletion can
+ never strand anyone, because `W_pw` and `W_rc` are never removed.
+
+The Mac daemon follows the same pattern at vault granularity: its own
+Secure-Enclave-backed P-256 device keypair, with the user granting access
+by wrapping specific vault keysets to it. Least privilege, and captured
+items can still be sealed to `vP_j` with no unlock at all.
+
+## Rotation β€” and what does not heal
+
+- **Routine passphrase change:** new salt, new `S_pw`, new `LK_pw` public
+ key, new `W_pw`, old wrap row deleted. Nothing else moves. This protects
+ against *future* disclosure. It does **not** revoke the old passphrase:
+ an attacker holding an archived snapshot has `W_pw_old` and RK is
+ unchanged.
+- **Credential rotation ("I think it was exposed"):** new RKβ€², new
+ `d_acct`β€², every vault keyset re-wrapped to the new public key (one
+ ECIES seal per vault β€” cheap), all three wrap families reissued, which
+ forces printing a new recovery code. Old snapshots plus old secrets then
+ yield only old vault keysets β€” which still open current items, because
+ `K_vault` has not moved.
+- **Full healing of a vault is vault rotation:** new keyset, items
+ re-encrypted, re-wrapped to members. **Run it automatically on member
+ removal** β€” in a seal-to-public-key model, revocation *is* rotation,
+ because the removed member may have cached `K_vault`.
+- **Recovery-code regeneration** is a new code, a new `W_rc`, old wrap
+ deleted. "I want a fresh printout" is regeneration; "the paper was
+ exposed" is credential rotation.
+- **Snapshot residue is part of the crypto design, not an ops detail.**
+ Replication plus bucket versioning means every superseded wrap persists.
+ A lifecycle policy expiring old object versions after 30 days bounds the
+ window; without one, every rotation leaves permanent crackable residue.
+ Pin the policy and publish it.
+
+## Compromise matrix
+
+| 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, 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 |
+
+## OPEN DECISION β€” the Account Key
+
+**This is the one thing in this document that is not settled, and it needs
+Paul.**
+
+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.
+
+## Further rulings from the review, adopted
+
+- **Optional sign-in 2FA** (TOTP, or the passkey assertion itself). It
+ gates blob release, touches no key material, and turns "the passphrase
+ alone" from full takeover into a stop at the door. Cheap and
+ A1-compatible.
+- **Move the email pepper off the instance** β€” to the routing layer or a
+ KMS β€” so a DB-plus-bucket leak alone cannot unblind addresses.
+- **Quantise ciphertext sizes**: pad items into 256-byte buckets. A1
+ tolerates size residue; that is no reason to hand over item-length
+ fingerprints for free.
+- **The sharing graph is at-rest metadata.** Wrap rows reveal who shares
+ with whom, even under A1's letter. This goes in the published model.
+ Hiding it (per-vault pseudonymous member slots) is possible and is not
+ worth the complexity now β€” recorded so the choice is visible rather than
+ accidental.
+- **Serve the web console from a separate, static, integrity-protected
+ origin** β€” signed releases, SRI, ideally an installable PWA or
+ extension β€” and say plainly that A1 for browser sessions means at-rest
+ and honest-but-curious, not actively-malicious-during-login. Native apps
+ with signed binaries genuinely meet the stronger claim.
diff --git a/README.md b/README.md
index b794f64..a720a60 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,23 @@ here instead.
any tool β€” 1Password does not let them out. Everything else in your
vault comes across; those need to be re-created by hand, and we will
show you which ones.
+- **A web browser trusts the server to send it honest code.** This one is
+ subtle and most of our competitors do not say it. When you use the web
+ console, the server hands your browser the JavaScript that handles your
+ passphrase β€” so a server that has been *actively taken over* could send
+ code that steals it as you type. Our at-rest promise ("steal the
+ database and get nothing") holds absolutely. The stronger promise β€”
+ "compromise the running server and still get nothing" β€” is only fully
+ true for the signed native apps, where the code is not fetched from us
+ each time. We serve the console from a separate, static, signed origin
+ to narrow this as far as a browser allows, and we would rather tell you
+ than let you assume.
+- **The server can see who shares a vault with whom.** Not what is in it,
+ not its name, not a single value β€” but the wrapped keys that let your
+ colleague open a shared vault are visible as a shape, so the server
+ learns that two accounts share something. Personal vaults reveal
+ nothing. We could hide this and have chosen not to spend the complexity
+ yet; it is listed here rather than left for someone to discover.
- **It is early.** What is built and what is not is in `PLAN.md`, with
triggers for each deferral rather than vague intentions.