# 🤖 PROTOCOL — CONTRA's cryptographic protocol and key hierarchy

Renamed from `DESIGN.md` on 2026-09-04. It was never a design document in
the visual sense, and the name was in the way: `DESIGN.md` is the
conventional home for a project's visual system, and this file is the wire
format. They are now separate files with separate jobs.

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.

## 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, 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)

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.
- **The per-wrap KCV is a key *confirmation* check first.** Its everyday
  job is distinguishing "wrong key" from "corrupted blob" — a distinction
  the unlock paths need constantly, and the whole of the passkey
  PRF-mismatch detector below. AES-GCM is also not key-committing, and the
  KCV supplies that too as a side effect, on exactly the rows that need
  it. Earlier drafts had this the other way round, which made the
  commitment sound load-bearing and the confirmation incidental.

  `HMAC-SHA-256(KEK, label)[..16]` **is** `HKDF-Expand(KEK, label)` minus
  a counter byte — that is, the Albertini et al. key-derivation
  commitment, not a hand-rolled substitute for one.

  Keysets and items carry no KCV and need none: nobody but the client can
  hold or enumerate RK, `K_vault`, or an ECDH-derived key, so a multi-key
  ciphertext cannot be built against them. Every key-commitment attack —
  partitioning oracles, invisible salamanders — requires the attacker to
  choose or enumerate the keys the victim opens under, and nobody in the
  compromise matrix below can. `W_pw` is the only branch with a dictionary
  at all, it is gated on the Account Key, and the KCV is checked *before*
  decryption, so a crafted multi-key ciphertext gets past it for at most
  one key.

  **Committing AEAD and XAES-256-GCM: considered and rejected**
  (2026-09-02, discussion 22). The property is already present where it is
  needed; the commitment here is a property of the decryption *procedure*
  rather than of the bytes, so a second implementation that skips the KCV
  check loses it — that is discipline to enforce, not a risk being
  carried. If commitment is ever wanted for items, the tool is 32 zero
  bytes prefixed to the plaintext, not a cipher change. Reopening this
  wants a new fact; Albertini 2020, Len–Grubbs–Ristenpart 2021 and XAES
  2024 all predate this document, which already named the non-committing
  property.

  Nit, recorded and not acted on: the KCV is unsalted, so two `W_prf` rows
  with equal KEKs have equal KCVs — telling the server that two providers
  return the same PRF, which the random nonces would otherwise hide.
  Salting with the wrap id removes it and changes the golden vectors.

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

  **Eager, not lazy, and this is the resolution of a conflict** (2026-09-02,
  discussion 23). `AGENTS.md` decision 1 says the key "rotates forward for
  new and changed items", which reads as lazy; this document says items are
  re-encrypted on removal, which is eager. Eager wins, for a residue
  reason rather than a performance one: lazy needs a per-row
  key-generation column, and that column tells the operator which items
  changed since a member was removed. Re-encrypting N rows is the same
  bytes as rewriting one blob and the CPU is negligible; what matters is
  that it is **one transaction**, guarded by a compare-and-swap on the
  vault epoch, so a concurrent writer gets a conflict rather than writing
  an item under the key that was just revoked.
- **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 | **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 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 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 |
| **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 |

### At-rest and transport residue, written down (2026-09-02, discussion 23)

The rows above say what an attacker gets from *keys*. These are what the
stored bytes give up regardless, and they were true before they were
written here, which is the reason for writing them here.

- **Item count per vault**, from the row count. One blob per vault would
  hide it; that is rejected for v1 in the same discussion, because
  drop-box and agent writers cannot produce the next blob and "one blob"
  becomes "one blob plus an inbox table" whose rows are the most sensitive
  class of item there is — captured, unreviewed secrets.
- **Per-item write timing and size class.** Reads leak nothing per item:
  `/api/items` returns the whole vault and there is no per-item GET. That
  property is deliberate and worth keeping.
- **Which item a write touched**, in transport.
- **Sign-in timing per account**, from the framework's `sessions` table,
  which lives in the same replicated file. The merciless greps scope their
  DDL check to CONTRA's own tables by design, so this is not a test gap —
  but it is residue and belongs here.
- **The sharing graph** — which accounts share a vault, never what is in
  it. Already published in the README's trade-offs.

**Rollback is not prevented, and nothing here should be read as claiming
it is.** The version bound into each item's AAD stops *relabelling*: a
server cannot serve ciphertext `v3` as `v7`. It does not stop a server
consistently serving the whole older `(version, nonce, ciphertext)` triple,
which opens correctly because it is genuinely an older authentic item.
Detecting that needs client-side memory of the highest version seen per
item, and for multiple devices a sealed manifest. Post-v1; until it
exists, no copy may claim rollback protection.

## The Emergency Kit — settled (Paul, 2026-09-01)

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

### 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, 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, and the reason changed when the kit became
two documents. The recovery code alone still reaches RK.

The tempting move is to gate recovery on the Account Key too, now that the
two live apart — defence in depth, and no longer defeated by one
photograph. **Rejected, because of which document survives what.** The
Account Key card is the *handy* one: it lives with your devices, because
that is where it gets used. The Recovery sheet is the *archived* one,
kept somewhere else precisely so it outlives a disaster at home. A fire, a
burglary or a lost bag that takes your devices very likely takes the card
sitting beside them — and gating recovery on the card would make the
archived sheet useless in exactly the scenario it exists for.

So the Recovery sheet must be sufficient on its own. What protects it is
not a second factor but its handling — archival storage, never
photographed — and the notify-every-device-and-delay policy on the online
recovery flow.

### What the kit is — two documents, not two panels

**Two separate PDFs, both generated in the browser at signup, neither ever
touching the server.** An earlier draft made this one page with a fold
line; that was too weak, for a reason worth writing down.

The two values have **different blast radii**, and they are not two halves
of one secret:

- The **Account Key alone is nearly harmless.** It is one input to the
  passphrase branch and useless without the passphrase. Photographing it
  costs an attacker nothing.
- The **recovery code alone is full takeover**, through the online
  recovery flow. It is a bearer credential for the entire vault.

One is a setup aid; the other is the crown jewel. And the failure we are
actually guarding against is not printing, it is **storage** — people save
PDFs, and one file means both secrets share a folder forever. A fold line
does nothing about that. Two files can go to two places, physically and
digitally.

**Document 1 — the Account Key card.** Instruction: **store somewhere
safe.** Wallet-sized and meant to stay reachable, because a new device
asks for it — but looked after, not left about. It carries the 128-bit key
as `A1-XXXXX-…` in Crockford Base32, its QR, the account's blind-index
handle, and one line saying it opens nothing without the passphrase.
Printing a second copy for another location is fine.

**Document 2 — the Recovery sheet.** Instruction: **store securely.**
Archival, and the higher bar of the two: the 128-bit recovery code as
`C1-XXXXX-…`, its QR, the same handle, the date, and plain language saying
this sheet alone can open the vault, that nobody can reissue it, and that
it should never be photographed.

**The copy must not lean on the adjectives.** "Store securely" and "store
somewhere safe" are near-synonyms in ordinary English, and a reader will
not derive two different standards of care from them. The words set the
register; **concrete places and behaviours do the actual work**, and the
final copy carries both:

- *Recovery sheet* — "Keep it where you'd keep a passport or a will: a
  safe, a deposit box, a trusted person's house. Never photograph it. You
  should not need to touch this again."
- *Account Key card* — "Keep it where you can find it and nobody else
  goes — a drawer, a wallet, a folder with your documents. It is useless
  to anyone who does not also know your passphrase."

**Do not encourage photographing the Account Key card.** An earlier draft
did, reasoning from its blast radius: alone it opens nothing. But "less
catastrophic" is not "harmless" — it is the second factor protecting the
at-rest copy, and passphrases get phished. Steering people to put it in a
cloud photo library would quietly undo the protection the Account Key
exists to provide. The QR is for scanning at setup, not for archiving to
a camera roll.

Signup generates both and offers a single **print both** action, so it
stays one gesture, and does not let the user past until both are confirmed.

### 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 either document 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. The two reprint very differently:

- 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 last two rows are not a price; they are the product.** A vault the
operator cannot open is a vault nobody can reopen for you — that is the
entire promise, and applying it without exception is the rule working
rather than a cost of the Account Key.

What the sixth row *does* change is a feeling, not a rule. "I forgot my
passphrase" and "I know my passphrase and lost the paper" land very
differently on a person even though the principle is identical. So the
obligation is not to soften the rule, it is to make sure **nobody arrives
there by accident** — the prominent reprint flow, and the nag to enrol a
second device. The README states the rule plainly and does not apologise
for it.

It also retires a promise that was never quite true once the Account Key
existed: *"your passphrase or your codes get you back in."* The honest
model is simpler. **The kit is the root; the passphrase is the everyday
convenience.**

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