carlos / contra Public

Ledger · web/signup-steps

Human

Should we be using https://escodb.github.io/docs/intro.html ?

Human

Can you check with a Fable agent about whether we should open this?

Human

Why don't we consider a dumb blob store? Wouldn't that be better?

Human

That's exactly how the ~/github.com/elevenmessenger/messenger home app works and it makes it much more scaleable

Human

Have Fable also reevaluate on that grounds. We're only 1 day into this project and there are no real vaults yet. We can decide to move to a stronger posture within the architecutre with no penalty.

Human

Shared vaults are a _requirement_ for V1. We'll be moving Eleven secrets to this app as soon as it's ready, and those need to be shared on day one.

Human

<task-notification> <task-id>ac767d6196a244c55</task-id> <tool-use-id>toolu_01VZ9BCbz6SwsHKQRszDgmKS</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-contra/ad0bdb88-8c0a-407a-8704-18d9e48389ca/tasks/ac767d6196a244c55.output</output-file> <status>completed</status> <summary>Agent "Second opinion on committing AEAD" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**Recommendation: DON'T open the AEAD proposal.** Narrower third option worth a few lines, below. ## 1. Is the KCV sound key commitment for this threat model? Yes, for every place it exists, and the places it doesn't exist don't need it. What "not key-committing" actually enables is one thing: a ciphertext that opens under more than one key, exploited either (a) as a partitioning oracle — an attacker crafts a k-key ciphertext, submits it to a decryptor, and learns which of k candidate keys is real from one query — or (b) as an invisible salamander — one ciphertext shows two different plaintexts to two honest holders of two different keys. Both require the attacker to *know or enumerate* the keys the victim will open under. Walk the compromise matrix in `DESIGN.md` against that: - **W_rc, W_prf:** KEK from 128- and 256-bit full-entropy inputs. No candidate set exists. No attack with or without commitment. - **W_pw:** the only branch with a dictionary, and the dictionary is only usable by someone holding the Account Key, which the server never sees. Even granting that, the KCV closes it: the client in `internal/contra/static/crypto/vaultkeys.js:70` checks `HMAC(KEK)[..16]` *before* decrypting, and a single 16-byte HMAC output matches exactly one candidate KEK. A crafted k-key GCM ciphertext gets past the KCV for at most one key, so one oracle query tests one guess — identical to what a committing AEAD gives. And the "oracle" is the user typing their passphrase at ~1 s of Argon2id each; an attacker with the Account Key would do the offline attack instead. - **Account keyset, vault keysets, items, and the future ECDH member wraps** carry no KCV. Nobody but the client ever holds or can enumerate RK, K_vault or the ECDH-derived key, so a crafted multi-key ciphertext cannot be built. - **Salamanders in shared vaults:** every member opens an item under the *same* K_vault, so there is no "one ciphertext, two keys" pair. The only holder of two keys (old and new K_vault mid-rotation) is the vault owner — who is the instance operator under decision 1 and can already serve different bytes to different members. Committing AEAD does not stop a server serving A and B different ciphertexts; nothing short of a transparency log does. The honest qualitative difference: a committing AEAD puts the commitment *in the bytes*; CONTRA's is a property of the *decryption procedure* (check KCV, then open), enforced by co-location, not by AAD or tag. A second implementation that skips the check loses it. Since no attack is enabled even then, that is discipline, not risk. Truncating to 128 bits gives 2^64 collisions only to an attacker who chooses both keys — and such an attacker has no honest victim. The framing in `DESIGN.md` ("AES-GCM is not key-committing, so the per-wrap KCV is the commitment check") is backwards in emphasis: the KCV's load-bearing job is key confirmation (wrong key vs corrupted blob; the PRF-mismatch detector). Commitment is a side effect that happens to be complete for wrap rows. ## 2. Does publishing the KCV weaken anything? No. Testing a candidate KEK via the KCV costs ~4 SHA-256 compressions; via the ciphertext it costs an AES key schedule plus ~4 AES blocks and a short GHASH. Both are ~10^-9 of the Argon2id call (pw branch) or face 2^128 (rc, prf). Equivalent 128-bit verifiers. One genuine nit, not a weakness: the KCV is unsalted, so two wrap rows with equal KEKs have equal KCVs. The only way that happens is two `W_prf` rows from providers returning the same PRF — the server learns "these two providers agree", which the random nonces would otherwise hide. Trivial residue; salting with the wrap id would remove it but changes vectors for nothing. Note it, don't act on it. I agree the Account Key makes W_pw infeasible offline; the compromise matrix's claim holds. ## 3. 96-bit random nonces Non-issue by many orders of magnitude. Collision probability is ~n²/2^97 per key. K_vault is the only key that sees volume: at 10^4 writes, ~6×10^-22; at 10^6, ~6×10^-18. NIST's 2^32-per-key guidance is four billion item writes per vault, and vault rotation resets the count. Wrap KEKs see one message each. The only realistic nonce-reuse path is RNG failure (a VM snapshot restored under the daemon). XAES does not address that — it widens the nonce, it doesn't fix the RNG. AES-GCM-SIV would, and it is in neither WebCrypto nor CryptoKit, so it isn't on the table. XAES-256-GCM would also cost a hand-rolled AES-CMAC in Go, a raw AES block emulated via AES-CBC-zero-IV in WebCrypto, and CommonCrypto in Swift, plus new vector cases. That is real portability cost to solve a problem that does not exist. ## 4. If a change were warranted, the minimum The thing you'd reach for — "derive both the AEAD key and a commitment from one secret via HKDF" — is what the code already does. `KCV = HMAC-SHA-256(KEK, label)[..16]` is `HKDF-Expand(KEK, label)` minus a counter byte, i.e. the Albertini et al. key-derivation commitment. Purist tweak: derive `K_enc` and `K_com` from KEK under two labels instead of using KEK as both the GCM key and the HMAC key. Cosmetic; no known interaction between AES and HMAC-SHA-256 under a shared HKDF-output key. If a reviewer ever insists on commitment for items/keysets, the correct tool is the **padding fix** — prefix 32 zero bytes to the plaintext, check them on open — not XAES and not encrypt-then-MAC. It is pure plaintext bytes, so Go/WebCrypto/CryptoKit GCM stay untouched, it is ~5 lines in `pad.go`/`sealed.js`, and it gives ~2^-256 commitment under GCM. Encrypt-then-MAC doubles the tag, adds a key derivation, and eats the 300-line budget for nothing. ## 5. Timing The premise is half wrong: the crypto contract *is* pinned — `vectors/README.md` says "changing a value in this file is changing the protocol", and discussion 19 pins the wire formats. What's unset is the JSON envelope. Timing is still the strongest argument FOR (a format bump plus re-wrap-on-unlock on three platforms is the future price), but the AGENTS.md test is "new facts": Albertini 2020, Len–Grubbs–Ristenpart 2021 and XAES 2024 all predate `DESIGN.md`, which explicitly names the non-committing property. There is no new fact; there is a re-read. That is a discussion, not a proposal. ## The two strongest arguments - **For:** it is free now and expensive after the first real vault, and the design text itself flags AES-GCM's non-commitment as a concern, so it should be either closed or fixed on the record. - **Against (decisive):** nobody in the compromise matrix can choose or enumerate a key any CONTRA ciphertext is opened under. Every key-commitment attack requires exactly that. The one branch with a dictionary (`wrap.pw`) is gated on the Account Key *and* already committed by the KCV at the same 128-bit strength a committing tag would give. ## The narrower third option A docs-only edit, dated and attributed: reword the KCV line in `DESIGN.md` so key confirmation is stated as the primary job and commitment as what it also delivers on wrap rows; state that keysets and items have no commitment and need none because no adversary can choose their keys; and record "committing AEAD / XAES considered and rejected" with the reasoning above so it doesn't get relitigated. Optionally note in `vectors/README.md` that the KCV check is part of Open on every platform, not a UX nicety.</result> <usage><subagent_tokens>85969</subagent_tokens><tool_uses>10</tool_uses><duration_ms>227054</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>ac87fcf71f3fe46e1</task-id> <tool-use-id>toolu_01Jo6yvV82CYr5WokFEsTHhN</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-contra/ad0bdb88-8c0a-407a-8704-18d9e48389ca/tasks/ac87fcf71f3fe46e1.output</output-file> <status>completed</status> <summary>Agent "Evaluate stronger storage posture" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Review complete. I read everything listed plus Eleven's `store.go`, `merge.go`, `stateless.go`, `email.go`, `s3store.go`, `web/static/crypto.js`, `docs/accounts-and-recovery.md`, and CONTRA's `auth.go`, `signup.go`, `app.go`, `items.js`, `passkey.js`, `kit.js`, `pad.go`, `aead.go`. No files were modified. # Verdict up front The framing overstates Eleven. On identity the two systems are the same posture; on integrity CONTRA is stronger; on the blob shape Eleven is stronger only because its problem is tiny and single-writer. Per-item rows are the right v1 shape for CONTRA, but the item write path has a correctness bug that becomes a data-loss bug the day a second device or member writes, and a handful of cheap Eleven habits are worth lifting now. # Corrections to the framing **Eleven is not identifier-free.** `home/main.go`'s package comment ("no email, no username") is stale. `home/db.go` declares `email_anchors`, `siwa_anchors`, and `oidc_anchors`, each an HMAC under a separate env-held pepper; `docs/accounts-and-recovery.md` (2026-07-16) explicitly reversed "no email ever" after real lockouts. Eleven therefore has the pepper problem four times over, and its "always send a code, unknown addresses too" door is the same oracle-suppression trick as CONTRA's decoy salt. There is no stronger identity posture to adopt. **Eleven leaks write timing too.** `blobs(user_id, version, data, updated_at)` keeps the last 5 versions with timestamps. The single blob hides item count and which-item, not when-written. **Eleven binds no AAD.** `encryptBytes` is bare `AES-GCM(iv, ct)`; the envelope carries no user, object, or version binding, and its ETag is server-chosen. CONTRA's length-prefixed, domain-tagged AAD (`crypto/aead.go`) is strictly better. Do not trade it away. **Eleven's blob works because of two facts CONTRA lacks:** the payload is a server list under 16 KiB, and the only concurrent writers are one person's devices, with LWW-per-URL merge that the doc admits "can drop a concurrent reorder". # The hard trade: one blob per vault **What per-item rows leak today** (not written down anywhere): exact item count per vault (row count); per-item `created_at`/`updated_at` columns in the replicated file; item size class; and in transport, which item id a write touched. Reads leak nothing per item, because `/api/items` returns the whole vault and there is no per-item GET. Keep that property. **What one blob would hide:** count, per-item timing, which-item. **What it would not hide:** vault write timing, and vault size unless fixed-padded, which is impossible for a password vault (1PUX carries attachments; 16 KiB is roughly 30 items). Size classes would leak coarse count anyway. **Why it is incompatible with v1, independent of concurrency:** - Drop-box writes (`DESIGN.md`: ECIES-seal to `vP_j`, "re-encrypted under `K_vault` by the next writer who is unlocked") and agent create-only staging (AGENTS decision 5: "sealing to a public key needs no unlock") are writes by a party that cannot open the vault. They cannot produce the next blob. So "one blob" is really "one blob plus an inbox table", and the inbox has per-row residue for the most sensitive class of item: captured, unreviewed secrets. - Bandwidth: every edit uploads the whole vault, and every member's every poll downloads it. An iOS AutoFill extension under a memory budget would have to open the whole vault to fill one password. **The conflict-storm concern is overstated.** This is not EscoDB's problem. EscoDB has to give atomic multi-key updates over arbitrary keys on dumb storage. CONTRA's records are independent, have random ids and per-item versions, and are written a few times a day. Union-by-id, max-version-wins, tombstones for deletes converges in a CAS retry loop with no human involvement; the only case needing policy is two edits to the same item inside one RTT, and a password manager wants "keep both versions" there anyway. So the cost of one blob is bandwidth and the inbox, not merging. **Middle grounds:** - Per-member blob: reject. Items are shared, so N copies to reconcile, revocation multiplies, and membership is still visible. - Per-vault shards keyed by `hash(item_id) mod K`: A1-clean (item ids are random). Hides which-item to 1/K, count to per-shard size class. Costs resharding on growth, K-fold padding, and the inbox problem remains. Not v1. - Append-only sealed segments plus periodic snapshot: each write is a fixed-padded segment holding one or more item changes; segments sealed under `K_vault` and segments sealed to `vP_j` (drop-box, agent) are the same table, so the inbox stops being a separate residue class; appends need no CAS, only compaction does; the write count is the only residue and that is transport shape already. This is the honest v2 shape. It costs snapshot-plus-tail reads, compaction logic, and history semantics. Not before the protocol is proven once (decision 6). **Verdict:** keep per-item rows for v1. Adopt the four cheap changes below (CAS, epoch, timestamps, floor) and keep the wire API segment-compatible (batch writes, no per-item GET). # Ranked changes to adopt now Every one of these is compatible with v1 shared vaults; the first two are required by it. **1. Compare-and-swap on item writes.** `store.UpdateItem` (`internal/contra/store/store.go:345`) is `WHERE id = ? AND vault_id = ?` and writes whatever version the client sends. No precondition. Two devices editing the same item lose one edit silently; two members, likewise; and a client can reuse a version number, which undercuts the AAD's own anti-relabel intent. Fix: `WHERE id = ? AND vault_id = ? AND version = ?` with expected = new minus 1, zero rows affected returns 409 carrying the current version, exactly Eleven's `putBlob` contract applied per item. Same on delete. Buys correctness under any concurrency and scopes every conflict to one item. Costs a small client retry-and-merge path in `items.js`. **2. Vault epoch in the AAD, eager rotation in one transaction.** Add an epoch to the vault row and to the item AAD, `(vault_id, epoch, "item", item_id, version)`. Member removal becomes one endpoint: all items re-sealed under `K_vault'`, all member wraps reissued, the removed row deleted, applied in one SQLite transaction with CAS on the epoch; concurrent writers get 409, refetch the keyset, retry. Re-encrypting N rows versus one blob is the same bytes and negligible CPU; atomicity is the only thing that matters and per-item can have it. This also settles a wording conflict: AGENTS decision 1 says the key "rotates forward for new and changed items" (lazy), `DESIGN.md` says "items re-encrypted, run automatically on member removal" (eager). Lazy needs a per-row key-generation column, which leaks "changed since the removal". Eager is better on residue; reconcile the text to eager. **3. Drop `CreatedAt`/`UpdatedAt` from `Item` and order by id.** The server never needs them; the client sorts by sealed title. Do the same for `VaultMember.CreatedAt` (when sharing began) and `LoginChallenge.CreatedAt` (redundant with `ExpiresAt`). Then write item count, write timing, and which-item into the compromise matrix as at-rest and transport residue. Zero cost. **4. Padding floor.** 256-byte buckets on a JSON body of roughly 150 to 500 bytes give two or three classes that separate "login" from "login with notes" from "secure note". Eleven's real property is that the common case is uniform. Get it with `max(1024, next 256 multiple)`: nearly every login item becomes exactly 1 KiB + tag, and only notes and attachments stand out; use power-of-two buckets above the floor. Four times the bytes on small items is irrelevant (5000 items = 5 MiB). Change `pad.go`, `items.js`, `IsBucketAligned`, and the golden vectors. Attachments will need their own object type and padding rule; the 1 MiB `MaxBytesReader` in `vault.go` caps them today. **5. Batch write endpoint.** `PUT /api/items` taking a list. Import needs it (a 1PUX of 2000 items is 2000 requests today), it hides which-of-these at transport, and it is the shape a segment format would keep. **6. Fresh-auth gate on access-reducing operations.** Eleven's asymmetry: a bearer session reads and writes; anything that reduces who can get in requires a live ceremony inside 5 minutes. CONTRA gates delete item, and will gate remove member, delete vault, and remove passkey wrap, on the session alone. rastrillo's `sessions.Session` carries `AuthTime`; require it within N minutes or a fresh signed challenge. **7. Tombstones and one retained previous version, purge ceremony-gated.** A stolen browser session can currently delete every item with no product-level history. Eleven keeps 5 blob versions for exactly this. Residue cost: edit and delete counts become visible; that is the class Eleven accepts. Say so in the matrix. **8. Rate limiting and per-address send gap.** There is none anywhere in `internal/contra`. `/auth/begin` sends mail on every request; `/auth/salt` and `/auth/challenge` do a DB lookup and Argon2-parameter response for anyone. Eleven has a per-IP limiter on begin endpoints and a 30-second per-address gap. Abuse, cost, and the existence-oracle claims only hold under timing analysis if volume is capped. **9. Sign in by account id, not email.** The index is used for four things: signup dedupe, binding `Complete` to the proved address, finding the account at sign-in, and (per `DESIGN.md`) emailing the address on recovery. The second can be bound by the token alone. The third can use the account id: a fresh device needs the Account Key card anyway (decision 2), the card's QR can carry the full id alongside the key (today `kit.js` prints an 8-hex-char handle, 32 bits, not an identifier), and enrolled devices already cache by account id (`account.js:131`). Then `/auth/salt` and `/auth/challenge` take an account id, the decoy-salt machinery goes away (random 128-bit ids need no existence defence), and the pepper leaves the sign-in hot path entirely. What the index still buys is dedupe and the recovery-notification email; note that the notification is unimplementable as written, because the server holds only an HMAC and cannot mail it. It works only if recovery is initiated by typing the address (Eleven's transient-use pattern). Owner's call: keep the index for those two (my recommendation, with the pepper off-instance as already ruled), or drop it and accept duplicate accounts (Eleven needed account merge for that) and device-only notification. Related: "passkey for quick sign-in" (decision 3) is not built. There is no WebAuthn credential stored, no assertion verified, and no passkey sign-in route; `passkey.js` only adds a PRF wrap inside an existing passphrase session. A discoverable-credential login is the identifier-free door, and when it lands the credential public key is a new column that needs its canary. **10. Caps.** `AddPasskey` accepts unbounded wraps per account; add a per-credential and per-account cap. Add a per-vault item quota for the free rung (decision 9). **11. Optional: stateless challenges.** HMAC-signed challenge tokens would retire `login_challenges` from the replicated file the way Eleven retired `ceremony_proofs`. The trade: single-use goes away within the TTL. Rows are already deleted on spend, so the gain is WAL and free-page residue only. Low priority. # Reject - **One blob per vault as the storage model.** Drop-box and agent writers cannot produce it; whole-vault bandwidth per edit; no partial sync for AutoFill; attachments break fixed padding. - **Fixed 16 KiB.** Wrong scale for this data. Use a floor plus buckets. - **One blob per member.** N copies of shared data; revocation multiplies; membership still visible. - **"The credential is the identity."** Decision 3 forbids it, and Eleven itself abandoned it after a week of lockouts. - **Lazy per-item key rotation.** Needs a key-generation column that leaks change-since-removal. - **Per-vault pseudonymous member slots.** Already rejected in `DESIGN.md`; a blob would not change it, since member wrap rows exist either way. # Already right - Per-item rows with a whole-vault list read and no per-item GET. - AAD binding, client-minted ids bound before the server sees ciphertext, server-enforced quantisation (Eleven only caps size). - Sharing graph published as accepted residue. - `W_pw` and `W_rc` never removed is Eleven's last-method guard by construction. - The blind-index posture equals Eleven's actual posture. - 404-not-403 and the owner filter in SQL. # Two things to write down **Rollback.** `DESIGN.md` and the comments in `store.go` and `items.js` say the version in the AAD stops rollback. It stops relabelling. A server that serves the old `(version, nonce, ciphertext)` triple consistently produces a blob that opens. Detecting rollback needs client-side memory of the highest version seen per item (or a vault head), and for multi-device a sealed manifest. Eleven has the same gap, worse. Do not claim rollback protection in copy until that exists; it is post-v1. **The framework's sessions table.** `TestMercilessGreps` scopes the DDL check to CONTRA's own tables by design. rastrillo's sessions table lives in the same replicated file and holds sign-in timing per account, which is transport shape at rest. Add it to the compromise matrix.</result> <usage><subagent_tokens>179724</subagent_tokens><tool_uses>31</tool_uses><duration_ms>429138</duration_ms></usage> </task-notification>