carlos / contra Public

Should CONTRA be built on EscoDB? Question open

πŸ€– Paul asked whether CONTRA should be built on EscoDB (https://escodb.github.io/docs/). Assessment, so the answer is on the record either way.

What it is

@escodb/core v0.1.0, Apache-2.0, by James Coglan. An end-to-end encrypted document store: filesystem-like keys, JSON values, over any blob store with conditional writes (S3, Dropbox, remoteStorage, localStorage). The overlap with us is real β€” its stated primary application is "storage of credentials β€” passwords, TOTP keys, configuration parameters".

It is also serious work. escodb/cryptography carries HKDF, HMAC, CMAC, PBKDF2, XAES-256-GCM and a key-committing variant of it; there is a model checker, in JavaScript and again in Rust. That is more rigour than most projects of any size.

Recommendation: no, not as our storage layer

Four reasons, roughly in order of how hard they are to argue with.

1. It is a different architecture, not a component. EscoDB says so plainly: "EscoDB is not a long-lived server process but is instead implemented as a JavaScript library. There is no central server that mediates requests." CONTRA is a rastrillo instance with an HTTP API, sessions, a blind index, owner-scoped queries and the merciless greps over a SQLite file. Adopting EscoDB does not slot into that β€” it replaces it, and takes web/1, web/2, web/3 and web/6 with it.

2. JavaScript only, against a three-language requirement. AGENTS.md: "Crypto is byte-identical in Go and JS, pinned by shared golden vectors." Plus Swift, since the Mac app is build-order item 2. EscoDB is JS (the only other language in the org is the Rust model checker), and it has no cross-language vector suite β€” so it would supply one of the three implementations and none of the machinery that keeps them honest.

3. The key hierarchy conflicts with a settled decision. EscoDB derives its password key with PBKDF2 (lib/ciphers/password.js), and its store takes a single password parameter. DESIGN.md pins Argon2id, m=64MiB, t=3, p=1, and decision 2 builds an Emergency Kit on three peer wraps β€” passphrase-plus-Account-Key, printed recovery code, passkey PRF. PBKDF2 is not memory-hard, and our whole reason for Argon2id is that the wrap sits in a replicated bucket as an offline target. Moving to EscoDB means reopening a dated, settled decision to take a weaker KDF.

4. Licensing, on the parts we would most want to read. core is Apache-2.0. escodb/cryptography and escodb/design have no licence file at all β€” all rights reserved by default. The primitives and the design prose are exactly what would be most useful to us and are the parts we cannot lift.

Maturity is a fifth, softer point: v0.1.0, and the published docs have no security chapter β€” the design book is almost entirely about concurrency (scheduling, merging, correctness rules, failure handling), with the cryptography left unspecified.

Two things worth taking anyway

Key-committing AEAD. DESIGN.md already says "AES-GCM is not key-committing, so the per-wrap KCV is the commitment check." That is us hand-rolling a commitment because the cipher does not provide one. EscoDB's kc_xaes_256_gcm is the principled version of the same idea, and its extended-nonce base (XAES-256-GCM) also removes the birthday-bound reasoning behind our 96-bit random nonces.

This is worth deciding now or not at all. It changes the wire format, and the canary currently holds nothing β€” after real data exists it is a migration rather than a choice. I have not opened it as a proposal because it reverses part of a settled design; if it is wanted, it wants its own discussion.

Their concurrency design, as reading. We have done nothing about two devices writing at once, and it is coming: web/5 puts a second device on the account. Their design book works through exactly that problem β€” conditional writes, sharding, operation scheduling, partial-failure handling β€” against the same constraint we have (a blob store with no transactions). Reading it is free even though the code is not.

The version of this where the answer is yes

If CONTRA's server were a dumb blob store rather than an instance with an API β€” no sessions, no blind index, no owner-scoped queries, the client talking straight to S3 β€” EscoDB would be a strong candidate for the entire vault layer, and it would be worth reopening the KDF decision to get the concurrency work. That is a real alternative architecture. It is not the one DESIGN.md describes, and it is a much bigger question than "should we use this library".

πŸ€– Second opinion from a Fable agent on the one thing I said was worth taking from EscoDB β€” moving to a key-committing AEAD, and possibly XAES-256-GCM.

Its recommendation: don't. It corrected me on three points and I think it is right on all three.

1. The KCV is already sound key commitment, for our threat model

Every key-commitment attack β€” partitioning oracles, invisible salamanders β€” needs the attacker to choose or enumerate the keys the victim will open under. Walking our own compromise matrix:

  • W_rc, W_prf: KEKs from 128- and 256-bit full-entropy inputs. No candidate set exists, so no attack, commitment or not.
  • W_pw: the only branch with a dictionary, and it is gated on the Account Key the server never sees. Even granting it, the client checks HMAC(KEK)[..16] before decrypting, and a 16-byte HMAC output matches exactly one candidate KEK β€” so a crafted k-key GCM ciphertext gets past it for at most one key. One oracle query tests one guess, which is what a committing AEAD gives.
  • Account keyset, vault keysets, items, and the future ECDH member wraps 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.
  • Salamanders in a shared vault: 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 is the owner mid-rotation β€” who is the instance operator under decision 1 and can already serve different bytes to different members. Committing AEAD does not fix that; nothing short of a transparency log does.

The honest difference it names: a committing AEAD puts the commitment in the bytes, ours is a property of the decryption procedure (check KCV, then open). A second implementation that skips the check loses it β€” so that is discipline to enforce, not a risk we are carrying.

It also says DESIGN.md's emphasis is backwards. The KCV's load-bearing job is key confirmation β€” wrong key versus corrupted blob, and the PRF-mismatch detector. Commitment is a side effect that happens to be complete exactly where it is needed.

2. The "minimum change" I asked about is what we already do

I asked whether there was something cheaper than XAES β€” such as deriving both the AEAD key and a commitment from one secret via HKDF. That is the code: HMAC-SHA-256(KEK, label)[..16] is HKDF-Expand(KEK, label) minus a counter byte, i.e. the Albertini et al. key-derivation commitment.

If commitment is ever wanted for items and keysets, the right tool is not XAES and not encrypt-then-MAC: prefix 32 zero bytes to the plaintext and check them on open. Pure plaintext bytes, so Go, WebCrypto and CryptoKit GCM are all untouched; about five lines in pad.go and sealed.js; ~2^-256 commitment. Worth remembering rather than doing.

3. Nonces and timing

96-bit random nonces are a non-issue here by many orders of magnitude β€” ~6Γ—10^-22 collision probability at 10^4 writes under K_vault, and rotation resets the count. The only realistic reuse path is RNG failure (a restored VM snapshot), and XAES does not fix that β€” it widens the nonce. AES-GCM-SIV would, and is in neither WebCrypto nor CryptoKit.

On timing, my premise was half wrong. I said the wire format was unset; the crypto contract is pinned β€” vectors/README.md says changing a value in that file is changing the protocol, and #19 pins the formats. What is unset is the JSON envelope. And AGENTS.md's test for reopening a settled decision is new facts: Albertini 2020, Len–Grubbs–Ristenpart 2021 and XAES 2024 all predate DESIGN.md, which already names the non-committing property explicitly. There is no new fact here, only a re-read β€” which is a discussion, not a proposal.

One nit worth recording and not acting on

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, telling the server "these two providers agree" β€” which the random nonces would otherwise hide. Salting with the wrap id removes it and changes the vectors for nothing.

Proposed close: a docs edit, not a redesign

Reword the KCV line in DESIGN.md so key confirmation is the stated primary job and commitment is what it also delivers on wrap rows; say that keysets and items have no commitment and need none, with the reason; and record "committing AEAD and XAES considered and rejected" with this reasoning so it is not relitigated. Optionally note in vectors/README.md that the KCV check is part of Open on every platform rather than a UX nicety.

Holding that edit until the second Fable review (storage posture, #23) reports β€” it touches the same section, and one dated edit beats two.