| 1 | # π€ PROTOCOL β CONTRA's cryptographic protocol and key hierarchy |
| 2 | |
| 3 | Renamed from `DESIGN.md` on 2026-09-04. It was never a design document in |
| 4 | the visual sense, and the name was in the way: `DESIGN.md` is the |
| 5 | conventional home for a project's visual system, and this file is the wire |
| 6 | format. They are now separate files with separate jobs. |
| 7 | |
| 8 | Status: **approved design β not yet built.** Reviewed 2026-09-01; the |
| 9 | Account Key decision settled the same day. Everything here is binding: if |
| 10 | a decision proves wrong in practice, stop and flag it rather than |
| 11 | redesigning in place. |
| 12 | |
| 13 | `AGENTS.md` is the one rule and the settled decisions. This is the spec. |
| 14 | |
| 15 | ## The shape |
| 16 | |
| 17 | One root key, three peer wraps. The wraps are **peers, not a chain** β |
| 18 | each is independently sufficient to reach the root, and none can be |
| 19 | derived from another. |
| 20 | |
| 21 | ``` |
| 22 | passphrase ββArgon2id(m=64MiB, t=3, p=1, salt_pw)βββΊ S_pw (32B) |
| 23 | S_pw ββHKDF-SHA-256, info="contra/v1/login"βββΊ seed βββΊ P-256 keypair LK_pw |
| 24 | (server stores the PUBLIC key only) |
| 25 | S_pw ββHKDF-SHA-256, salt=account_key, info="contra/v1/kek"βββΊ KEK_pw |
| 26 | W_pw = AES-256-GCM(KEK_pw, RK) |
| 27 | |
| 28 | account key (128-bit random, printed once on the kit, cached per device) |
| 29 | mixes into KEK_pw above β so W_pw is not an offline target without it |
| 30 | |
| 31 | recovery code (128-bit random, printed once) |
| 32 | ββHKDF, info="contra/v1/rc-login"βββΊ P-256 keypair LK_rc (public key to server) |
| 33 | ββHKDF, info="contra/v1/rc-kek"βββββΊ KEK_rc W_rc = AES-GCM(KEK_rc, RK) |
| 34 | |
| 35 | passkey PRF output (32B, UV required) |
| 36 | ββHKDF, salt=cred_salt, info="contra/v1/prf-kek"βββΊ KEK_prf[i] |
| 37 | W_prf[i] = AES-GCM(KEK_prf[i], RK) |
| 38 | |
| 39 | RK 256-bit random, generated client-side at signup β the root |
| 40 | ββAES-GCMβββΊ account keyset { d_acct : P-256 private key |
| 41 | K_meta : AES-256 key for account metadata } |
| 42 | |
| 43 | vault j keyset { K_vault : AES-256 bulk key |
| 44 | (vd_j, vP_j) : P-256 drop-box keypair } |
| 45 | wrapped to each member: ephemeral-static ECDH(P-256) β HKDF β AES-GCM, |
| 46 | sealed to that member's account public key |
| 47 | |
| 48 | items: AES-256-GCM under K_vault, AAD = (vault_id, item_id, version) |
| 49 | drop-box writes (no unlock needed): ECIES-seal to vP_j, re-encrypted under |
| 50 | K_vault by the next writer who is unlocked |
| 51 | ``` |
| 52 | |
| 53 | Every wrap row carries a format version, the KDF parameters (authenticated |
| 54 | as AAD), a 16-byte key-check value `KCV = HMAC-SHA-256(KEK, |
| 55 | "contra/v1/kcv")[..16]`, a 96-bit random nonce, and the ciphertext. |
| 56 | |
| 57 | Every AEAD's AAD binds `(account_id, object_type, object_id, version)` so |
| 58 | a malicious server cannot transplant a ciphertext between slots or between |
| 59 | users. |
| 60 | |
| 61 | **All object identifiers are random UUIDs, never content hashes.** No |
| 62 | convergent encryption and no dedup: a content-derived identifier at rest |
| 63 | is both an A1 violation and an equality oracle. |
| 64 | |
| 65 | ## Sign-in is a signature, not a verifier |
| 66 | |
| 67 | One expensive Argon2id call, then HKDF-Expand into two subkeys under |
| 68 | distinct `info` labels. Independence is provable from HKDF's PRF property: |
| 69 | holding the login branch says nothing about the KEK branch without |
| 70 | inverting HMAC-SHA-256. |
| 71 | |
| 72 | The client derives a deterministic P-256 signing keypair from the login |
| 73 | branch and the server stores **only the public key**, authenticating by |
| 74 | signed challenge. Better than a stored password verifier in three ways: a |
| 75 | fully compromised server holds something that cannot decrypt, cannot be |
| 76 | replayed to impersonate, and adds no offline attack surface it did not |
| 77 | already have via `W_pw`. It is also one auth mechanism shared by the |
| 78 | browser, the Mac app and the daemon. |
| 79 | |
| 80 | **The client pins a hard floor on KDF parameters β m β₯ 64 MiB, t β₯ 3 β and |
| 81 | refuses anything weaker the server offers.** Without the floor a malicious |
| 82 | server serves `m=8KiB, t=1`, harvests one challenge signature, and cracks |
| 83 | the passphrase cheaply offline. The floor is the defence, not the |
| 84 | ceremony. |
| 85 | |
| 86 | ## Parameters, and what they cost |
| 87 | |
| 88 | - **Argon2id m=64 MiB, t=3, p=1**, 32-byte tag. 64 MiB is the largest |
| 89 | allocation that reliably succeeds in iOS Safari's WASM heap under memory |
| 90 | pressure. `p=1` is forced, not chosen: Argon2id's output depends on `p`, |
| 91 | WASM without `SharedArrayBuffer` cannot parallelise, so `p>1` would |
| 92 | break byte-identity across Go/JS/Swift. What we traded: multicore |
| 93 | speedup on native, and the last factor of 2β4 of memory-hardness versus |
| 94 | a native-only product. Parameters are versioned in the wrap header and |
| 95 | authenticated as AAD, so they can be raised later with transparent |
| 96 | re-wrap on the next successful unlock. |
| 97 | - **Salt:** 16 random bytes, client-generated at signup and at every |
| 98 | passphrase change, served pre-auth. The salt endpoint answers an unknown |
| 99 | email with a deterministic fake salt `HMAC(pepper, email)`, so account |
| 100 | existence does not leak from it. |
| 101 | - **HKDF-SHA-256 everywhere**, every derivation carrying an explicit |
| 102 | versioned `info` label. |
| 103 | - Golden vectors cover Argon2id, every HKDF label, Crockford |
| 104 | normalisation and the checksum. Cross-platform KDF drift is exactly the |
| 105 | bug class behind the 1Password PRF mismatch. |
| 106 | - **The per-wrap KCV is a key *confirmation* check first.** Its everyday |
| 107 | job is distinguishing "wrong key" from "corrupted blob" β a distinction |
| 108 | the unlock paths need constantly, and the whole of the passkey |
| 109 | PRF-mismatch detector below. AES-GCM is also not key-committing, and the |
| 110 | KCV supplies that too as a side effect, on exactly the rows that need |
| 111 | it. Earlier drafts had this the other way round, which made the |
| 112 | commitment sound load-bearing and the confirmation incidental. |
| 113 | |
| 114 | `HMAC-SHA-256(KEK, label)[..16]` **is** `HKDF-Expand(KEK, label)` minus |
| 115 | a counter byte β that is, the Albertini et al. key-derivation |
| 116 | commitment, not a hand-rolled substitute for one. |
| 117 | |
| 118 | Keysets and items carry no KCV and need none: nobody but the client can |
| 119 | hold or enumerate RK, `K_vault`, or an ECDH-derived key, so a multi-key |
| 120 | ciphertext cannot be built against them. Every key-commitment attack β |
| 121 | partitioning oracles, invisible salamanders β requires the attacker to |
| 122 | choose or enumerate the keys the victim opens under, and nobody in the |
| 123 | compromise matrix below can. `W_pw` is the only branch with a dictionary |
| 124 | at all, it is gated on the Account Key, and the KCV is checked *before* |
| 125 | decryption, so a crafted multi-key ciphertext gets past it for at most |
| 126 | one key. |
| 127 | |
| 128 | **Committing AEAD and XAES-256-GCM: considered and rejected** |
| 129 | (2026-09-02, discussion 22). The property is already present where it is |
| 130 | needed; the commitment here is a property of the decryption *procedure* |
| 131 | rather than of the bytes, so a second implementation that skips the KCV |
| 132 | check loses it β that is discipline to enforce, not a risk being |
| 133 | carried. If commitment is ever wanted for items, the tool is 32 zero |
| 134 | bytes prefixed to the plaintext, not a cipher change. Reopening this |
| 135 | wants a new fact; Albertini 2020, LenβGrubbsβRistenpart 2021 and XAES |
| 136 | 2024 all predate this document, which already named the non-committing |
| 137 | property. |
| 138 | |
| 139 | Nit, recorded and not acted on: the KCV is unsalted, so two `W_prf` rows |
| 140 | with equal KEKs have equal KCVs β telling the server that two providers |
| 141 | return the same PRF, which the random nonces would otherwise hide. |
| 142 | Salting with the wrap id removes it and changes the golden vectors. |
| 143 | |
| 144 | ## The recovery code |
| 145 | |
| 146 | **128 bits of CSPRNG entropy, Crockford Base32, 26 data characters plus a |
| 147 | 4-character checksum**, printed as `C1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX` |
| 148 | with a QR of the same payload on the sheet. |
| 149 | |
| 150 | - 128 bits because it must resist the replicated bucket forever. More buys |
| 151 | nothing. |
| 152 | - Crockford Base32 because it is case-insensitive and decodes Oβ0 and |
| 153 | I/Lβ1 β the exact confusions humans and OCR make off aged paper. |
| 154 | - The checksum is 20 bits of `SHA-256(payload)`. On entry the client |
| 155 | normalises, checks, and on failure brute-forces every single-character |
| 156 | substitution (~30 positions Γ 32 characters β 10Β³ candidates) against |
| 157 | the checksum and then the KCV. Typos self-heal with no exotic |
| 158 | error-correcting code. Chosen over BIP39 words: language-neutral, and |
| 159 | word lists localise badly. |
| 160 | - **HKDF, not Argon2id.** The input is already full-entropy, so |
| 161 | memory-hardness adds nothing, and the recovery path must be the |
| 162 | simplest, least-rotted code in the product. No server-supplied |
| 163 | parameters on this path at all β immune to downgrade by construction. |
| 164 | - The crypto cannot tell the owner from a burglar holding the sheet, so |
| 165 | **policy must**: the online recovery flow notifies every enrolled device |
| 166 | and the account email, with an optional user-configurable delay. |
| 167 | |
| 168 | ## Passkey PRF fast unlock |
| 169 | |
| 170 | A third, additive wrap β never a shortcut around RK. Enrolment creates the |
| 171 | credential then immediately asserts with `prf.eval.first = cred_salt` (32 |
| 172 | random non-secret bytes, stored server-side); |
| 173 | `KEK_prf = HKDF(prf_output, salt=cred_salt, info="contra/v1/prf-kek")`. One |
| 174 | assertion both signs the login challenge and returns the PRF output, so |
| 175 | one touch signs in and unlocks β through RK, not around it. |
| 176 | |
| 177 | Tolerating the 1Password-class mismatch, where a provider returns a |
| 178 | different PRF for the same credential: |
| 179 | |
| 180 | - Pin `userVerification: "required"` in every ceremony. CTAP2's |
| 181 | hmac-secret keeps distinct with-UV and without-UV secrets, and mixed UV |
| 182 | policy is a known cause of the mismatch. |
| 183 | - **Check the KCV before attempting decryption.** A mismatch means "this |
| 184 | provider returns a different PRF here" β treat it as *fast unlock |
| 185 | unavailable*, never as an authentication failure, since the assertion |
| 186 | signature is still valid for sign-in. Fall back to the passphrase |
| 187 | silently, then offer to enrol the newly observed output as an |
| 188 | *additional* wrap. Several `W_prf` rows per credential, keyed by |
| 189 | provider fingerprint, are harmless: each is an independent ciphertext of |
| 190 | the same RK. |
| 191 | - PRF wraps are pure convenience. Their loss, mismatch or deletion can |
| 192 | never strand anyone, because `W_pw` and `W_rc` are never removed. |
| 193 | |
| 194 | The Mac daemon follows the same pattern at vault granularity: its own |
| 195 | Secure-Enclave-backed P-256 device keypair, with the user granting access |
| 196 | by wrapping specific vault keysets to it. Least privilege, and captured |
| 197 | items can still be sealed to `vP_j` with no unlock at all. |
| 198 | |
| 199 | ## Rotation β and what does not heal |
| 200 | |
| 201 | - **Routine passphrase change:** new salt, new `S_pw`, new `LK_pw` public |
| 202 | key, new `W_pw`, old wrap row deleted. Nothing else moves. This protects |
| 203 | against *future* disclosure. It does **not** revoke the old passphrase: |
| 204 | an attacker holding an archived snapshot has `W_pw_old` and RK is |
| 205 | unchanged. |
| 206 | - **Credential rotation ("I think it was exposed"):** new RKβ², new |
| 207 | `d_acct`β², every vault keyset re-wrapped to the new public key (one |
| 208 | ECIES seal per vault β cheap), all three wrap families reissued, which |
| 209 | forces printing a new recovery code. Old snapshots plus old secrets then |
| 210 | yield only old vault keysets β which still open current items, because |
| 211 | `K_vault` has not moved. |
| 212 | - **Full healing of a vault is vault rotation:** new keyset, items |
| 213 | re-encrypted, re-wrapped to members. **Run it automatically on member |
| 214 | removal** β in a seal-to-public-key model, revocation *is* rotation, |
| 215 | because the removed member may have cached `K_vault`. |
| 216 | |
| 217 | **Eager, not lazy, and this is the resolution of a conflict** (2026-09-02, |
| 218 | discussion 23). `AGENTS.md` decision 1 says the key "rotates forward for |
| 219 | new and changed items", which reads as lazy; this document says items are |
| 220 | re-encrypted on removal, which is eager. Eager wins, for a residue |
| 221 | reason rather than a performance one: lazy needs a per-row |
| 222 | key-generation column, and that column tells the operator which items |
| 223 | changed since a member was removed. Re-encrypting N rows is the same |
| 224 | bytes as rewriting one blob and the CPU is negligible; what matters is |
| 225 | that it is **one transaction**, guarded by a compare-and-swap on the |
| 226 | vault epoch, so a concurrent writer gets a conflict rather than writing |
| 227 | an item under the key that was just revoked. |
| 228 | - **Recovery-code regeneration** is a new code, a new `W_rc`, old wrap |
| 229 | deleted. "I want a fresh printout" is regeneration; "the paper was |
| 230 | exposed" is credential rotation. |
| 231 | - **Snapshot residue is part of the crypto design, not an ops detail.** |
| 232 | Replication plus bucket versioning means every superseded wrap persists. |
| 233 | A lifecycle policy expiring old object versions after 30 days bounds the |
| 234 | window; without one, every rotation leaves permanent crackable residue. |
| 235 | Pin the policy and publish it. |
| 236 | |
| 237 | ## Compromise matrix |
| 238 | |
| 239 | | Compromise | What the attacker gets | What they do not | |
| 240 | |---|---|---| |
| 241 | | **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 | |
| 242 | | **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 | |
| 243 | | **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 | |
| 244 | | **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 | |
| 245 | | **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 | |
| 246 | | **The Account Key card alone** | Nothing by itself. It is one input to the passphrase branch, useless without the passphrase | RK, the vault, or any wrap. That is *not* a licence to photograph it β see "Do not encourage photographing the Account Key card" below, which this row used to contradict | |
| 247 | | **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 | |
| 248 | |
| 249 | ### At-rest and transport residue, written down (2026-09-02, discussion 23) |
| 250 | |
| 251 | The rows above say what an attacker gets from *keys*. These are what the |
| 252 | stored bytes give up regardless, and they were true before they were |
| 253 | written here, which is the reason for writing them here. |
| 254 | |
| 255 | - **Item count per vault**, from the row count. One blob per vault would |
| 256 | hide it; that is rejected for v1 in the same discussion, because |
| 257 | drop-box and agent writers cannot produce the next blob and "one blob" |
| 258 | becomes "one blob plus an inbox table" whose rows are the most sensitive |
| 259 | class of item there is β captured, unreviewed secrets. |
| 260 | - **Per-item write timing and size class.** Reads leak nothing per item: |
| 261 | `/api/items` returns the whole vault and there is no per-item GET. That |
| 262 | property is deliberate and worth keeping. |
| 263 | - **Which item a write touched**, in transport. |
| 264 | - **Sign-in timing per account**, from the framework's `sessions` table, |
| 265 | which lives in the same replicated file. The merciless greps scope their |
| 266 | DDL check to CONTRA's own tables by design, so this is not a test gap β |
| 267 | but it is residue and belongs here. |
| 268 | - **The sharing graph** β which accounts share a vault, never what is in |
| 269 | it. Already published in the README's trade-offs. |
| 270 | |
| 271 | **Rollback is not prevented, and nothing here should be read as claiming |
| 272 | it is.** The version bound into each item's AAD stops *relabelling*: a |
| 273 | server cannot serve ciphertext `v3` as `v7`. It does not stop a server |
| 274 | consistently serving the whole older `(version, nonce, ciphertext)` triple, |
| 275 | which opens correctly because it is genuinely an older authentic item. |
| 276 | Detecting that needs client-side memory of the highest version seen per |
| 277 | item, and for multiple devices a sealed manifest. Post-v1; until it |
| 278 | exists, no copy may claim rollback protection. |
| 279 | |
| 280 | ## The Emergency Kit β settled (Paul, 2026-09-01) |
| 281 | |
| 282 | **Decided: we print the Account Key too, on the same client-generated PDF |
| 283 | as the recovery code, and layer UX on top of it.** Discussion 5, closed. |
| 284 | |
| 285 | ### Why |
| 286 | |
| 287 | With passphrase-only unlock, `W_pw` sitting in the replicated bucket is an |
| 288 | offline dictionary target bounded only by ~1 second of Argon2id per guess. |
| 289 | A human-typical passphrase falls to that, which would have made **bucket |
| 290 | security equal to passphrase strength** β the ceiling on how strong the |
| 291 | product can ever be, set by the weakest thing a user types. |
| 292 | |
| 293 | A 128-bit **Account Key**, mixed into the KEK branch, removes that ceiling |
| 294 | outright. The bucket becomes uncrackable regardless of how bad the |
| 295 | passphrase is. This is the half of 1Password's Emergency Kit that actually |
| 296 | protects the data at rest, and our audience already understands the shape. |
| 297 | |
| 298 | **Rejected: Option B, passphrase-only with compensating controls** (a |
| 299 | pushed generated passphrase, an enforced zxcvbn floor, and a published |
| 300 | admission that the bucket is only as strong as the passphrase). Rejected |
| 301 | because a compensating control that depends on user behaviour is not a |
| 302 | control β it is a hope with a progress bar. |
| 303 | |
| 304 | ### The derivation change |
| 305 | |
| 306 | KEK_pw = HKDF-SHA-256(S_pw, salt=account_key, info="contra/v1/kek") |
| 307 | |
| 308 | The account-key input was already specified as present-and-constant so |
| 309 | this would be a re-wrap rather than a redesign. It now carries the real |
| 310 | 128-bit value. Nothing else in the hierarchy moves: `W_rc` and `W_prf` |
| 311 | remain peer wraps of the same RK, keyed by their own full-entropy inputs |
| 312 | and **not** gated on the Account Key. |
| 313 | |
| 314 | That last point is deliberate, and the reason changed when the kit became |
| 315 | two documents. The recovery code alone still reaches RK. |
| 316 | |
| 317 | The tempting move is to gate recovery on the Account Key too, now that the |
| 318 | two live apart β defence in depth, and no longer defeated by one |
| 319 | photograph. **Rejected, because of which document survives what.** The |
| 320 | Account Key card is the *handy* one: it lives with your devices, because |
| 321 | that is where it gets used. The Recovery sheet is the *archived* one, |
| 322 | kept somewhere else precisely so it outlives a disaster at home. A fire, a |
| 323 | burglary or a lost bag that takes your devices very likely takes the card |
| 324 | sitting beside them β and gating recovery on the card would make the |
| 325 | archived sheet useless in exactly the scenario it exists for. |
| 326 | |
| 327 | So the Recovery sheet must be sufficient on its own. What protects it is |
| 328 | not a second factor but its handling β archival storage, never |
| 329 | photographed β and the notify-every-device-and-delay policy on the online |
| 330 | recovery flow. |
| 331 | |
| 332 | ### What the kit is β two documents, not two panels |
| 333 | |
| 334 | **Two separate PDFs, both generated in the browser at signup, neither ever |
| 335 | touching the server.** An earlier draft made this one page with a fold |
| 336 | line; that was too weak, for a reason worth writing down. |
| 337 | |
| 338 | The two values have **different blast radii**, and they are not two halves |
| 339 | of one secret: |
| 340 | |
| 341 | - The **Account Key alone is nearly harmless.** It is one input to the |
| 342 | passphrase branch and useless without the passphrase. Photographing it |
| 343 | costs an attacker nothing. |
| 344 | - The **recovery code alone is full takeover**, through the online |
| 345 | recovery flow. It is a bearer credential for the entire vault. |
| 346 | |
| 347 | One is a setup aid; the other is the crown jewel. And the failure we are |
| 348 | actually guarding against is not printing, it is **storage** β people save |
| 349 | PDFs, and one file means both secrets share a folder forever. A fold line |
| 350 | does nothing about that. Two files can go to two places, physically and |
| 351 | digitally. |
| 352 | |
| 353 | **Document 1 β the Account Key card.** Instruction: **store somewhere |
| 354 | safe.** Wallet-sized and meant to stay reachable, because a new device |
| 355 | asks for it β but looked after, not left about. It carries the 128-bit key |
| 356 | as `A1-XXXXX-β¦` in Crockford Base32, its QR, the account's blind-index |
| 357 | handle, and one line saying it opens nothing without the passphrase. |
| 358 | Printing a second copy for another location is fine. |
| 359 | |
| 360 | **Document 2 β the Recovery sheet.** Instruction: **store securely.** |
| 361 | Archival, and the higher bar of the two: the 128-bit recovery code as |
| 362 | `C1-XXXXX-β¦`, its QR, the same handle, the date, and plain language saying |
| 363 | this sheet alone can open the vault, that nobody can reissue it, and that |
| 364 | it should never be photographed. |
| 365 | |
| 366 | **The copy must not lean on the adjectives.** "Store securely" and "store |
| 367 | somewhere safe" are near-synonyms in ordinary English, and a reader will |
| 368 | not derive two different standards of care from them. The words set the |
| 369 | register; **concrete places and behaviours do the actual work**, and the |
| 370 | final copy carries both: |
| 371 | |
| 372 | - *Recovery sheet* β "Keep it where you'd keep a passport or a will: a |
| 373 | safe, a deposit box, a trusted person's house. Never photograph it. You |
| 374 | should not need to touch this again." |
| 375 | - *Account Key card* β "Keep it where you can find it and nobody else |
| 376 | goes β a drawer, a wallet, a folder with your documents. It is useless |
| 377 | to anyone who does not also know your passphrase." |
| 378 | |
| 379 | **Do not encourage photographing the Account Key card.** An earlier draft |
| 380 | did, reasoning from its blast radius: alone it opens nothing. But "less |
| 381 | catastrophic" is not "harmless" β it is the second factor protecting the |
| 382 | at-rest copy, and passphrases get phished. Steering people to put it in a |
| 383 | cloud photo library would quietly undo the protection the Account Key |
| 384 | exists to provide. The QR is for scanning at setup, not for archiving to |
| 385 | a camera roll. |
| 386 | |
| 387 | Signup generates both and offers a single **print both** action, so it |
| 388 | stays one gesture, and does not let the user past until both are confirmed. |
| 389 | |
| 390 | ### The UX ladder for a fresh device |
| 391 | |
| 392 | The Account Key's whole cost is that a new device needs it. Every screen |
| 393 | that needs one offers all available rungs and **never dead-ends**, cheapest |
| 394 | first: |
| 395 | |
| 396 | 1. **Handoff from an enrolled device.** The new browser mints an ephemeral |
| 397 | keypair and posts the public half; the server shows a short code; an |
| 398 | already-enrolled device confirms it and seals the Account Key to that |
| 399 | ephemeral key. The server relays a blob it cannot open β hashed code, |
| 400 | single use, ten minutes. This is Slopbox's `link` pattern and we reuse |
| 401 | its shape rather than inventing one. |
| 402 | 2. **Scan the QR** on the kit with the device's camera. |
| 403 | 3. **Type it**, Crockford Base32 with the self-healing checksum, as the |
| 404 | last resort. |
| 405 | |
| 406 | Once a device holds the Account Key it is cached locally, and from then on |
| 407 | **the passphrase alone unlocks that device** β which is the everyday case |
| 408 | and is unchanged from the original promise. |
| 409 | |
| 410 | ### Reprinting |
| 411 | |
| 412 | If either document is lost while at least one device is still enrolled, |
| 413 | the user must be able to produce a new one, and the flow must be obvious |
| 414 | rather than buried. The two reprint very differently: |
| 415 | |
| 416 | - The **Account Key can be reprinted exactly**, because enrolled devices |
| 417 | cache it. |
| 418 | - The **recovery code cannot** β only its wrap is stored, never the value. |
| 419 | Reprinting therefore *regenerates* it: a new code, a new `W_rc`, the old |
| 420 | wrap deleted. The old printed code stops working, and the copy says so |
| 421 | before the user commits. |
| 422 | |
| 423 | ### The loss modes, stated plainly |
| 424 | |
| 425 | | Situation | Outcome | |
| 426 | |---|---| |
| 427 | | Passphrase, on an enrolled device | Fine β the everyday case | |
| 428 | | Passphrase, fresh device, kit to hand | Fine β scan or type the Account Key | |
| 429 | | Passphrase, fresh device, no kit, but another device still enrolled | Fine β handoff | |
| 430 | | Passphrase forgotten, kit to hand | Fine β the recovery code | |
| 431 | | Kit lost, passphrase known, a device still enrolled | Fine β and the app should push hard to reprint | |
| 432 | | **Passphrase known, kit lost, every device gone** | **Data is gone.** New, and caused by this decision | |
| 433 | | Kit lost and passphrase forgotten | Data is gone β unchanged | |
| 434 | |
| 435 | **The last two rows are not a price; they are the product.** A vault the |
| 436 | operator cannot open is a vault nobody can reopen for you β that is the |
| 437 | entire promise, and applying it without exception is the rule working |
| 438 | rather than a cost of the Account Key. |
| 439 | |
| 440 | What the sixth row *does* change is a feeling, not a rule. "I forgot my |
| 441 | passphrase" and "I know my passphrase and lost the paper" land very |
| 442 | differently on a person even though the principle is identical. So the |
| 443 | obligation is not to soften the rule, it is to make sure **nobody arrives |
| 444 | there by accident** β the prominent reprint flow, and the nag to enrol a |
| 445 | second device. The README states the rule plainly and does not apologise |
| 446 | for it. |
| 447 | |
| 448 | It also retires a promise that was never quite true once the Account Key |
| 449 | existed: *"your passphrase or your codes get you back in."* The honest |
| 450 | model is simpler. **The kit is the root; the passphrase is the everyday |
| 451 | convenience.** |
| 452 | |
| 453 | ## Further rulings from the review, adopted |
| 454 | |
| 455 | - **Optional sign-in 2FA** (TOTP, or the passkey assertion itself). It |
| 456 | gates blob release, touches no key material, and turns "the passphrase |
| 457 | alone" from full takeover into a stop at the door. Cheap and |
| 458 | A1-compatible. |
| 459 | - **Move the email pepper off the instance** β to the routing layer or a |
| 460 | KMS β so a DB-plus-bucket leak alone cannot unblind addresses. |
| 461 | - **Quantise ciphertext sizes**: pad items into 256-byte buckets. A1 |
| 462 | tolerates size residue; that is no reason to hand over item-length |
| 463 | fingerprints for free. |
| 464 | - **The sharing graph is at-rest metadata.** Wrap rows reveal who shares |
| 465 | with whom, even under A1's letter. This goes in the published model. |
| 466 | Hiding it (per-vault pseudonymous member slots) is possible and is not |
| 467 | worth the complexity now β recorded so the choice is visible rather than |
| 468 | accidental. |
| 469 | - **Serve the web console from a separate, static, integrity-protected |
| 470 | origin** β signed releases, SRI, ideally an installable PWA or |
| 471 | extension β and say plainly that A1 for browser sessions means at-rest |
| 472 | and honest-but-curious, not actively-malicious-during-login. Native apps |
| 473 | with signed binaries genuinely meet the stronger claim. |
| 474 | |