carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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

KEKpw = HKDF-SHA-256(Spw, 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.