WaFine docs
Project docsResearch & decisions

R4 — E2E Encryption: Cryptographic Correctness & Browser Constraints

Historical (pre-rebrand, 2026-10): written when the product was called WaaSel and the hosts were waasel.* / bouskoura.online. The product is now WAFINE: production hosts wafine.zawia.tech, api.wafine.zawia.tech, docs.wafine.zawia.tech; bouskoura.online is the testing playground. Names and hosts below are kept as written.

Scope: openwa-oci-infra @ mo/e2e-encryption-scaffold (commit 04e3e75). Inputs read: docs/04-e2e-encryption.md §3, §4.3, §5.3; app/bff/src/infrastructure/crypto/{argon2id,blind-index,xchacha20}.ts; app/web/src/lib/{crypto,key-management}.ts; app/bff/src/index.ts; app/bff/package.json; app/web/package.json. Method: every "does this API exist" claim was checked against the running runtime (Bun 1.4.2) or an authoritative spec/doc, not inferred. Legend: [STANDARD] = established practice with a citable source · [INVENTED] = appears only in this design · UNVERIFIED = could not confirm against a primary source.


0. Executive verdict (read this first)

#QuestionVerdict
1XChaCha20-Poly1305 in native Web Crypto?No. Not in any shipping browser spec. Use libsodium-wrappers or @noble/ciphers.
2Argon2id params for interactive loginOWASP minimum is m=19 MiB, t=2, p=1; the doc's m=64 MiB, t=3, p=1 is defensible but is not the OWASP minimum and costs ~100–400 ms in-browser.
3HMAC-SHA256 blind indexRight primitive, wrong framing. It is standard practice; the design under-states frequency/confirmation leakage and its "mitigations" are mostly theatre.
4KEK rotation & recoveryRotation scheme is sound (envelope re-wrap). Recovery is incoherent: a passphrase-derived KEK cannot be recovered from a mnemonic that wraps the URK — the two paths never meet.
5Does "Bun KV" exist?No. Fabricated API. Verified Bun.KV === undefined on Bun 1.4.2. Correct replacement: Redis (Bun.redis) or a Postgres table.
6Is the design internally coherent?No — server-side KEK caching defeats the stated threat model. The doc admits this in §1 but then contradicts itself in §4.3.

The single most important finding: the design's own "honest guarantee" (§1, line 20) says the BFF can read content transiently. §4.3 then caches the unwrapped KEK in the BFF for 5 minutes. Those are consistent with each other — but both are inconsistent with the label "E2E encryption" and with the client-side-crypto section (§5.3) that claims "keys never leave the client." The design is a server-side envelope-encryption scheme with an optional client-side mode, and the naming/threat-model framing must be fixed before implementation. Details in §6.


1. XChaCha20-Poly1305 in the browser: is it in Web Crypto?

1.1 The direct answer: no

SubtleCrypto does not expose XChaCha20-Poly1305, and it does not expose even plain ChaCha20-Poly1305 in any shipping browser:

Consequence for the scaffold: the choice of libsodium-wrappers is forced if you want XChaCha20-Poly1305. There is no native path. The doc's §5.1 "Crypto: libsodium-wrappers (WASM)" is correct.

1.2 The real options and their tradeoffs

OptionXChaCha20?Bundle (gzip)AuditKey-handling ergonomics
libsodium-wrappers (in scaffold)✅ native~290 KB std / ~375 KB sumo (min+gz, JS+WASM)libsodium C core audited by Cryptography Engineering / PIA (v1.0.12–1.0.13, 2017); libsodium.js wrapper not separately auditedEasiest: crypto_aead_xchacha20poly1305_ietf_*, crypto_pwhash (Argon2id), crypto_secretstream_* all present. But crypto_pwhash_* is sumo-only.
@noble/ciphers✅ xchacha20poly1305~3 KB tree-shaken ChaCha"Audited by an independent security firm" (Cure53-class), pure-TS, 0 depsPure JS, no WASM init, no await sodium.ready. Sub-imports (@noble/ciphers/chacha.js) keep it tiny. No secretstream — you build chunking yourself.
Web Crypto AES-256-GCM❌ (AES only)0 KBNIST/FIPS, hardware-acceleratedNative, non-extractable CryptoKey handles — best key-hygiene story. But 12-byte nonce → random-nonce reuse risk at scale; no XChaCha safety margin.

Sources: libsodium-wrappers README https://github.com/jedisct1/libsodium.js · libsodium audit https://www.privateinternetaccess.com/blog/libsodium-v1-0-12-and-v1-0-13-security-assessment/ · noble-ciphers https://github.com/paulmillr/noble-ciphers · noble npm https://www.npmjs.com/package/@noble/ciphers

1.3 Recommendation

Keep libsodium-wrappers for the AEAD and secretstream — the scaffold's xchacha20.ts and crypto.ts use the right API calls, and secretstream has no noble equivalent, so a pure-noble stack would force you to hand-roll chunked media encryption (a known footgun). [STANDARD]

But fix two real defects in the scaffold:

  1. crypto_pwhash is sumo-only. app/web/src/lib/crypto.ts:89 calls sodium.crypto_pwhash(...). The default libsodium-wrappers build does not export crypto_pwhash_* — that is explicitly documented as sumo-only. Either switch the web dependency to libsodium-wrappers-sumo or move Argon2id to @noble/hashes / argon2-browser. As written, deriveKek will throw at runtime. (This is a concrete bug, not a style note.)
  2. Bundle cost is real. ~290 KB gz of WASM on the login path, plus 20–500 ms WASM instantiation. Lazy-load after first paint and preload via rel="modulepreload". If the login page's only crypto need is Argon2id, consider @noble/hashes argon2id (pure JS, tree-shakeable) and reserve libsodium for the AEAD/secretstream paths.

[UNVERIFIED] The claim that libsodium.js (the WASM wrapper) has never been separately audited is inferred from the audit scope naming only the C library v1.0.12/1.0.13; no wrapper-specific audit report was found.


2. Argon2id in a Web Worker: real parameters and real timings

2.1 What OWASP actually says (current)

OWASP Password Storage Cheat Sheet — the minimum configurations, all judged equivalent defense, differing only in CPU/RAM tradeoff:

  • m=47104 (46 MiB), t=1, p=1
  • m=19456 (19 MiB), t=2, p=1 ← the headline minimum
  • m=12288 (12 MiB), t=3, p=1
  • m=9216 (9 MiB), t=4, p=1
  • m=7168 (7 MiB), t=5, p=1

Source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html · raw: https://raw.githubusercontent.com/OWASP/CheatSheetSeries/refs/heads/master/cheatsheets/Password_Storage_Cheat_Sheet.md

OWASP also explicitly says: "Benchmark the chosen parameters on the target system" and "increasing parallelism is not a guarantee of slower password guessing." [STANDARD]

2.2 What RFC 9106 says (the normative source OWASP defers to)

  • FIRST RECOMMENDED: Argon2id, t=1, m=2 GiB, p=4 — "default setting for all environments."
  • SECOND RECOMMENDED: Argon2id, t=3, m=64 MiB, p=4 — "memory-constrained environments."

Source: https://www.rfc-editor.org/rfc/rfc9106.html · https://datatracker.ietf.org/doc/doc/draft-irtf-cfrg-argon2/13/

2.3 The design's parameters, judged

The doc §5.3 and the scaffold default to m=64 MiB, t=3, p=1 (argon2id.ts:17, crypto.ts:80-82).

  • m=64 MiB, t=3 matches RFC 9106's second recommended profile — defensible, and a reasonable choice for a browser where 2 GiB is impossible.
  • p=1 vs RFC's p=4: RFC's profile uses 4 lanes. p=1 is not wrong — OWASP's own table uses p=1 throughout — but it is inconsistent with the cited RFC profile, and the doc cites RFC 9106 in §11. Minor, but the doc should say which profile it is targeting.
  • It is NOT the OWASP minimum. The doc never claims it is; if anyone later asserts "OWASP-compliant," the minimum is 19 MiB/t=2, and 64 MiB/t=3 exceeds it. That is fine — exceeding is good. Just don't mislabel.

2.4 How long does it actually take in a browser?

Measured figures (browser WASM, not native):

Conclusion: 64 MiB/t=3 is ~100–400 ms on desktop, plausibly 0.5–2 s on a mid-range Android. That is at the upper edge of the "50–250 ms interactive" rule of thumb. [STANDARD]

Recommendations:

  1. Run it in a Web Worker — the doc says this (§5.3) and the scaffold comments say this (crypto.ts:75), but no worker exists in the scaffold. app/web/src/lib/crypto.ts calls sodium.crypto_pwhash synchronously on the main thread. This will freeze the UI for the full derivation time. This is a missing implementation, not a design flaw.
  2. Benchmark on the slowest target device (mid-range Android, not a dev laptop) before freezing parameters, per OWASP.
  3. Consider m=64 MiB, t=3, p=2 if you want to use the device's cores; but note argon2-browser and @noble/hashes do not run lanes in parallel — p>1 buys nothing there. Only libsodium-wrappers-sumo and hash-wasm benefit.
  4. Store the parameters with the wrapped key. The scaffold's deriveKek hardcodes defaults; a future parameter bump must not lock existing users out. The wrapped-key record needs a kdf_params field.

2.5 A design bug in the derivation

argon2id.ts:27-38 and crypto.ts:86-96 derive:

input = passphrase || device_secret
salt  = device_secret

Using the device secret as both the salt and part of the password input is a real weakness. If device_secret is stored on the device (it must be, for the KEK to be re-derivable), an attacker with the device file has the salt and half the input. The remaining entropy is the passphrase alone — which is exactly what Argon2id is supposed to protect, but the "device_secret" adds no cryptographic strength against a device-compromise adversary; it only adds a second factor against a remote attacker. That is a legitimate design (it is the 1Password two-secret model, cited in §11), but the doc must state the threat model: device_secret defends against server-side/remote guessing, not against device theft. As written, the code reads as if it strengthens the KDF, which it does not. [STANDARD] (1Password Security Design White Paper, cited in doc §11 — not independently re-verified here.)

Also: salt = device_secret means every device has a different salt for the same passphrase, which is correct for per-device KEK derivation but means the KEK is device-specific, not user-specific. The doc's key hierarchy (§3.1) says "Tenant KEK — stored wrapped per-device in tenant_keys," which is consistent — but then §3.4's recovery mnemonic wraps the URK, and the URK→KEK relationship is never defined in the code. See §4.


3. Blind index design: HMAC-SHA256 and its real attacks

3.1 Is HMAC-SHA256 with a separate index key the right primitive?

Yes — this is standard practice. It is the construction used by CipherSweet (Paragon Initiative) and by essentially every "searchable equality on encrypted columns" implementation. The design's formula:

blind_index = HMAC-SHA256(HKDF(IK, "idx/<col>/v1"), normalize(value))[:16]

is sound in structure:

  • Keyed (HMAC with a secret key) → an attacker without IK cannot compute the index for a guessed plaintext, which blocks offline dictionary attacks if the key is secret. [STANDARD]
  • Per-column key derivation via HKDF → prevents cross-column correlation (a phone number and a JID that happen to be equal don't collide). Good. [STANDARD]
  • Truncation to 128 bits → 2^128 collision space, fine for equality lookup; the birthday bound is irrelevant at realistic row counts. [STANDARD]
  • No server-side FTS, no OPE, no deterministic payload encryption → correct restraint. OPE is broken (Naveed et al., "Inference Attacks on Property-Preserving Encrypted Databases," CCS 2015). [STANDARD]

One correctness bug in the scaffold: blind-index.ts:49-61 implements HKDF by hand:

const prk = createHmac('sha256', Buffer.from('waasel-blind-index-v1'));
prk.update(ik);

This is HKDF-Extract with the salt and IKM arguments swapped. RFC 5869 defines HKDF-Extract(salt, IKM) = HMAC(salt, IKM). Here the literal string is the HMAC key (salt) and ik is the message (IKM) — which is actually the correct argument order for RFC 5869. So it happens to be right, but it is hand-rolled, unlabeled, and fragile. Replace with node:crypto's hkdfSync or @noble/hashes/hkdf. Also note the scaffold imports node:crypto — fine in the Bun BFF, but the same file cannot run in the browser; if blind-index computation ever needs to happen client-side (it should, for the "edge phone-resolution" open decision #2), this must be rewritten against WebCrypto HKDF or noble. [STANDARD] (RFC 5869)

3.2 The known attacks

Frequency analysis. If the same plaintext value always produces the same blind index, the distribution of index values mirrors the distribution of plaintext values. An adversary who knows the plaintext domain (e.g., Moroccan phone numbers, or a list of chat JIDs) and sees the index frequency histogram can match the most-frequent index to the most-frequent plaintext. This is the core leakage-abuse attack family.

  • The canonical formalization is the KKNO attack (Kellaris, Kollios, Nissim, O'Neill, "Generic Attacks on Secure Outsourced Databases," CCS 2016).
  • The current state of the art generalizes it: LAMa (Leakage-Abuse via Matching), Moyer et al., 2025 — reconstructs plaintext from access-pattern leakage alone, in up to four dimensions, and proves the limit of frequency analysis. https://arxiv.org/html/2508.11563v1
  • Crucially for this design: frequency analysis on a blind index requires the attacker to observe the index values and their frequencies. If the index is stored in a column the attacker can read (Supabase admin, backup leak), the attack applies directly. [STANDARD]

Confirmation attacks. An attacker who can submit a query (e.g., the search endpoint in §6.3) can test whether a guessed value is present: compute the index for the guess (if they have the key — they don't) or observe whether the query returns rows. Even without the key, an attacker who can query the BFF's search endpoint gets an oracle: "is +2126XXXXXXX in this tenant's contacts?" This is the keyword-guessing attack (KGA) applied to blind indexes. The design's §6.3 exposes exactly this oracle to any authenticated client. [STANDARD] (KGA literature, e.g. https://doi.org/10.1016/j.ins.2023.119060)

Deterministic-encryption deduplication leakage. Not applicable here (payloads are non-deterministic AEAD), but worth noting the design avoids the worst version of this by not encrypting payloads deterministically. Good. [STANDARD]

3.3 What mitigations are real vs theatre

MitigationVerdictWhy
Per-tenant + per-column key✅ RealBlocks cross-tenant and cross-column correlation. Already in the design.
Key secrecy (index key never in DB)✅ Real, and the whole pointIf IK is only ever in the BFF RAM / client, an attacker with DB read cannot compute indexes for guesses. But this is defeated by §4.3's server-side KEK cache — see §6.
Normalization (E.164, NFC)✅ RealPrevents the same number producing different indexes (which would break search). It does not reduce leakage.
Rate-limiting the search endpoint🟡 PartialRaises the cost of the KGA oracle but does not close it. Necessary, not sufficient.
Truncation to 128 bits✅ Real (collision safety)Not a leakage mitigation.
"No server-side FTS"✅ RealAvoids a much worse leakage surface.
Query-distribution flattening / dummy queries🟡 Real but expensiveFormally shown to work (Low-Cost Hiding of the Query Pattern, https://doi.org/10.1145/3433210.3453103), but requires a constant number of fake queries per real query and changes the product (you must issue decoys). Not in the design.
ORAM🟡 Real but impracticalHides access patterns fully; prohibitive for a WhatsApp-scale message store. Correctly omitted.
"Encrypt the index with a second key"❌ TheatreIf the second key is server-held, it changes nothing. If client-held, you've just moved the problem.
"Only index phone numbers and JIDs, not names"🟡 PartialReduces the domain size problem but phone numbers are a low-entropy, enumerable domain — this is the worst case for frequency analysis, not the best. The design's framing implies it is safer; it is not.

Bottom line: the primitive is right, the framing is optimistic. The design should state plainly: blind indexes leak equality and frequency; an attacker with DB read + domain knowledge can recover the most common values. The real mitigation is key secrecy + query rate-limiting + not storing the index key anywhere the DB attacker can reach — which §4.3 undermines.


4. Key rotation and recovery

4.1 Rotation: the scheme is sound

The design's envelope model (URK → tenant KEK → per-message CK/DEK) is standard envelope encryption, and the rotation property it claims is correct:

Rotating a KEK does not require re-encrypting data. You unwrap each DEK with the old KEK and re-wrap it with the new KEK. That is a key-management operation, not a data operation.

Sources: https://www.encryptionconsulting.com/envelope-encryption-kek-vs-dek-and-key-wrapping/ · https://workos.com/blog/envelope-encryption-explained · Google Cloud KMS envelope encryption https://docs.cloud.google.com/kms/docs/envelope-encryption

The design's specifics are good:

  • "Write-new → verify → retire-old after 7-day soak" — correct, matches the "all wrapped DEKs must be re-wrapped before the old KEK is retired" requirement. [STANDARD]
  • "Dual-version-tolerant: decryption tries key_version first, falls back to key_version - 1" — correct, and the key_rotation_jobs cursor table (§7) is the right shape for resumable rotation. [STANDARD]
  • One gap: the schema stores kek_version on tenant_keys and dek_version on messages/media_objects, but there is no kek_version on the wrapped-DEK rows. During a KEK rotation, a given DEK could be wrapped under either version; the decrypt path needs to know which KEK version to try, or it must try both. The doc says "tries key_version first, falls back to key_version - 1" — but key_version on a message is the DEK version, not the KEK version. These are conflated in the schema. Add a kek_version column to every row that holds a wrapped_dek.

Rotation of the URK is different and the doc does not address it: if the URK is the root of the hierarchy and is wrapped by the passphrase-derived key, rotating the URK requires re-wrapping the tenant KEK(s) under the new URK — cheap, same mechanism. Fine, but unstated.

4.2 Recovery: the design is incoherent

The doc §3.4 says:

Default: generated 256-bit recovery mnemonic (BIP39-style) wrapping the URK Enterprise opt-in: Shamir secret sharing (k-of-n) Hard mode: no recovery

And the schema (§7) has:

CREATE TABLE user_recovery (
  wrapped_urk BYTEA NOT NULL,  -- URK wrapped by recovery mnemonic
  ...
);

The problem: the KEK is derived from the passphrase, and the URK is what the mnemonic wraps — but the code never connects them. Look at the actual derivation path:

  • key-management.ts:86 — const kek = await deriveKek(passphrase, deviceSecret);
  • key-management.ts:89 — const tenantKek = await generateDek(); (random, not derived)
  • key-management.ts:93 — wraps tenantKek with the passphrase-derived kek.

So the tenant KEK is random and is wrapped by Argon2id(passphrase, deviceSecret). The URK appears in the doc's hierarchy as the root, but nowhere in the scaffold is a URK generated, stored, or used. The recovery mnemonic is supposed to wrap the URK, but the URK does not wrap the tenant KEK in any code path. The recovery chain is broken at the URK↔KEK link.

What actually happens on passphrase loss, as designed:

  1. The passphrase-derived key is gone.
  2. The tenant KEK is still in the DB, wrapped under the lost key.
  3. The recovery mnemonic wraps a URK that is not used to protect anything reachable.
  4. Result: total, unrecoverable data loss — even though the design claims a recovery path exists.

What a defensible scheme looks like (and what the doc should specify):

URK (random 256-bit, never leaves device)
  ├─ wrapped by Argon2id(passphrase, device_secret)   ← normal unlock path
  └─ wrapped by recovery_mnemonic                      ← recovery path
       │
       └─ URK wraps the tenant KEK(s)

The URK must be the thing that wraps the tenant KEK, and it must be wrapped twice — once by the passphrase-derived key, once by the recovery mnemonic. Then:

  • Passphrase change = re-wrap the URK under the new passphrase key. Cheap. (The doc says this at §3.1 line 95 — "WK is random, not derived — passphrase change is a cheap re-wrap" — but the code does the opposite: it derives the KEK directly from the passphrase, so a passphrase change does invalidate the wrapped tenant KEK and requires re-wrapping it. The doc and code disagree.)
  • Passphrase loss = unlock the URK with the mnemonic, then unwrap the tenant KEK. Works.
  • Mnemonic loss + passphrase loss = data loss. Correct and honest.

Verdict: the recovery design is not implementable as written and the scaffold does not implement it. This is a P0 correctness gap, not a polish item.

4.3 Other recovery issues

  • generateRecoveryMnemonic() (argon2id.ts:54-61) returns hex, with a TODO: Replace with proper BIP39 encoding. A 64-char hex string is a valid 256-bit secret but is not a BIP39 mnemonic and is not what §3.4 promises. [INVENTED] placeholder.
  • Shamir secret sharing is listed as "enterprise opt-in" but has no schema, no library, and no code. [INVENTED] — aspirational.
  • "Hard mode: no recovery" — fine, but must be an explicit, irreversible user choice, not a default.
  • BIP39 for a non-Bitcoin secret is a mild smell: BIP39's checksum and wordlist are designed for HD wallet seeds. Using it for a generic secret is common practice (many password managers do it) but the doc should cite why (human transcription error detection via checksum) rather than implying it is a standard for this use. [STANDARD] (BIP39, https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)

5. CRITICAL: Does "Bun KV" exist?

5.1 Verdict: NO. It is a fabricated API.

Verified directly against the running runtime (Bun 1.4.2, the version in app/bff/package.json's toolchain):

$ bun -e 'console.log("Bun.KV:", typeof Bun.KV); ...'
Bun.KV: undefined
Bun.redis: object
Bun.SQL: function
Bun.s3: object
Bun.file: function

Additional confirmation:

  • No bun:kv module exists. Grep across app/ for bun:kv / Bun.KV returns only the two comments (index.ts:25, rate-limit.ts:3) — no import, no implementation.
  • Bun's official API list does not include a KV store. It lists bun:sqlite, Bun.SQL/Bun.sql, Bun.RedisClient/Bun.redis, Bun.s3/S3Client, Bun.password, Bun.file, Bun.serve. No KV. https://bun.sh/docs/runtime/bun-apis
  • The confusion is almost certainly with Deno KV (Deno.openKv(), backed by FoundationDB on Deno Deploy, SQLite locally). Deno KV is real; Bun KV is not. https://docs.deno.com/deploy/kv/manual/ [STANDARD]

5.2 The correct replacements

NeedReal primitiveFits single-VM OCI?
KEK session cache (5-min TTL, zeroize on eviction)In-process Map with TTL — which is already what the scaffold does (index.ts:26-54)✅ Yes — and it is the correct choice. A single-VM BFF has one process; a network round-trip to Redis for a 5-minute KEK cache adds latency and a new failure mode for zero benefit.
Rate-limit token buckets (per tenant+endpoint)In-process Map (single VM) or Bun.redis / Bun.RedisClient if you ever scale to >1 BFF instance✅ In-process for single VM. Redis only if multi-instance.
Durable key/session statePostgres table (Supabase, already in the stack)✅ Yes.
Distributed cache across instancesRedis / Valkey via Bun.redis⚠️ Only if multi-instance. Adds a service to the VM.

Sources: Bun APIs https://bun.sh/docs/runtime/bun-apis · Bun data & storage https://bun.edu.rich/en/docs/core/data-and-storage · Bun Redis https://bun.com/docs/runtime/redis

5.3 Recommendation for this deployment

Production is a single OCI Ampere A1 VM (doc §8.2). Therefore:

  1. KEK cache: keep the in-memory Map. It is correct for single-VM. Fix the comment (index.ts:25), which tells a future reader to "replace with Bun KV in production" — that instruction is wrong and will be followed.
  2. Rate limiter: in-memory Map for single-VM. rate-limit.ts:3's comment ("Uses Bun KV for distributed state") is also wrong.
  3. If the BFF ever scales to >1 instance, switch both to Redis via Bun.redis — a real, built-in client, no npm dependency needed. Add a Redis service to the compose. Do not invent a KV primitive.
  4. Never put the KEK cache in a shared store (Redis or Postgres) even if you add one. A shared KEK cache means any process that can read Redis can read every tenant's unwrapped KEK. See §6.

Note: the scaffold's in-memory cache has a zeroization bug: invalidateKek calls entry.kek.fill(0) (good), but getKek on an expired entry does this.store.delete(sessionId) without zeroizing (index.ts:39-42). The KEK stays in the heap until GC. Fix: zeroize before delete on the expiry path too. (Zeroization in JS is best-effort — the GC may have already copied the buffer — but the code should still do it.)


6. Is the design internally coherent? Does server-side KEK caching defeat the threat model?

6.1 The verdict: NO — and the design contradicts itself in three places

The design contains two mutually exclusive claims:

Claim A — "E2E" / client-side crypto (§5.3, line 271-275):

"Client-side crypto — XChaCha20-Poly1305 via libsodium-wrappers. Runs in the browser; keys never leave the client."

Claim B — server-side KEK caching (§4.3, line 205, 212-213):

"Cache: short-TTL KEK cache (Bun KV, 5-min TTL, zeroized on eviction)" "BFF caches unwrapped KEK for session duration (5 min TTL, sliding)" "Subsequent requests: JWT → BFF looks up cached KEK → decrypts/encrypts"

These cannot both be true. If the BFF holds the unwrapped KEK, the BFF can decrypt everything the KEK protects. "Keys never leave the client" is false.

Claim C — the "honest guarantee" (§1, line 20):

"Supabase, OCI, and Cloudflare cannot read tenant message content, contacts, or media. The BFF can, transiently, in order to run automation — here is exactly when and for how long."

Claim C is the honest one, and it is consistent with Claim B. It is inconsistent with Claim A and with the document's title ("E2E Encryption Architecture").

6.2 Does server-side KEK caching defeat the threat model?

It depends entirely on which threat model you claim — and the doc claims two.

  • If the threat model is "protect against Supabase/OCI/Cloudflare" (Claim C): server-side KEK caching does not defeat it. The BFF is trusted; the DB and object store are not. This is a legitimate, standard architecture — it is exactly what server-side envelope encryption (Vault Transit, AWS KMS, GCP KMS) does. The KEK lives in the trusted compute boundary; the ciphertext lives outside it. [STANDARD]
  • If the threat model is "E2E, the server cannot read content" (the title, Claim A): server-side KEK caching completely defeats it. A 5-minute sliding TTL means that for any active session, the server holds the plaintext key. An attacker who compromises the BFF process (RCE, SSRF, a malicious dependency, a debug endpoint, a memory dump) gets every active tenant's KEK. The E2E guarantee is gone.

The design is coherent only under Claim C. Under Claim C it is a well-understood server-side envelope scheme, and the "E2E" label is marketing, not architecture.

6.3 The specific incoherences to fix

#IncoherenceFix
1Title says "E2E"; §4.3 caches the KEK server-sideRename. This is "server-side envelope encryption with an optional client-side mode." Reserve "E2E" for the mode where the KEK never reaches the BFF.
2§5.3 says "keys never leave the client"; §4.3 says the BFF caches the KEKPick one per mode. If client-side mode is on, the BFF must not cache the KEK — it should receive only ciphertext and opaque wrapped keys.
3§1's honest guarantee ("BFF can read, transiently") vs. the titleThe honest guarantee is the correct one. Make it the headline. The prior research report (e2e-encryption-report.md §10, quoted in GROUNDING.md) already pins this — the design doc regressed from it.
4§3.1 says "WK is random, not derived — passphrase change is a cheap re-wrap"; the code derives the KEK directly from the passphraseImplement the URK layer (§4.2 above) so the claim becomes true.
5§3.4 recovery mnemonic wraps the URK; the code never uses a URKImplement the URK, or delete the recovery claim.

6.4 The one architectural question the doc must answer

When client-side mode is enabled (open decision #8, "Yes, per-tenant"), where does decryption happen?

  • Option 1 — BFF decrypts (current design): the KEK is cached server-side. This is not E2E. It is server-side envelope encryption. The client-side crypto in §5.3 is then only for the transport layer (client encrypts to the BFF, BFF decrypts, BFF re-encrypts to storage) — which is pointless (TLS already protects transport) and adds complexity for no security gain.
  • Option 2 — Client decrypts: the BFF never sees the KEK. The BFF stores and forwards ciphertext only. The client unwraps the KEK locally (from IndexedDB), decrypts, and renders. This is E2E. But it breaks every server-side automation feature (n8n routing, Claude agents, server-side search) — which is the whole tension the prior report identified.

The design cannot have both. The doc's §4.3 chooses Option 1 while §5.3 describes Option 2. This is the central unresolved decision, and it is a product decision, not a crypto one — exactly as GROUNDING.md §4 concludes.

6.5 Additional coherence gaps found in the scaffold

  • app/bff/src/infrastructure/crypto/argon2id.ts imports argon2 (import { argon2id, argon2Verify } from 'argon2') — but argon2 is not in app/bff/package.json dependencies. The BFF will not build/run. Either add the dependency or use Bun.password (which supports Argon2id natively) — Bun's built-in is the better choice for a Bun BFF.
  • app/web/src/lib/crypto.ts calls sodium.crypto_pwhash — sumo-only, not in the default build (see §1.3). Runtime failure.
  • key-management.ts:92 does const { encrypt } = await import('./crypto.js') — a dynamic import of a module already statically imported at line 6 (generateDek, deriveKek, generateDeviceSecret, zeroize). zeroize is imported but zeroize is not defined in crypto.ts — the file exports encrypt, decrypt, generateDek, deriveKek, generateDeviceSecret. zeroize does not exist. Runtime failure on enrollDevice.
  • xchacha20.ts:44 passes null as the ad argument and aad as the second positional arg. libsodium's crypto_aead_xchacha20poly1305_ietf_encrypt(message, ad, nsec, npub, key) — the second arg is ad. The code passes aad second and null third, which is correct (third is nsec). But the comment says "additional data (we use AAD parameter)" next to the null — the comment is attached to the wrong argument. Cosmetic, but confusing.
  • wrapDek uses XChaCha20-Poly1305, not AES-256-KW. The doc §3.2 says "Key wrapping: AES-256-KW (RFC 3394) or XChaCha20-Poly1305 with random nonce." The scaffold chose XChaCha20. Both are acceptable — RFC 3394 AES-KW is the more conventional choice (NIST SP 800-38F, deterministic, no nonce) and is what KMS products use; XChaCha with a random 24-byte nonce is also fine and avoids needing an AES-KW implementation. But the doc lists AES-256-KW first and §9 Phase 1 has a task "Key wrapping/unwrapping (AES-256-KW)" — the code and the task list disagree. Pick one and make it consistent. [STANDARD] (RFC 3394, NIST SP 800-38F)

7. Summary of concrete defects (actionable)

SeverityDefectLocation
P0"Bun KV" is a fabricated API; two comments instruct future devs to use itindex.ts:25, rate-limit.ts:3, doc §4.3/§4.2/§9
P0Recovery chain broken: mnemonic wraps a URK that never wraps the KEKdoc §3.4, key-management.ts
P0zeroize imported but not exported/defined → enrollDevice throwskey-management.ts:6,107-108
P0argon2 imported but not in package.json → BFF won't runargon2id.ts:9, app/bff/package.json
P0sodium.crypto_pwhash is sumo-only → deriveKek throws on default buildcrypto.ts:89
P1E2E claim contradicts server-side KEK cachedoc §1 vs §4.3 vs §5.3
P1Argon2id runs on the main thread; no Web Worker existscrypto.ts:77-99
P1kek_version missing from wrapped-DEK rows → rotation can't select the right KEKdoc §7 schema
P1device_secret used as both salt and password input; threat model unstatedargon2id.ts:27-38, crypto.ts:86-96
P2Expired KEK cache entries deleted without zeroizationindex.ts:39-42
P2Hand-rolled HKDF (correct but fragile); node:crypto blocks browser useblind-index.ts:49-61
P2Doc says AES-256-KW; code uses XChaCha20-Poly1305 for wrappingdoc §3.2/§9 vs xchacha20.ts:87-106
P2generateRecoveryMnemonic returns hex, not BIP39argon2id.ts:54-61
P2Argon2id parameters not stored with wrapped keys → future bumps lock users outschema

8. Sources

Specs & standards

OWASP

Web Crypto

Libraries

Blind index / leakage-abuse attacks

Envelope encryption / rotation

Runtime primitives


Report compiled against commit 04e3e75, Bun 1.4.2. Runtime claims verified by direct execution; spec claims cited. Items marked UNVERIFIED were not confirmed against a primary source.

On this page