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 hostswafine.zawia.tech,api.wafine.zawia.tech,docs.wafine.zawia.tech;bouskoura.onlineis 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)
| # | Question | Verdict |
|---|---|---|
| 1 | XChaCha20-Poly1305 in native Web Crypto? | No. Not in any shipping browser spec. Use libsodium-wrappers or @noble/ciphers. |
| 2 | Argon2id params for interactive login | OWASP 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. |
| 3 | HMAC-SHA256 blind index | Right primitive, wrong framing. It is standard practice; the design under-states frequency/confirmation leakage and its "mitigations" are mostly theatre. |
| 4 | KEK rotation & recovery | Rotation 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. |
| 5 | Does "Bun KV" exist? | No. Fabricated API. Verified Bun.KV === undefined on Bun 1.4.2. Correct replacement: Redis (Bun.redis) or a Postgres table. |
| 6 | Is 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:
- The W3C WebCrypto spec's algorithm registry covers AES-CBC/CTR/GCM/KW, RSA, ECDH/ECDSA, HKDF, PBKDF2, HMAC, SHA-1/2. ChaCha is absent. https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- ChaCha20-Poly1305 is only in the proposed "Modern Algorithms" extension (
wicg.github.io/webcrypto-modern-algos), which is not implemented in browsers. Node.js added it in v24.7.0 — Node, not browsers. https://nodejs.org/api/webcrypto.html · https://wicg.github.io/webcrypto-modern-algos/ - XChaCha20 (192-bit nonce) is not in any WebCrypto proposal. It is a libsodium/draft-irtf-cfrg-xchacha construction.
[STANDARD]
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
| Option | XChaCha20? | Bundle (gzip) | Audit | Key-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 audited | Easiest: 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 deps | Pure 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 KB | NIST/FIPS, hardware-accelerated | Native, 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:
crypto_pwhashis sumo-only.app/web/src/lib/crypto.ts:89callssodium.crypto_pwhash(...). The defaultlibsodium-wrappersbuild does not exportcrypto_pwhash_*— that is explicitly documented as sumo-only. Either switch the web dependency tolibsodium-wrappers-sumoor move Argon2id to@noble/hashes/argon2-browser. As written,deriveKekwill throw at runtime. (This is a concrete bug, not a style note.)- 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/hashesargon2id (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):
- argon2-browser (the reference browser build), 1 MiB × t=100, p=1: Chrome WASM 225 ms, Firefox WASM 195 ms, Safari WASM 174 ms; native -O3 SSE 15 ms. Scaling to 64 MiB × t=3 lands in the ~100–400 ms range on desktop, worse on mobile. https://cdn.jsdelivr.net/npm/argon2-browser@1.18.0/README.md
- hash-wasm (browser-grade WASM), m=65536 KiB, t=3, p=1: ~104 ms on an Apple M5 Max.
@noble/hashes(pure JS) is ~433 ms for the same params. Native Rust: ~49 ms. https://github.com/napi-rs/node-rs/tree/main/packages/argon2 - libsodium native, m=64 MiB, t=3: ~76 ms (desktop, native). https://github.com/samuel-lucas6/benchmark-argon2-dotnet
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:
- 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.tscallssodium.crypto_pwhashsynchronously on the main thread. This will freeze the UI for the full derivation time. This is a missing implementation, not a design flaw. - Benchmark on the slowest target device (mid-range Android, not a dev laptop) before freezing parameters, per OWASP.
- Consider m=64 MiB, t=3, p=2 if you want to use the device's cores; but note
argon2-browserand@noble/hashesdo not run lanes in parallel — p>1 buys nothing there. Only libsodium-wrappers-sumo and hash-wasm benefit. - Store the parameters with the wrapped key. The scaffold's
deriveKekhardcodes defaults; a future parameter bump must not lock existing users out. The wrapped-key record needs akdf_paramsfield.
2.5 A design bug in the derivation
argon2id.ts:27-38 and crypto.ts:86-96 derive:
input = passphrase || device_secret
salt = device_secretUsing 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
IKcannot 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
| Mitigation | Verdict | Why |
|---|---|---|
| Per-tenant + per-column key | ✅ Real | Blocks cross-tenant and cross-column correlation. Already in the design. |
| Key secrecy (index key never in DB) | ✅ Real, and the whole point | If 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) | ✅ Real | Prevents the same number producing different indexes (which would break search). It does not reduce leakage. |
| Rate-limiting the search endpoint | 🟡 Partial | Raises 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" | ✅ Real | Avoids a much worse leakage surface. |
| Query-distribution flattening / dummy queries | 🟡 Real but expensive | Formally 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 impractical | Hides access patterns fully; prohibitive for a WhatsApp-scale message store. Correctly omitted. |
| "Encrypt the index with a second key" | ❌ Theatre | If 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" | 🟡 Partial | Reduces 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_versionfirst, falls back tokey_version - 1" — correct, and thekey_rotation_jobscursor table (§7) is the right shape for resumable rotation.[STANDARD] - One gap: the schema stores
kek_versionontenant_keysanddek_versiononmessages/media_objects, but there is nokek_versionon 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 "trieskey_versionfirst, falls back tokey_version - 1" — butkey_versionon a message is the DEK version, not the KEK version. These are conflated in the schema. Add akek_versioncolumn to every row that holds awrapped_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— wrapstenantKekwith the passphrase-derivedkek.
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:
- The passphrase-derived key is gone.
- The tenant KEK is still in the DB, wrapped under the lost key.
- The recovery mnemonic wraps a URK that is not used to protect anything reachable.
- 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 aTODO: 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: functionAdditional confirmation:
- No
bun:kvmodule exists. Grep acrossapp/forbun:kv/Bun.KVreturns 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
| Need | Real primitive | Fits 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 state | Postgres table (Supabase, already in the stack) | ✅ Yes. |
| Distributed cache across instances | Redis / 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:
- 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. - Rate limiter: in-memory Map for single-VM.
rate-limit.ts:3's comment ("Uses Bun KV for distributed state") is also wrong. - 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. - 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
| # | Incoherence | Fix |
|---|---|---|
| 1 | Title says "E2E"; §4.3 caches the KEK server-side | Rename. 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 KEK | Pick 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 title | The 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 passphrase | Implement the URK layer (§4.2 above) so the claim becomes true. |
| 5 | §3.4 recovery mnemonic wraps the URK; the code never uses a URK | Implement 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.tsimportsargon2(import { argon2id, argon2Verify } from 'argon2') — butargon2is not inapp/bff/package.jsondependencies. The BFF will not build/run. Either add the dependency or useBun.password(which supports Argon2id natively) — Bun's built-in is the better choice for a Bun BFF.app/web/src/lib/crypto.tscallssodium.crypto_pwhash— sumo-only, not in the default build (see §1.3). Runtime failure.key-management.ts:92doesconst { encrypt } = await import('./crypto.js')— a dynamic import of a module already statically imported at line 6 (generateDek, deriveKek, generateDeviceSecret, zeroize).zeroizeis imported butzeroizeis not defined incrypto.ts— the file exportsencrypt,decrypt,generateDek,deriveKek,generateDeviceSecret.zeroizedoes not exist. Runtime failure onenrollDevice.xchacha20.ts:44passesnullas theadargument andaadas the second positional arg. libsodium'scrypto_aead_xchacha20poly1305_ietf_encrypt(message, ad, nsec, npub, key)— the second arg isad. The code passesaadsecond andnullthird, which is correct (third isnsec). But the comment says "additional data (we use AAD parameter)" next to thenull— the comment is attached to the wrong argument. Cosmetic, but confusing.wrapDekuses 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)
| Severity | Defect | Location |
|---|---|---|
| P0 | "Bun KV" is a fabricated API; two comments instruct future devs to use it | index.ts:25, rate-limit.ts:3, doc §4.3/§4.2/§9 |
| P0 | Recovery chain broken: mnemonic wraps a URK that never wraps the KEK | doc §3.4, key-management.ts |
| P0 | zeroize imported but not exported/defined → enrollDevice throws | key-management.ts:6,107-108 |
| P0 | argon2 imported but not in package.json → BFF won't run | argon2id.ts:9, app/bff/package.json |
| P0 | sodium.crypto_pwhash is sumo-only → deriveKek throws on default build | crypto.ts:89 |
| P1 | E2E claim contradicts server-side KEK cache | doc §1 vs §4.3 vs §5.3 |
| P1 | Argon2id runs on the main thread; no Web Worker exists | crypto.ts:77-99 |
| P1 | kek_version missing from wrapped-DEK rows → rotation can't select the right KEK | doc §7 schema |
| P1 | device_secret used as both salt and password input; threat model unstated | argon2id.ts:27-38, crypto.ts:86-96 |
| P2 | Expired KEK cache entries deleted without zeroization | index.ts:39-42 |
| P2 | Hand-rolled HKDF (correct but fragile); node:crypto blocks browser use | blind-index.ts:49-61 |
| P2 | Doc says AES-256-KW; code uses XChaCha20-Poly1305 for wrapping | doc §3.2/§9 vs xchacha20.ts:87-106 |
| P2 | generateRecoveryMnemonic returns hex, not BIP39 | argon2id.ts:54-61 |
| P2 | Argon2id parameters not stored with wrapped keys → future bumps lock users out | schema |
8. Sources
Specs & standards
- RFC 9106 — Argon2: https://www.rfc-editor.org/rfc/rfc9106.html
- RFC 5869 — HKDF: https://www.rfc-editor.org/rfc/rfc5869.html
- RFC 3394 — AES Key Wrap: https://www.rfc-editor.org/rfc/rfc3394.html
- RFC 8439 — ChaCha20-Poly1305: https://www.rfc-editor.org/rfc/rfc8439.html
- draft-irtf-cfrg-xchacha — XChaCha20-Poly1305: https://datatracker.ietf.org/doc/draft-irtf-cfrg-xchacha/
- NIST SP 800-38F — Key Wrapping: https://csrc.nist.gov/pubs/sp/800/38/f/final
- BIP39 — Mnemonic code: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
OWASP
- Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Raw markdown: https://raw.githubusercontent.com/OWASP/CheatSheetSeries/refs/heads/master/cheatsheets/Password_Storage_Cheat_Sheet.md
- ASVS hash-function issue (parameter sets): https://github.com/OWASP/ASVS/issues/2991
Web Crypto
- MDN SubtleCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
- WICG Modern Algorithms (ChaCha20-Poly1305 proposal, not shipping): https://wicg.github.io/webcrypto-modern-algos/
- Node.js WebCrypto (ChaCha added v24.7.0 — Node, not browsers): https://nodejs.org/api/webcrypto.html
Libraries
- libsodium.js: https://github.com/jedisct1/libsodium.js
- libsodium audit (PIA/Cryptography Engineering, 2017): https://www.privateinternetaccess.com/blog/libsodium-v1-0-12-and-v1-0-13-security-assessment/
- @noble/ciphers: https://github.com/paulmillr/noble-ciphers · https://www.npmjs.com/package/@noble/ciphers
- argon2-browser (browser WASM timings): https://cdn.jsdelivr.net/npm/argon2-browser@1.18.0/README.md
- node-rs argon2 benchmarks (hash-wasm vs noble): https://github.com/napi-rs/node-rs/tree/main/packages/argon2
Blind index / leakage-abuse attacks
- Kellaris, Kollios, Nissim, O'Neill — "Generic Attacks on Secure Outsourced Databases," CCS 2016 (KKNO)
- Moyer et al. — "Pushing the Limits of Frequency Analysis in Leakage Abuse Attacks" (LAMa), 2025: https://arxiv.org/html/2508.11563v1
- "Low-Cost Hiding of the Query Pattern," 2020: https://doi.org/10.1145/3433210.3453103
- "Information Leakage in Encrypted Deduplication via Frequency Analysis," 2019: https://doi.org/10.1145/3365840
- "Public key authenticated searchable encryption against frequency analysis attacks," 2023: https://doi.org/10.1016/j.ins.2023.119060
- Naveed, Kamara, Wright — "Inference Attacks on Property-Preserving Encrypted Databases," CCS 2015
Envelope encryption / rotation
- Envelope Encryption: KEK vs DEK: https://www.encryptionconsulting.com/envelope-encryption-kek-vs-dek-and-key-wrapping/
- WorkOS — Envelope encryption explained: https://workos.com/blog/envelope-encryption-explained
- Google Cloud KMS envelope encryption: https://docs.cloud.google.com/kms/docs/envelope-encryption
Runtime primitives
- Bun APIs (official list — no KV): 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
- Deno KV (the real KV primitive Bun does not have): https://docs.deno.com/deploy/kv/manual/
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.