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