A sub-plan of README.md (the bun-cache plan). It plans three
features that share one mechanism, a chain of transforms the core applies
to every stored entry:
- at-rest encryption: an
encryptoption oncreateCache, with a built-in AES-256-GCM, key rotation, key providers, and user-supplied ciphers; - name hashing: keys, namespaces and tags stored as keyed hashes, so a dump of the store does not show them either;
- pluggable compression: Brotli and deflate beside zstd and gzip, and user-supplied compressors, configured the same way as ciphers.
On write the order is encode → compress → encrypt. On read it is the reverse.
The user approved encryption in principle on 2026-10-28 ("plan it, then build"). The same day they added three things: pluggable ciphers, key providers and hashing functions; a survey and measurement of what Bun itself offers for encryption; and customisable compression in the same configuration style.
Complete (2026-11-10): PR-Z1 #567, PR-E1 #571, PR-E2 #574, PR-E3 #577 and
the encrypt shapes as types #579 have merged.
Approved in full by the user on 2026-10-28. That covers every recommendation in §13 and every name in §14, as written. The user also decided:
- The build order: PR-Z1 (compression) is built now. PR-E1 to PR-E3 (encryption, name hashing, docs and playground) follow after the Cloudflare drivers (PR-C10, PR-C11).
- The decompression cap (Q18): a separate small PR, built now. It bounds
decompression at
maxEntryBytes, and a bomb reads as a miss plus an event. - Tag stripping on stamp-mode drivers: only encrypted caches close it, because their tags are sealed in the envelope. For plain caches it is documented, not hardened.
PR-Z1 merged (2026-10-28, #567), stacked on the decompression cap (#564): "br"
and raw "deflate", CacheCompressor (sync or async) with
compress.previous, the per-call compress: false, and entry format 2
(written only for br, deflate or a user compressor; develop's own entries
pinned as byte fixtures). Every compressor goes through #564's bound:
decompressBounded takes the header's compressor id and decodes br and
deflate with node:zlib's maxOutputLength, and a user compressor is handed
the cap and held to it. Departures, each a question in the PR: a second
decompress argument, maxOutputLength; a level beside a user compressor
refused.
PR-E1 merged (2026-10-28, #571), on develop d41aa084 (after PR-Z1 and the
Cloudflare drivers): the "BE" envelope and the built-in "aes-256-gcm"
with a key per entry (lib/core/encrypt.ts); rings, keys functions with
keyRefresh and the unknown-id refresh; aad; the sealed tags and expiry,
trusted over the driver's; CacheDecryptError and the "encrypt"/"decrypt"
phases, with no deletion on failure; acceptUnencrypted; CacheCipher with
previous; runCacheCipherConformance; keyId on hit and stale-hit;
the README's "Encryption at rest". Every §11 test but rawDump (PR-E2), each
with its negative control. Decisions the plan left open, each a question in
the PR:
- The tag-stamp check runs on every driver for an encrypted cache, not
only on stamps drivers: an index driver's tag index lives apart from the
entry (SQL
tags, Redis tag sets), so stripping it there must still miss. It costs one stamp read per tagged read, cached fortagStampCacheMs. - A failed
keyscall is retried by a later operation at most once per second;keyRefreshis checked as operations run (no timer) and is refused beside a static ring.
The user kept both (2026-10-28). One fix followed, found by the examples
agent: a cache without encrypt read a sealed entry as a decode failure
and deleted it, so one process missing the configuration during a rollout
would wipe its peers' encrypted entries (against Q8). It now recognises the
"BE" magic and reads the entry as a miss with an error event
(phase: "decrypt", CacheDecryptError reason "cipher", the key id when
well-formed), never deleting it; its own set or load still overwrites it,
as any write of the app's does. No new name or reason.
Measured on the real cache (bench-e1-overhead.ts):
set/get +3.9/+4.0 µs at 100 B on the memory driver (+8.4/+6.0 µs on
loopback Redis), +5.8/+4.6 µs at 4 KiB, and within the noise from 70 KB up.
Seal and open alone take about 2.6 µs each at 100 B, against §3.2's 1.8 and
1.9 µs for the bare cipher: the envelope's body, AAD and copies.
PR-E2 merged (2026-10-29, #574), on develop 17cc3ab8 (after PR-E1, #571):
encrypt.hashNames ({ key }, the HMAC of §7.1, or { hash }) in the core
(lib/core/names.ts), hashed once per name through a 10,000-name memo. Each
cache view hashes its namespace once; the drivers, L1 (its tag index now
holds the stored tags), the flight map, the stamp cache, the bus messages and
the two key-bearing log lines see only the stored forms, while events and
the cipher's AAD keep the plaintext. describeKey(); CacheNameContext;
the kit's rawDump and an encrypted pass of eight cases (encrypted.*)
that every driver's test runs — memory (through a test hook,
memoryDriverDump), file, SQLite, Postgres, MySQL, MariaDB, Redis, MongoDB
(standalone and replica set), S3, Azure, Cloudflare KV and D1 — with a scan
of each backend's raw bytes that first proves it sees a plain cache's value
and namespace. Decisions it took, each a question in the PR: evict events
carry the stored names (only the driver knows them); a user hash is checked
on every new name (1–200 bytes, no NUL or newline) and hashed twice for the
first name of each kind (determinism), a TypeError from the operation —
the namespace's at createCache; a top-level hashNames (JavaScript) is a
TypeError; Redis's dump reads by type, not DUMP (LZF could hide a
plaintext).
PR-E3 merged (2026-10-29, #577), on develop a4ea7c38 (after PR-E2, #574, and PR-B1, #576): the
README's compression, "Encryption at rest" and "Hiding names" sections
checked against the code and corrected where they differed or were silent
(each correction is listed in the PR, with the code and the probe it was
checked against): the threat model's sizes (exact, no padding, with a
key's length readable under hashNames), timings, shared names and the
compression oracle; the size cap, which holds the value before compression
and the sealed bytes, oversize reporting whichever passed; the rotation
runbook's re-sealing rewrites and ttl: null entries; keys functions'
timeout (off by default); the move from the built-in to a user cipher; a
migration in three steps, since acceptUnencrypted cannot bridge a
hashNames turned on in the same deploy; the user hash's checks; and the
entry header's layout for other readers. The playground's at-rest item
(/playground/cache/at-rest, playground/cache-at-rest.ts, checked by
cache-smoke.ts): one value through an encrypted cache with hashNames and
through a plain one, over one in-memory SQLite database read back with SQL,
the sealed entry opened by hand from the README's format alone, and
describeKey checked against the stored row. And, the user's choice of
the same day, a format test in the package
(__tests__/core/format.test.ts) that reads sealed entries and stored
names from the README's text alone with node:crypto, so a drift between
the documented format and the code fails bun-cache's own gate. No product
code changed. The
examples are written alongside, in the same worktree, by the examples agent.
PR merged (2026-10-29, #579): the encrypt shapes as types, on develop
33fa0874 (after PR-E3, #577), the user's decision of the same day. Two
§10.4 refusals that were only a TypeError at createCache are compile
errors too: CacheEncryptOptions is a union of a built-in shape (keys
required, cipher?: "aes-256-gcm", keyRefresh?) and a user-cipher shape
(cipher: CacheCipher, keys?: never, keyRefresh?: never), so hashNames
without a cipher, an empty encrypt and keys beside a user cipher are
refused; and hashNames is { key; hash?: never } | { hash; key?: never },
so both of key and hash are refused (neither already was). The branch
interfaces are not exported (no new public name); the runtime checks stay.
keyRefresh beside a static ring stays a TypeError only, since typing it
would break a keys held in a variable of the ring-or-function union. The
type test is __tests__/encrypt.type-test.ts, with a negative control for
each refused shape. The two shapes and the two hashNames shapes are exported as
CacheEncryptWithKeys, CacheEncryptWithCipher, CacheHashNamesKey and
CacheHashNamesHash (the user's decision, 2026-10-29).
Written 2026-10-28 against develop at 251a64f1 (#560). No product code
was changed. Every file:line is at 251a64f1, relative to
packages/bun-cache/ unless it says otherwise. The marks are the main
plan's: [S] source-read, [M] measured, [W] read on the web, [I] inference,
[D] design.
Every measurement is on this machine (i9-11900H, 16 threads, Linux
7.0.0-38-generic), on Bun 1.4.4-canary.1+a0cc096ce, at a load average of
3 to 6 from peer sessions, under nice -n 10. The evidence is in
evidence/encryption/. Each script there
re-runs with one command.
Historical: superseded by the Status (2026-10-28) above.
Plan only, 2026-10-28. It awaits the user's answers to §13. Building is four PRs (§12): PR-Z1 (compression), then PR-E1 (encryption), PR-E2 (name hashing) and PR-E3 (docs, playground).
- Summary
- Threat model
- What Bun offers, measured
- The cipher design
- Keys
- Pluggable ciphers
- Name hashing
- Compression
- Interplay with the rest of bun-cache
- The API
- Testing
- PR slicing and effort
- Open questions for the user
- Names needing approval
- Risks
- Evidence
- Encryption. Use AES-256-GCM through
node:crypto, which on Bun is synchronous BoringSSL.- Speed. Sealing 100 B takes 1.0 µs and opening it 1.1 µs. That is 11×
less than
crypto.subtleawaited per operation, which costs about 12 µs on Bun at any small size [M]. - A key per entry. Each entry gets its own key, derived from the user's key and a random 128-bit salt. This costs about 0.7 µs more per seal. It removes GCM's limit of 2^32 encryptions per key, which a cache writing 10,000 entries a second would reach in 5 days with plain random nonces [M].
- Speed. Sealing 100 B takes 1.0 µs and opening it 1.1 µs. That is 11×
less than
- What gets sealed. The whole entry is sealed: the header, the full
key, the payload, and copies of the tags and the hard expiry. The
ciphertext is bound to its namespace and key by AAD.
- A swap attack. We ran one on the real
createCache[M]. Encryption done today, as a customCodec, serves the wrong value when an attacker swaps two entries' payloads, in one namespace or across two. - The envelope. With the envelope, every swap, tamper, wrong-key and unknown-key case is a miss.
- Tag stripping. Stripping an entry's tags in the store lets it survive
invalidateTagstoday, on the file driver. With the tags inside the envelope, it does not.
- A swap attack. We ran one on the real
- Names. Optional HMAC-SHA-256 name hashing (
hashNames) is done in the core. Everything below the core then sees only hashed names: the drivers, the bus, L1 and the flight map.- Its own key. It takes a separate key that never rotates, because a changed names key would orphan every stored name.
- Compression. Add
branddeflate, and user compressors. A one-byte compressor id goes into a format-2 header. It is written only when the new algorithms are used, so existing deployments write format 1 as they do today.- zstd stays the default. At equal ratio it is 3.5× faster than Brotli at quality 4. On incompressible bytes it gives up 50× sooner than gzip or deflate [M].
- Custom transforms. User ciphers and compressors may be sync or
async. The core awaits only a returned promise. The read and write paths
are already async, so an extra
awaitcosts 0.36 µs at 100 B [M]. The built-ins stay sync.
| # | Decision | Section |
|---|---|---|
| E1 | Encryption is a core transform, not a codec and not a driver wrapper | §4.1 |
| E2 | Built-in "aes-256-gcm": node:crypto, a key per entry by HKDF from a 128-bit salt, a 96-bit nonce, nonces from crypto.getRandomValues |
§4.2–§4.4 |
| E3 | The envelope seals the whole entry, plus the tags and the hard expiry, and the core trusts those over the driver's clear copies | §4.3 |
| E4 | AAD = a label, the namespace, the full key, an optional aad context, and the envelope's own prefix |
§4.5 |
| E5 | A failed open is a miss with an error event (phase: "decrypt"), and the entry is not deleted |
§4.6 |
| E6 | Keys are exactly 32 bytes; no passphrases in v1; key ids in the envelope; the first key encrypts and all keys decrypt; a key provider function may be async | §5 |
| E7 | User ciphers and compressors take ids 128–255, may return promises, and are checked by a conformance helper | §6, §8.4 |
| E8 | Name hashing is in the core, with its own stable key, as 128-bit hex HMACs bound to the namespace | §7 |
| E9 | The compressor id lives in a format-2 header, written only when needed | §8.3 |
| E10 | No per-namespace overrides; two createCache calls over one driver give that. Per call, only compress: false |
§10.3 |
- Effort. About 10.5 focused days over four PRs (§12).
- Runtime. About 1–2 µs of CPU per operation at 100 B. That is under a quarter of the encode-and-compress cost at 4 KiB, and 2–8% from 70 KB up [M]. A networked driver's round trip, 0.1 ms or more, hides all of it.
It protects against a reader of the store, without the app's keys, who learns the cached values:
- a dump or backup of Redis, a SQL database, MongoDB, an S3 bucket or the file driver's directory;
- a stolen disk or snapshot;
- a store operator, or a co-tenant of a managed store (Upstash, a shared database);
- a compromised store that can read everything.
It also protects against a writer to the store who wants the app to accept forged or misplaced data. With the AAD and the sealed tags, such a writer can only:
- delete entries;
- replay an older ciphertext of the same key, inside the lifetime it was written with (spike D1, §4.5);
- leave garbage, which reads as a miss.
They cannot:
- make the app serve a value under another key or namespace;
- forge a value;
- extend an entry's hard expiry, which comes from the sealed copy;
- strip its tags to dodge an invalidation.
- A compromised app process. It holds the keys, the decrypted values in L1, and every value it loads. Nothing in a cache library can help here.
- Traffic and metadata analysis. These stay visible:
- each entry's size, ±1 byte (no padding, §13 Q14);
- when it was written (
ca,at, the file's mtime, the S3at=); - when it expires (the driver's
expiresAt, which native TTLs and sweepers need); - how often it is read (the store's own logs);
- how many entries a namespace holds.
- Names, unless
hashNamesis on (§7). Keys, namespaces and tags appear in the store as written (§2.3). Turning on encryption alone does not change that. - Counter values. Counters are plain integers, so that the backend can add to them atomically (§9.6).
- A compression oracle. Suppose an attacker can place chosen text inside a
value that also holds a secret, and can watch the stored size. Then the
compressed length leaks the secret byte by byte. This is the CRIME and
BREACH class of attack [I]. Encryption hides the bytes, not the length.
Such values belong in a cache with
compress: false, or are written withset(…, { compress: false })(§8.5). - Availability. A writer can always delete entries, or fill the store.
Names means keys, namespaces and tags. Value bytes are the core's
header and payload. The header holds the full key in clear
(lib/core/header.ts:12-15), so even a hashed long key is readable in the
value.
| Driver | Names in clear | Value bytes | Metadata in clear |
|---|---|---|---|
| memory | in process | in process | — |
| file | the namespace (encodeSegment); keys and tags as unsalted SHA-256, which a guess of a short key like user:42 confirms; tags verbatim in each entry file's frame (drivers/file.ts:133-152) |
the .bce file after its frame |
expiresAt (14-byte frame), mtime, counter JSON { value, expiresAt, createdAt }, lock JSON, bus.log JSON (keys, tags, namespace) |
| SQLite, Postgres, MySQL, MariaDB | entries.ns, entries.k, entries.tg (JSON), tags.tag, stamps.tag, locks.k; bus.msg (MySQL, MariaDB, SQLite), the NOTIFY payload (Postgres) |
entries.v |
exp, ca, n (counters), stamps.at/exp |
| Redis | key names <p>:{<ns>}:e:<key>, …:t:<tag>, …:s:<tag>; tags as JSON in each entry frame; pub/sub messages |
the frame after its first line | PEXPIREAT, counter hashes n/x/c, stamps "<at> <until>" |
| MongoDB | _id.ns, _id.k, tags[], the stamps' _id.tag; the capped bus collection |
v |
exp, ca, n |
| S3 | the namespace segment; keys and tags as unsalted SHA-256 in object names | the object body | Content-Type: …; exp=; at=, lastModified, counter objects |
The bus stores names at rest on four drivers. The SQL bus table keeps
them for busRetention (60 s), the MongoDB capped collection until it wraps,
and the file bus.log until compaction. That is the only reason name hashing
has to happen above the bus (§7.1).
| Item | Stays in clear because | Sealed |
|---|---|---|
StoredEntry.expiresAt |
Native TTLs (Redis PEXPIREAT, the MongoDB TTL index) and every sweeper read it |
A copy inside the envelope. The core enforces that copy, so a raised clear expiry gains the attacker nothing |
StoredEntry.tags |
deleteByTags on index drivers works through them |
A copy inside the envelope. The stamps-mode read check (cache.ts:609-626 today reads raw.tags) and getEntry().tags use the sealed copy. In the clear they are hashed under hashNames |
StoredEntry.key and the namespace |
Every driver addresses by them | Under hashNames, an HMAC in clear. The full key is always inside the sealed header |
| The envelope prefix: magic, version, cipher id, key id, salt, nonce | Needed to open the entry | Authenticated as AAD, not encrypted |
createdAt, freshUntil, loadMs, codec id, flags, the full key, the payload |
— | Sealed. The negative flag is sealed too, so a reader cannot tell which lookups came back empty |
Counter values (n) |
INCRBY, $inc, n = n + ? |
No (§9.6) |
| Lock owner tokens | Compare-and-delete | Not needed: a random UUID (cache.ts:1037) |
Probed with probe-subtle.ts and
bun -e. The output is in results-probe-subtle.txt.
| API | Sync | What it offers here | Verdict |
|---|---|---|---|
node:crypto createCipheriv/createDecipheriv |
yes | aes-128/192/256-gcm. BoringSSL native since Bun 1.2.6 [W] |
The built-in |
node:crypto ChaCha20-Poly1305 |
— | Not on Bun: ERR_CRYPTO_UNKNOWN_CIPHER. Open as oven-sh/bun#8072 since 2024-01-09 |
— |
node:crypto AES-GCM-SIV, OCB, CCM, XChaCha20 |
— | Unknown cipher, all four. OCB is oven-sh/bun#42580. getCiphers() lists 28 ciphers, and GCM is the only AEAD among them |
— |
crypto.subtle AES-GCM |
no | Async, imported CryptoKey. Its output is byte-compatible with node's GCM: each opens the other's (sanity block of bench-ciphers.ts) |
Not the built-in: about 12 µs of thread hop per op. A user cipher may use it (§6) |
crypto.subtle ChaCha20-Poly1305 |
no | Present (the WICG "Modern Algorithms" set). It imports only as "raw-secret", not "raw" [M] |
Slower than GCM from 70 KB up (§3.2). For user ciphers |
crypto.subtle HMAC, HKDF |
no | Both work | 12 µs per awaited op: not for per-op name hashing |
Bun.CryptoHasher("sha256", key) |
yes | HMAC when given a key: the same bytes as createHmac [M] |
The name hasher, and the per-entry key derivation |
node:crypto createHmac, hkdfSync, scryptSync |
yes | hkdfSync takes 1.1 µs; scryptSync takes 85 ms at N = 2^15 |
hkdfSync once per key at startup; scrypt only if passphrases are ever wanted (§13 Q6) |
Bun.hash |
yes | wyhash: 64-bit, unkeyed, not cryptographic | Not usable to hide names. Anyone can recompute it from a guessed name |
Bun.password |
async and sync | argon2id and bcrypt password hashing. It writes PHC strings with a random salt | Not for data. It cannot encrypt, and cannot derive a stable key: it is for storing and checking passwords |
crypto.getRandomValues |
yes | 12 random bytes in 0.04 µs, against randomBytes(12)'s 2.8 µs on Bun (3.4 µs and 4.7 µs on Node 24.2.0) |
The nonce and salt source. No getrandom syscall per call for either API (strace) |
The "faster" claim. Bun 1.2.6's release notes say Bun rewrote Cipheriv
and Decipheriv natively on BoringSSL.
- What the notes measured. Their aes-256-gcm benchmark went from 1.15 ms
to 3.54 µs per iteration, on an M1 Max
(bun.sh/blog/bun-v1.2.6, "Faster, more
compatible
node:crypto") [W]. - Confirmed here. A 100 B seal plus open takes 2.2 µs, which is that order. It is about 11× less than WebCrypto awaited per operation.
- Not checked. We did not compare Bun's GCM with Node's GCM; the choice does not need it.
- One more finding. The cost of a small seal was not the cipher at all:
randomBytes(12)took 2.8 of the 3.7 µs (bench-nonce.ts). This is not a Bun regression, since Node 24 is no faster (repro-randombytes.mjs). Bun'sgetRandomValues, however, is 60× faster than Bun'srandomBytes.
From bench-ciphers.ts, section 1:
random bytes, with the AAD set. Each cell is µs per op of wall time, then
CPU µs per op. The median of 5 rounds is shown.
| Candidate | Op | 100 B | 4 KiB | 70 KB | 1 MiB |
|---|---|---|---|---|---|
| node AES-256-GCM | seal | 1.03 / 1.10 | 2.24 / 2.82 | 20.3 / 27.3 | 309 / 404 |
| node AES-256-GCM | open | 1.14 / 1.17 | 1.89 / 2.11 | 16.0 / 20.1 | 173 / 219 |
| node AES-256-GCM, a key per entry (the built-in) | seal | 1.76 / 1.83 | 3.38 / 3.94 | 29.5 / 40.3 | 403 / 531 |
| the same | open | 1.90 / 1.96 | 2.59 / 2.78 | 14.6 / 18.1 | 179 / 226 |
node GCM behind an await |
seal | 1.39 / 1.43 | 2.53 / 2.89 | 19.8 / 26.3 | 321 / 416 |
| subtle AES-GCM, awaited one by one | seal | 11.8 / 16.0 | 15.0 / 19.6 | 42.3 / 53.1 | 554 / 667 |
| subtle AES-GCM, awaited one by one | open | 11.5 / 16.1 | 14.3 / 18.8 | 35.2 / 42.7 | 418 / 478 |
| subtle AES-GCM, 64 in flight | seal | 4.07 / 12.3 | 6.15 / 16.9 | 26.0 / 66.3 | 496 / 1413 |
subtle AES-GCM, importKey per entry |
seal | 17.8 / 23.2 | 19.4 / 24.7 | 60.0 / 70.6 | 609 / 724 |
| subtle ChaCha20-Poly1305 | seal | 14.7 / 19.7 | 19.6 / 24.9 | 89.0 / 101 | 1140 / 1243 |
| subtle ChaCha20-Poly1305 | open | 11.8 / 16.6 | 16.3 / 21.4 | 72.8 / 82.1 | 851 / 925 |
How to read it:
- subtle runs off-thread. Its CPU exceeds its wall time. At 64 in flight it reaches node's per-op wall time at 100 B, but burns 12× the CPU to get there.
- The per-entry key costs 0.7 µs per seal and 0.8 µs per open at 100 B. From 70 KB up it is noise.
- ChaCha20-Poly1305 has no edge on this x86 machine, which has AES-NI. Its usual advantage is on CPUs without AES instructions [I].
From bench-ciphers.ts, section 2. The values are cache-shaped records,
taken through bun:jsc serialize (the default codec) and zstd from 16 KiB
(the default threshold). Each cell is µs per op of wall time.
| JSON size | Stored payload | encode + zstd | + seal | Seal's share | decode | open + decode | Open's share |
|---|---|---|---|---|---|---|---|
| 63 B | 83 B | 1.06 | 2.38 | 56% | 0.60 | 1.89 | 68% |
| 4,164 B | 2,193 B | 13.1 | 16.2 | 19% | 8.36 | 10.8 | 23% |
| 70,042 B | 2,403 B (zstd) | 286 | 312 | 8% | 143 | 140 | within noise |
| 1,048,807 B | 29,328 B (zstd) | 4,392 | 4,486 | 2% | 1,979 | 1,926 | within noise |
Compression first makes encryption cheap on large values. The cipher sees the compressed bytes: 2.4 KB, not 70 KB.
Whole cache operations (bench-cache-overhead.ts,
the real createCache, with a driver wrapper that seals like the built-in).
This is the noisiest measurement here. Three runs at a load of 3–6 gave:
- memory, 100 B:
set+3 to +8 µs,get+3 to +9 µs, on 3–8 µs; - SQLite, 4 KiB: no difference out of the noise.
The wrapper copies more than the core would. Read these as the upper bound of the overhead, not its value.
We considered three places:
-
A
Codec. This is the only hook today, and it is wrong:- a codec sees the value, not its key, so it cannot bind the ciphertext to it;
- the header (the full key,
createdAt, the flags) stays in clear and unauthenticated.
Spike A (
swap-attack.ts) swaps two entries' payloads and keeps each one's clear header. The realcreateCachethen serves alice's admin record foruser:2, and tenant a's invoice in tenant b. Also,Codec.encodeis synchronous. -
A driver wrapper (
encryptDriver(driver)). This works for values. Spike B shows it, as the spike's stand-in. It fails for names:- the core keys L1, the flight map, the stamp cache and bus messages by the names it passes down;
- so hashing names below the core means a bus message from another process arrives with hashes the receiving core cannot match to its own L1.
-
The core — chosen.
- Where it runs.
#write(cache.ts:774) seals afterencodeEntry, and#decodeStored(cache.ts:579) opens beforedecodeEntry. Both are already async. - Every other path. It also covers
touch(cache.ts:1739), the background-refresh rewrite (cache.ts:1377-1394) andgetMany. - L1 gets the plaintext.
#l1CopyOfWrite(cache.ts:913) is handed the plaintext entry bytes, not the sealed ones, so L1 never decrypts. - The only API change is the new options.
- Where it runs.
-
Implementation.
node:cryptocreateCipheriv("aes-256-gcm"), with the key given as aBuffer. AKeyObjectmeasured the same: 1.02 against 1.03 µs. -
A key per entry. Each seal draws a 16-byte salt and a 12-byte nonce from
crypto.getRandomValues. Then:K = HKDF-SHA-256(ikm = user key, salt = "", info = "bun-cache/enc/1/aes-256-gcm") once per key K_salt = HKDF-Expand(K, "entry" ‖ salt, 32) = HMAC-SHA-256(K, "entry" ‖ salt ‖ 0x01) per seal, Bun.CryptoHasher sealed = AES-256-GCM(K_salt, nonce, plaintext, AAD) 16-byte tag -
Why a key per entry, and not plain random nonces (§4.4): a nonce repeat under one GCM key leaks the XOR of two plaintexts and the authentication key, which allows forgery. The per-entry key moves the birthday bound from 96 bits to the salt's 128. This is the same idea as XAES-256-GCM [I].
-
Why not ChaCha20-Poly1305 or AES-GCM-SIV. Neither is in Bun's
node:crypto(§3.1); ChaCha20 is subtle-only, async and slower here. GCM-SIV would make nonce repeats harmless, but it is not available.
offset size field (built-in cipher)
0 2 magic "BE"
2 1 envelope version 1
3 1 cipher id: 1 = "aes-256-gcm"; 128–255 a user cipher (§6)
4 1 key id length L (1–32)
5 L key id, UTF-8
5+L 16 salt
21+L 12 nonce
33+L n ciphertext of the sealed body
33+L+n 16 GCM tag
sealed body = u8 body version (1)
| f64 LE expiresAt (-1 = none)
| u16 LE tag count | per tag: u16 LE length, UTF-8
| the core entry: the format-1 or format-2 header and its payload (lib/core/header.ts; §8.3)
For a user cipher, the core writes bytes 0–3. Everything after them is the cipher's own output: its key id, its nonce and its tag.
The size.
- The built-in adds 49 + L bytes, so 56 B for a seven-character id such
as
2026-10. - The sealed body adds 11 bytes, plus the tags.
- An entry of 140 B grows to about 210 B; one of 70 KB grows by under 0.1%.
maxEntryBytesand the driver's cap are checked against the sealed size (§9.2).
What the core trusts. It takes expiresAt and the tags from the sealed
body, never from the driver's StoredEntry. Two things follow:
- an attacker who raises the clear expiry gains nothing;
- an attacker who strips the clear tags gains nothing. Spike C1 measures the
hole as it is today: on the file driver, an entry whose tags were removed in
the store survives
invalidateTags. Spike C2 shows the same entry invalidated once the tags come from the envelope.
From nonce-bound.ts. NIST SP 800-38D
§8.3 caps random 96-bit nonces at 2^32 encryptions per key.
| Writes/s | Time to reach 2^32 seals | P(any repeat) after 1 year, plain random nonces | After 1 year, a key per entry (128-bit salt) |
|---|---|---|---|
| 100 | 497 days | 6.3e-11 | 1.5e-20 |
| 1,000 | 49.7 days | 6.3e-9 | 1.5e-18 |
| 10,000 | 5.0 days | 6.3e-7 | 1.5e-16 |
| 100,000 | 11.9 hours | 6.3e-5 | 1.5e-14 |
- Counters are not an option. A counter nonce would need coordination across processes, which a cache does not have.
- Rotating keys is not an option. No process can count the cluster's writes to know when to rotate.
- The per-entry key is the fix, at the 0.7 µs measured in §3.2.
AAD = "bun-cache/enc/1" 0x00 | full namespace 0x00 | full key 0x00 | encrypt.aad (or empty) 0x00 | envelope bytes 0 .. end of nonce
Its parts.
- The plaintext names. The namespace and key are the plaintext full
ones, not the stored forms. So the binding does not depend on
hashNamesor the long-key hashing. - The envelope prefix binds the cipher id and the key id.
aadis optional context such as"prod". With it, an entry written by one environment does not open in another that shares the key.
What spike B measured, on the real createCache:
| Attack | Envelope without AAD (control) | With AAD |
|---|---|---|
Swap user:1 and user:2 in one namespace |
miss (B0a): the full key inside the sealed header differs | miss (B1) |
Copy tenant-a's invoice:8 over tenant-b's |
served tenant a's invoice (B0b) | miss (B2) |
| Flip one ciphertext bit | — | miss (B3) |
| Replay an older ciphertext of the same key | — | served the old value (D1): not preventable without a server-side version, which a cache store does not offer |
So the AAD is what stops a cross-namespace swap, and the sealed full key is
a second guard within one namespace. A replay is bounded by the sealed
freshUntil and expiresAt: a replayed entry is at most as fresh as when it
was written. The README says so.
These outcomes are a miss, with an error event (phase: "decrypt",
error CacheDecryptError with a reason):
- the tag fails (
"auth"); - the key id is unknown (
"unknown-key", after one key refresh, §5.4); - the bytes are not an envelope (
"unencrypted"), unlessacceptUnencrypted; - the envelope is malformed (
"malformed"); - a user cipher throws (
"cipher").
How each case is handled:
- Never thrown, not even under
strict. This matches decode errors (§3.2 of the main plan). A cache that cannot read an entry falls back to the origin. - Not deleted. Unlike decode failures, which
#deleteInBackgroundremoves (cache.ts:595). During a key rollout, a process that does not yet have the new key would otherwise delete every entry its peers wrote. The entry is overwritten by the nextsetor load anyway. - Logged at
warnat most once per 10 s per reason, as driver errors are. The message names the key id, never key material or plaintext. - Writes. When a write cannot seal (no key yet, the provider failed, a
user cipher threw), the
setresolvesfalsewith anerrorevent (phase: "encrypt"). It throws only understrict, as a driver write does (§3.14). Nothing is stored unencrypted, ever.
- The format. A key is 32 bytes: a
Uint8Array, or a string that decodes to exactly 32 bytes as base64, base64url or hex.- Anything else is a
TypeErroratcreateCache, and the message givesopenssl rand -base64 32. - No passphrases in v1 (§13 Q6). A passphrase needs a slow KDF and a
stored salt. scrypt at N = 2^15 costs 85 ms, and 316 ms at 2^17
(
bench-hmac.ts). Users then get the salt wrong: a random salt per start is a cold cache on every boot. A helper can come later.
- Anything else is a
- Derivation. Per key and per use, with HKDF-SHA-256 and a distinct
info(bun-cache/enc/1/aes-256-gcm,bun-cache/names/1). One key's bytes are never used directly for two purposes.hkdfSynctakes 1.1 µs, once per key per process.
-
The ring.
keys: [{ id, key }, …].- The first encrypts, and all decrypt.
- An id is 1–32 bytes of
[A-Za-z0-9._-]; ids are unique, and the ring is not empty. - The id is written into every envelope, so a read picks its key directly without trying each one.
-
The rotation runbook (in the README). Spike B4 opens an entry written under
2026-09with the ring[2026-10, 2026-09].- Deploy
[old, new]: every process can read the new key's entries. - Deploy
[new, old]: new writes use the new key. - After the longest TTL plus its stale window, deploy
[new].
Skipping step 1 costs only misses while the deploy rolls. Entries are not deleted on failure (§4.6).
- Deploy
-
Re-encrypt on read (
reencrypt): not in v1 (§13 Q7). It would turn reads into writes during a rotation, and TTL turnover re-encrypts anyway. TheCacheDecryptErrorreason and thekeyIdonhit(§10.2) show when an old key is no longer used.
keys may be a function returning the ring or a promise of it. bun-cache
never talks to a KMS itself: there is no dependency. The README shows the
envelope-key pattern, where the function asks the KMS to decrypt a data key
once.
- When it is called. On first use, and again every
keyRefreshms (default: never after the first call). - Before it answers. Operations wait for the first call. A read waits at
most the read
timeoutand is then a miss; a write resolvesfalse. - An unknown key id on read triggers one early refresh, at most once per 10 s. A process then learns a peer's new key without a restart.
- When it rejects. An
errorevent (phase: "encrypt"on a write,"decrypt"on a read) and the previous ring stays. Before any ring exists, writes resolvefalseand reads miss. - Validation. Each answer is checked like a static ring (§5.1). An invalid ring is rejected as a provider failure, never half-applied.
interface CacheCipher {
/** Stored in the envelope: 128–255 (1–127 are the package's). */
id: number;
/** Seals `plaintext`, authenticating `context.aad`. May be async. */
seal(plaintext: Uint8Array, context: CacheCipherContext): Uint8Array | Promise<Uint8Array>;
/** Opens what `seal` wrote; throws when it does not authenticate. May be async. */
open(sealed: Uint8Array, context: CacheCipherContext): Uint8Array | Promise<Uint8Array>;
}
interface CacheCipherContext {
/** The full namespace, plaintext. */
namespace: string;
/** The full key, plaintext. */
key: string;
/** The AAD the core built (§4.5), without the user cipher's own bytes. The cipher must authenticate it. */
aad: Uint8Array;
}encrypt: { cipher: myCipher } replaces the built-in. Its key management is
its own business: KMS calls, HSMs, a library such as @noble/ciphers. Then:
keysis refused beside a user cipher: aTypeErrornaming the conflict.hashNamesstill works, with its own key (§7).previous: [oldCipher, …]decrypts entries written by an earlier cipher, as the key ring does for keys. The built-in"aes-256-gcm"is always readable whenkeysare given.- An unknown cipher id is a miss (
"cipher"), as an unknown codec id is.
- The user cipher may return a promise. The core awaits only a thenable, so a sync cipher pays nothing for the option.
- The cost of the extra
await. The core's read and write paths are already async (#write,#decodeStored). Putting a sync GCM seal behind anawaitadded 0.36 µs at 100 B (1.03 to 1.39 µs, §3.2). - Async ciphers run where the built-ins run.
#l1CopyOfWriteworks on the plaintext and needs no cipher.touchand the refresh rewrite are async already. - The built-in stays sync. subtle's 11 µs hop buys nothing for a cipher
that
node:cryptoruns in 1 µs.
./testing gains runCacheCipherConformance(name, cipher), on bun:test.
It asserts:
- a round trip at 0 B, 100 B, 70 KB and 1 MiB;
- the ciphertext does not contain the plaintext;
- two seals of the same input differ: a randomised nonce;
- a flipped bit fails;
- a changed
aad,namespaceorkeyfails; - truncation fails;
- an async cipher round-trips.
A cipher that ignores aad fails the suite. The negative control is a test
cipher that does exactly that.
-
On.
encrypt.hashNames: { key }turns it on.- It requires encryption. The sealed header carries the full key, and
without encryption that key is readable in the value, which would make
hashing pointless.
createCacherefuseshashNameswith no cipher.
- It requires encryption. The sealed header carries the full key, and
without encryption that key is readable in the value, which would make
hashing pointless.
-
The hash. Each name becomes the lower-case hex of the first 128 bits of
HMAC-SHA-256(K_names, …), bound to its kind and namespace:K_names = HKDF-SHA-256(hashNames.key, info = "bun-cache/names/1") stored ns = hex128(HMAC(K_names, "ns" 0x00 full namespace)) stored key = hex128(HMAC(K_names, "key" 0x00 full namespace 0x00 full key)) stored tag = hex128(HMAC(K_names, "tag" 0x00 full namespace 0x00 tag)) -
Why bind keys and tags to the namespace. Without it, the same key in two namespaces hashes alike, and the dump shows which tenants hold the same key.
-
Why 32 hex characters.
- The format fits every driver's rules: SQL
VARCHAR(255), file and S3 segments, Redis hash tags. It is case-proof for the file driver'sencodeSegment. - A collision between two stored keys only makes them overwrite each
other's entries: a miss, never a wrong value. The sealed header's full
key is checked on read (
cache.ts:490-495). - At 2^40 keys the chance of any collision is about 2^-49.
- The format fits every driver's rules: SQL
-
Cost. 0.44 µs (12 B) to 0.61 µs (200 B) per name with
Bun.CryptoHasher, against 0.68–0.85 µs withcreateHmac(bench-hmac.ts). The core keeps a bounded memo (by default, the last 10,000 names), since hot keys repeat. -
In the core.
this.#nsand#storedKey()(cache.ts:225-243) answer the hashed forms, and tags are hashed before every driver call. So the drivers, L1, the flight map, the stamp cache and bus messages all see one consistent set of hashed names (§4.1).- Events keep the plaintext.
hit,miss,fencedand the rest go to the app's own listeners, which already know the names. - The core's two log lines (
cache.ts:1073,1403) switch to the stored form, because logs are often stored.
- Events keep the plaintext.
-
Long keys. The current
#+ SHA-256 step (§3.7 of the main plan) is skipped underhashNames: an HMAC already has a fixed length.
| Driver | Under hashNames |
Unchanged |
|---|---|---|
| memory | Hashed map keys (no reason to, but harmless) | — |
| file | Directory and file names from hex, so encodeSegment is a pass-through; the entry frame's tags hashed |
The frame's expiresAt, mtime |
| SQLite and SQL | ns, k, tg, tags.tag, stamps.tag, locks.k, bus.msg (hashed names in JSON) |
exp, ca, n |
| Redis | Key names <p>:{<hns>}:e:<hk>. The hash tag is still one per namespace, so one slot per namespace, as today. The frame's tag JSON is hashed |
TTLs, counter fields |
| MongoDB | _id, tags[], bus.m |
exp, ca, n |
| S3 | Object names (the namespace segment; sha256(hashed key)) |
Content-Type exp= and at= |
Behaviour that stays the same.
clear(),invalidateTags()and the bus work as before, since every driver matches the namespace exactly (memory.ts:310,ns = ?, the Redis{<ns>}scan).- Nothing ever needs a prefix relation between a namespace and its children:
clear()does not reach child namespaces today (§3.7). - Counters are hashed the same way as entries, by key (§9.6).
- The HTTP response cache gains most. Its keys contain URLs, query
strings and
Varyheader values (lib/http/key.ts:113-128). Its path tags contain paths (key.ts:146). All of them become opaque.
cache.describeKey(key, tags?) returns { namespace, key, tags }: the
stored forms, to find an entry in a dump or a redis-cli session. A get
with { tier: "l2" } still works for checking a value. The README covers
finding a key by its hash.
- A user hash.
hashNames: { hash(name, context) }replaces the HMAC, for example to match an existing scheme.contextis{ kind: "namespace" | "key" | "tag", namespace }.- The output must be 1–200 bytes, with no NUL or newline, and is checked on first use. It must be deterministic, which the README says and a conformance case checks: hash twice, compare.
- All or nothing. Hashing applies to the whole cache and every
namespace()view of it. Mixed views would make one view's entries invisible to another under the same name. - Turning it on is a flush. Old entries sit under plaintext names nobody
asks for any more, and expire or are swept. The README says to
clear()first, or to accept the orphans. The same holds for changinghashNames.key, which is why it is its own key and never rotates with the encryption keys. Rotating it means a new, empty cache, as aversionbump does.
From bench-compression.ts:
deterministic JSON records, an HTML table page, and random
(incompressible) bytes. Each cell is the ratio, then compress µs, then
decompress µs. The run has the full table, including zstd 1 and 9, gzip 1
and Brotli 1 and 5.
| Algorithm | JSON 4 KiB | JSON 70 KB | JSON 1 MiB | HTML 100 B | HTML 70 KB | Random 70 KB | Random 1 MiB |
|---|---|---|---|---|---|---|---|
| zstd 3 (default) | 3.8× 17 / 6.6 | 5.0× 184 / 64 | 20.8× 967 / 442 | 1.1× 3.9 / 1.6 | 6.9× 128 / 53 | 1.0× 27 / 7.4 | 1.0× 356 / 91 |
| gzip 6 (Bun) | 3.7× 26 / 7.5 | 5.1× 817 / 53 | 14.9× 3389 / 380 | 1.0× 7.0 / 1.9 | 7.7× 684 / 45 | 1.0× 1335 / 8.7 | 1.0× 20685 / 123 |
| deflate 6, raw (Bun) | 3.8× 30 / 7.5 | 5.1× 792 / 64 | 14.9× 3180 / 358 | 1.2× 7.1 / 1.9 | 7.7× 675 / 43 | 1.0× 1380 / 8.4 | 1.0× 20730 / 90 |
| deflate 6, zlib-wrapped (node:zlib) | 3.7× 27 / 8.8 | 5.1× 779 / 75 | 14.9× 3243 / 594 | 1.1× 10 / 4.4 | 7.7× 713 / 58 | 1.0× 1337 / 20 | 1.0× 20376 / 243 |
| br 4 (bun-common's default) | 3.9× 56 / 10 | 5.0× 642 / 124 | 20.0× 3917 / 871 | 1.4× 12 / 3.4 | 7.1× 528 / 90 | 1.0× 110 / 22 | 1.0× 2199 / 322 |
| br 11 (Brotli's default) | 4.4× 5145 / 14 | 6.4× 90397 / 137 | 28.2× 3.04 s / 844 | 2.2× 256 / 3.8 | 9.6× 90140 / 88 | 1.0× 17216 / 21 | 1.0× 0.36 s / 316 |
What the numbers show:
- zstd 3 stays the default.
- It matches Brotli 4's ratio on JSON in a quarter to a third of the time, and gzip's ratio in a quarter of the time.
- Its decompression is the fastest but one.
- On incompressible bytes, an image or something already compressed, it
gives up in 27 µs at 70 KB. gzip and deflate spend 1.3 ms there, and
20 ms at 1 MiB, before the core's "only if smaller" rule
(
cache.ts:478) throws the result away.
- Brotli's quality default must be 4. Its own default, 11, takes 90 ms
for 70 KB and 3 s for 1 MiB: 140× and 780× the time of quality 4, for at
most 1.4× the ratio. That confirms the reason bun-common's
compression.ts:125-127gives for its quality 4 ("two orders of magnitude slower").- Where Brotli helps a cache: small text, through its built-in dictionary. 100 B of HTML compresses 1.4× at quality 4, where zstd gives 1.1×, but that only matters with a threshold far under the default.
- deflate is raw DEFLATE (RFC 1951) through
Bun.deflateSync. It is the same ratio as the zlib-wrapped form, 6 bytes shorter, and its 1 MiB decompression is 0.36 ms against 0.59 ms. The stored bytes never reach HTTP, so HTTP's zlib wrapping (RFC 9110 §8.4.1.2), which bun-common must use, does not apply. The README says so, because the name is the same. - Bun has no
Bun.brotli*. The Bun functions aredeflateSync,gunzipSync,gzipSync,inflateSync,zstdCompress(Sync)andzstdDecompress(Sync). Brotli isnode:zlibbrotliCompressSyncandbrotliDecompressSync. - The async forms are slower per operation ("Async forms" in the
results).
await Bun.zstdCompresstook 1.6 ms againstzstdCompressSync's 0.69 ms on 1 MiB.await zlib.gziptook 160 µs against 52 at 4 KiB. They buy only an unblocked loop, so the built-ins stay sync. bun-common'sasyncThreshold(64 KiB) exists because an HTTP body may be 10 MiB; a cache entry is capped at 4 MiB by default.
algorithm |
Default level |
Range | Implementation |
|---|---|---|---|
"zstd" (default) |
3 | 1–22 | Bun.zstdCompressSync / zstdDecompressSync |
"gzip" |
6 | 0–9 | Bun.gzipSync / gunzipSync |
"br" |
4 (bun-common's) | 0–11 | node:zlib brotliCompressSync with BROTLI_PARAM_QUALITY |
"deflate" |
6 | 0–9 | Bun.deflateSync / inflateSync (raw) |
- The threshold stays 16 KiB for every algorithm (§13 Q13). Lowering it would change the CPU and the stored size of every existing deployment. The README shows that at 4 KiB, zstd saves 3.8× for 17 µs.
levelis the one tuning knob. It means Brotli's quality for"br". bun-common's fullbrotli/zstdoption objects are not mirrored: a cache entry is one buffer, not a stream, and the other parameters are window and stream controls.
-
No room in today's flags byte. It has three free bits (bits 5–7,
header.ts:31-42), not enough for two more algorithms plus user ids. -
Format 2 has one extra byte after the flags:
format 2: "BC" | 2 | codec | flags (bits 0–1 zero; counter, negative, rawString as today) | compressor id | f64 createdAt | f64 freshUntil | f64 loadMs | u32 key length | key | payload compressor id: 0 none, 1 zstd, 2 gzip, 3 br, 4 deflate (raw), 128–255 a user compressor -
When each is written. The core writes format 1 whenever it can express the entry: no compression, zstd or gzip. That is every entry a cache configured as today writes. Format 2 is written only for
br,deflateor a user compressor. -
Why. During a rolling deploy, a process on the old version reads a format-2 entry as a decode failure (
header.ts:110-114): a miss, deleted. That is acceptable during the deploy that turns on Brotli. It is not acceptable for every deploy of the new version. -
Reading. The decoder reads both formats. An unknown compressor id is a miss with
phase: "decode", as an unknown codec id is (codecs.ts:110). -
Encryption is separate. It has its own magic (
"BE") and wraps either format.
interface CacheCompressor {
/** Stored in the header: 128–255 (0–127 are the package's). */
id: number;
compress(bytes: Uint8Array): Uint8Array | Promise<Uint8Array>;
decompress(bytes: Uint8Array): Uint8Array | Promise<Uint8Array>;
}- How to use one.
compress: { algorithm: myCompressor, threshold }. It takes the same option slot as a built-in name: one configuration style for both kinds, and the same asencrypt.cipher. - Sync or async. Either, as with ciphers (§6.2).
- The "only if smaller" rule applies to user compressors too.
- Reading old entries. Every built-in always decompresses.
compress.previous: [oldCompressor]keeps entries from an earlier user compressor readable; without it they are misses. - The decompression bomb. No decompression is capped today [S]:
#decodeValuecallsBun.zstdDecompressSyncandBun.gunzipSyncwith no output limit (cache.ts:498-503). So a store writer can plant a small entry that expands to gigabytes on read.- Encryption closes this for encrypted caches: a forged entry does not open.
- Plain caches need a cap, which is outside this plan (§13 Q18). A user compressor gets no more protection than the built-ins.
- A dictionary-trained zstd is the obvious user compressor. Bun's native
zstd ignores a dictionary (bun-common's comment at
compression.ts:828), so it goes throughnode:zlibzstdCompressSync({ dictionary }). The README shows it.
- The type, not the code. bun-cache already depends on bun-common
(
package.json:141), and imports its types (types.ts:10). Thealgorithmunion is typedCompressionEncoding | CacheCompressor, withCompressionEncodingimported from bun-common (compression.ts:61). The two spellings cannot drift: if bun-common adds an encoding, bun-cache's exhaustiveswitchfails to compile until it handles it. - No shared runtime helper. bun-common's
createCodec(compression.ts:750) is internal, and HTTP-shaped: streams, RFC 9842 dictionaries, zlib-wrapped deflate, flush kinds, prefixes. Exporting it would make an HTTP detail a cache format. - The Brotli default, 4, is mirrored with a comment pointing at
compression.ts:737. bun-common exports no constant for it, and adding one is a bun-common name (§13 Q12). - Per call,
compress: falseonset,setMany,getOrSetandwrap. This is for values the caller knows are already compressed (images, archives), where gzip, deflate and Brotli would waste up to 20 ms per MiB before the "only if smaller" rule discards their work. It is also the escape from the compression oracle (§2.2).
Unchanged. Compression runs first, on plaintext; ciphertext does not compress.
Checked on the bytes the driver receives: the sealed envelope (§4.3, +56 B
and the tags). For a store-imposed cap (capabilities.maxEntryBytes),
oversize reports the sealed size against it, and stats().bytesWritten
counts sealed bytes.
L1 already holds decoded values (§4.1 of the main plan), so it never
decrypts on a hit and never holds ciphertext. The bus carries names, never
values (R13): with hashNames, hashed names.
cacheResponse stores frames as cache values (lib/http/frame.ts), and the
bun-nest decorators call getOrSet. Both inherit encryption and name hashing
with no change: they use createCache's cache. Hashing the HTTP keys
matters most (§7.2).
- Events keep plaintext names: they are the app's own in-process API.
- New: an optional
keyIdonhitandstale-hitfrom an encrypted entry, so a listener can watch an old key drain. - New:
phase: "encrypt" | "decrypt"onerror.
- New: an optional
- No event or error carries plaintext values or key material.
CacheDecryptError's message names the reason and the key id only. - Logs use the stored names under
hashNames(§7.1). stats()gains nothing: theerrorevents cover decryption failures.
- They stay plaintext integers.
INCRBY,$incandn = n + ?need the number, and an encrypted counter cannot be incremented by the backend. Order-preserving or homomorphic schemes are out of scope. - Their names are hashed under
hashNames, as entries' are. - Not refused. A counter is usually a rate-limit or hit count, so the
README documents it rather than refusing
incrementon an encrypted cache (§13 Q10).
- Every process sharing the cache needs the same keys (and
hashNameskey). One without them misses (§4.6). - A non-Bun reader (the main plan's Q8,
codec: "json") now also needs the keys and the envelope format, both specified in the README: HKDF-SHA-256, HMAC-SHA-256 and AES-256-GCM are in every standard library. acceptUnencrypted: truelets an encrypted cache read plain entries during a migration. Writes are always sealed. It is off by default: with it on, anyone who can write to the store can inject plaintext values the app accepts (§13 Q9).
validateruns after the entry is opened, on the decoded value, as today.- The stamps-mode tag check uses the sealed tags (§4.3), hashed again for
the stamp lookup when
hashNamesis on.
import { createCache, redisDriver } from "@kingsleyweb/bun-cache";
const cache = createCache({
driver: redisDriver({ url }),
compress: { algorithm: "br", level: 4, threshold: 4096 }, // or "zstd" (default), "gzip", "deflate", or a CacheCompressor
encrypt: {
keys: [
{ id: "2026-10", key: process.env.CACHE_KEY_2026_10! }, // the first encrypts
{ id: "2026-09", key: process.env.CACHE_KEY_2026_09! }, // still decrypts
],
// or: keys: async () => await loadKeysFromKms(), keyRefresh: 300_000,
// or: cipher: myCipher (a CacheCipher), previous: [oldCipher],
aad: "prod", // optional context bound into every entry
hashNames: { key: process.env.CACHE_NAMES_KEY! }, // optional; never rotated
// acceptUnencrypted: true, // migration only
},
});
await cache.set("img:1", pngBytes, { compress: false }); // already compressed
cache.describeKey("user:42", ["org:7"]); // { namespace: "3f…", key: "9a…", tags: ["c1…"] }interface CacheEncryptOptions {
/** The built-in cipher's keys: the first encrypts, all decrypt. A function may be async (a KMS). Required unless `cipher` is a CacheCipher. */
keys?: CacheKeyRing | (() => CacheKeyRing | Promise<CacheKeyRing>);
/** Re-call a `keys` function after this many ms. Default: never (an unknown key id still triggers one refresh per 10 s). */
keyRefresh?: number;
/** `"aes-256-gcm"` (the default) or a user CacheCipher. */
cipher?: "aes-256-gcm" | CacheCipher;
/** User ciphers that still decrypt. */
previous?: readonly CacheCipher[];
/** Extra context bound into every entry's AAD (an environment, an app id). Default: none. */
aad?: string | Uint8Array;
/** Hash keys, namespaces and tags in the store: an HMAC key (never rotated), or a function. Default: off. */
hashNames?: { key: string | Uint8Array } | { hash: (name: string, context: CacheNameContext) => string };
/** Read entries written without encryption (migration). Default: false. */
acceptUnencrypted?: boolean;
}
type CacheKeyRing = readonly CacheKey[];
interface CacheKey { id: string; key: string | Uint8Array }
interface CacheNameContext { kind: "namespace" | "key" | "tag"; namespace: string }
interface CompressOptions { // widened
algorithm?: CompressionEncoding | CacheCompressor; // "br" | "zstd" | "gzip" | "deflate", from bun-common
threshold?: number;
level?: number;
previous?: readonly CacheCompressor[];
}The user asked whether per-namespace or per-call overrides would help.
-
Per call: only
compress: false(§8.5). A per-callencrypt: falsewould make one leaked flag a plaintext entry. -
Per namespace: a
namespace(name, { encrypt })view would put two storage formats under one cache's names, and an encrypting view would then have to accept plaintext. That is the injection hole of §9.7. TwocreateCachecalls over one driver give the same result with no new option:const driver = redisDriver({ url }); const secret = createCache({ driver, namespace: "secret", encrypt: { keys } }); const pub = createCache({ driver, namespace: "public" });
The driver is shared, and its
close()runs once per cache. bun-cache's rule that a driver closes only what it created already covers this.
Each is a TypeError at createCache, naming the option:
- a key that is not 32 bytes;
- a malformed, empty or duplicate key id;
- an empty ring;
keysbeside a user cipher;- a cipher, compressor or
previousid outside 128–255, or one that duplicates another; hashNameswith no cipher;hashNames.keythe same bytes as an encryption key. This is allowed, because HKDF separates them, but warned;leveloutside its algorithm's range;aadover 1 KiB.
Each guarantee has a test and a negative control: a mutation that must fail it, recorded in the PR.
| Guarantee | Test | Negative control |
|---|---|---|
| Tamper is a miss | Flip a bit in each region: prefix, salt, nonce, body, tag | setAuthTag skipped, or AES-CTR: the test fails |
| Swap is a miss | Within a namespace, across namespaces, across caches with different aad |
AAD omitted: the cross-namespace case serves the wrong value (as spike B0b) |
Wrong or unknown key is a miss, not deleted, with an error ("decrypt") |
A ring without the writer's key; then the right ring reads the entry | Delete on failure: the second read misses |
| Rotation | Written under a, read with [b, a]; new writes under b |
Encrypting with the last key: the "new writes" assertion fails |
| Key provider | Async ring; an unknown id triggers one refresh; a rejection keeps the old ring | No early refresh: the peer's new key is never learned |
| Sealed tags win | Strip the tags in the store, then invalidateTags: a miss (stamps and index drivers) |
Using raw.tags: the entry survives (as spike C1) |
| Sealed expiry wins | Raise the clear expiresAt past the sealed one: a miss after the sealed one |
Using raw.expiresAt: served |
| Nonces and salts do not repeat | 100,000 seals, all distinct | A fixed salt: the test fails |
| No plaintext in any store | The kit's new rawDump factory field yields every byte a driver stored (rows, keys, objects, files, the bus). Sentinel values, keys, namespaces and tags are written, then searched for in UTF-8 and UTF-16 |
Encryption off: the values are found. hashNames off: the names are found |
acceptUnencrypted: false |
A plaintext entry injected into the store is a miss | Accepted: the test fails |
| User ciphers | runCacheCipherConformance on a sync and an async test cipher |
An AAD-ignoring cipher fails the helper |
| Compression | Each algorithm round-trips at the four sizes. Format-1 entries written by develop decode (byte fixtures pinned in the test). Format 2 is written only for br, deflate or user ids. An unknown compressor id is a miss. An async user compressor works. compress: false per call stores the payload uncompressed |
Format 2 always: the fixtures' rolling-deploy test fails. previous ignored: the old compressor's entries miss |
| Every driver | The conformance kit's full pass with encrypt and hashNames on, on all nine drivers, the two-process pass included |
— |
What rawDump covers, per driver:
| Driver | What it reads |
|---|---|
| file | Walks the root |
| SQLite | The database file, its -wal and -shm |
| SQL | SELECT * from the run's tables, cast to bytes |
| Redis | SCAN the prefix, then DUMP each key, plus its name |
| MongoDB | Every document of the run's collections as BSON |
| S3 | Lists the prefix, then GETs each object, with names and Content-Type |
| memory | Its maps, through a test hook |
Each cloud driver PR (C8–C11) adds its own.
The gate is the bun-cache gate in RULES.md and CLAUDE.md, with every
BUN_CACHE_TEST_*_URL set. PR-E2 touches every driver's kit pass, so it
runs every URL.
| PR | What it ships | Depends on | Effort |
|---|---|---|---|
| PR-Z1 compression | Format-2 header (written only when needed) and the dual-format decoder. "br" and "deflate", with per-algorithm default levels. CacheCompressor (sync or async) and compress.previous. Per-call compress: false. The async-transform plumbing in #write, #decodeStored, touch and the refresh rewrite. Fixture tests for format-1 entries; README |
— | ~2 d |
| PR-E1 encryption | The envelope and the built-in "aes-256-gcm" with per-entry keys. Keys, ids and rings; keys functions with keyRefresh and the unknown-id refresh. aad. Sealed tags and expiry, used by the core. CacheDecryptError and the "encrypt"/"decrypt" phases, with no deletion on failure. acceptUnencrypted. CacheCipher with previous. runCacheCipherConformance. The keyId on hit events. Tests in §11 except rawDump; README "Encryption at rest" with the rotation runbook and the KMS pattern |
PR-Z1 (the async plumbing) | ~4 d |
| PR-E2 names | hashNames (HMAC and user hash) in the core: names, tags, the bus, the stamp cache, logs. describeKey. The kit's rawDump field, implemented in the tests of all nine drivers. The kit's full pass with encryption and hashing on, every driver |
PR-E1 | ~3.5 d |
| PR-E3 docs and playground | README sections finished. A playground item showing a value as stored (sealed bytes, hashed names) beside the decoded one. The change report to the examples agent, who writes the examples | PR-E2 | ~1 d |
| Total | ~10.5 d |
- Order. Z1 → E1 → E2 → E3. Z1 is core-only and could also land before
or between the cloud drivers. Each cloud driver PR merged after E2 adds a
rawDump; one merged before gets it in E2. - Reviews. The bun-common agent reviews Z1, which imports
CompressionEncoding. - Owners. The bun-cache role owns all four PRs. No other package changes: bun-common is untouched unless §13 Q12 says otherwise.
Each question has a recommended answer.
- Where encryption lives. In the core, as here, or as a driver wrapper
(
encryptDriver(driver))? Recommended: the core (§4.1). A wrapper cannot hash names consistently with L1 and the bus, and a codec cannot bind ciphertext to its key (spike A). - The built-in cipher. Recommended: AES-256-GCM through
node:crypto, with a key per entry derived from a 128-bit salt.- The alternative is plain random-nonce GCM, which saves 0.7 µs per seal but reaches NIST's limit in 5 days at 10,000 writes/s (§4.4).
- ChaCha20-Poly1305 through
crypto.subtleis async and slower here. - We offer no second built-in; a
CacheCiphercovers the rest.
- Sync or async user ciphers and compressors. Recommended: either
(
T | Promise<T>, awaited only when a promise). The cost is 0.36 µs per op at 100 B, paid only by async ones. The built-ins stay sync (§6.2, §8.1). - The names key. Recommended: a separate key (
hashNames: { key }), never rotated, since rotating it flushes the cache. The alternative, deriving it from the primary encryption key, would flush on every rotation. - The option's name:
hashNamesorhashKeys. Recommended:hashNames. It hashes namespaces and tags too, not only keys. - Passphrases. Recommended: not in v1. Keys are exactly 32 bytes,
generated with
openssl rand -base64 32. A passphrase helper (scrypt N = 2^15, 85 ms, with a required deployment salt) can come later if asked for. - Re-encrypt on read during a rotation. Recommended: not in v1. TTL turnover re-encrypts, and the runbook (§5.2) needs no writes on reads.
- Delete an entry that does not open? Recommended: no. A miss and an
errorevent only. Deleting would let one misconfigured process wipe its peers' entries during a key rollout (§4.6). acceptUnencryptedfor migrating an existing cache. Recommended: yes, off by default, documented as a migration-window setting that lets a store writer inject values.- Counters. Recommended: plain integers, with hashed names and
documented; no option to refuse
incrementon an encrypted cache (§9.6). - Overrides. Recommended: none per namespace (two
createCachecalls over one driver, §10.3); per call,compress: falseonly. - Compression names and sharing with bun-common. Recommended:
- spell the algorithms
"br","zstd","gzip"and"deflate", typed by importing bun-common'sCompressionEncoding, not"brotli"; - share no runtime code;
- mirror the Brotli quality 4 in a comment, rather than adding a
DEFAULT_BROTLI_QUALITYexport to bun-common.
- spell the algorithms
- Compression defaults. Recommended:
- zstd stays the default;
- the threshold stays 16 KiB for every algorithm (lowering it changes every existing deployment's CPU and size);
- the default levels are zstd 3, gzip 6,
br4 and deflate 6; deflateis raw DEFLATE.
- Size padding (hiding exact entry sizes). Recommended: no. It costs space on every entry, and sizes stay visible within the padding anyway. Documented as not protected (§2.2).
- The
aadoption. Recommended: include it. It is cheap, and stops a staging cache from opening production entries that share a key. - An error class for failed opens. Recommended:
CacheDecryptError, withreason("auth","unknown-key","unencrypted","malformed","cipher") andkeyId. Rotation mistakes can then be monitored without parsing messages. - When to build it. Recommended: PR-Z1 next, since it is core-only
and small. Then E1 to E3, before or after the cloud drivers C8–C11, as
the user prefers. A cloud driver landing after E2 brings its own
rawDump. - A decompression cap (found while planning, §8.4). Today any entry's
decompressed size is unbounded, so a store writer can plant a
decompression bomb in a plain cache. Recommended: a follow-up, not
part of this plan. Cap the output at a multiple of
maxEntryBytes(zstd's frame header states its size; gzip and Brotli need a streaming limit), as its own small PR with its own name.
Every new public or persisted name. Nothing here is in the main plan's §21.
| Name | Kind | Where | Why this name |
|---|---|---|---|
encrypt |
option | CreateCacheOptions |
The verb, beside compress |
CacheEncryptOptions |
type | root | After CompressOptions |
keys, keyRefresh, cipher, previous, aad, hashNames, acceptUnencrypted |
options | CacheEncryptOptions |
aad is the AEAD term; previous is also used by compress |
CacheKey (id, key), CacheKeyRing |
types | root | A ring is the usual term for a set of rotating keys |
"aes-256-gcm" |
cipher name | cipher |
node:crypto's spelling |
CacheCipher (id, seal, open), CacheCipherContext (namespace, key, aad) |
types | root | Seal and open are the AEAD verbs; not encrypt/decrypt, which the option already uses |
CacheNameContext (kind, namespace) |
type | root | The argument of hashNames.hash |
describeKey(key, tags?) → { namespace, key, tags } |
method | Cache |
It says what it answers |
CacheDecryptError (reason, keyId) |
error class | root | After CacheDriverError |
"encrypt", "decrypt" |
values | CacheErrorPhase |
Beside "decode" and "validate" |
keyId |
event field | hit, stale-hit |
Optional; only on encrypted entries |
"br", "deflate" |
values | CompressOptions.algorithm |
bun-common's CompressionEncoding spellings |
CacheCompressor (id, compress, decompress) |
type | root | After Codec |
previous |
option | CompressOptions |
Matches encrypt.previous |
compress: false |
per-call option | set, setMany, getOrSet, wrap |
The cache option's name |
runCacheCipherConformance |
function | ./testing |
After runCacheDriverConformance |
rawDump |
factory field | the conformance kit's factory result | It yields what the driver stored, raw |
"BE" envelope v1; entry format 2 with a compressor-id byte; cipher ids 1 and 128–255; compressor ids 0–4 and 128–255 |
persisted formats | stored bytes | Specified in the README for other readers |
bun-cache/enc/1, bun-cache/enc/1/aes-256-gcm, bun-cache/names/1, "entry", "ns", "key", "tag" |
persisted labels | HKDF info, AAD and HMAC inputs |
Changing one changes every stored entry or name |
| Risk | Likelihood | Mitigation |
|---|---|---|
| A misconfigured key turns a cache into all misses (a typo, a missing env var in one service) | Medium | error events and a rate-limited warn with the key id. A key that is not 32 bytes fails at createCache, not at first read. The README's rotation runbook |
| A user cipher that ignores the AAD, or reuses nonces | Medium | runCacheCipherConformance, linked from the CacheCipher JSDoc and the README |
| The names key is lost or changed, which is a full cache flush | Low | It is documented as like a version bump, and it is a separate option, so a rotation of the encryption keys cannot change it by accident |
Bun's node:crypto changes (ChaCha20 arrives in #8072; a regression) |
Low | The built-in uses only createCipheriv("aes-256-gcm"), HMAC and HKDF, all in Node's test suite that Bun runs. The bench scripts re-run on an upgrade |
| Compression oracle on mixed secret and attacker data | Low, but real | Documented. compress: false per call, or a separate cache (§2.2) |
| The format-2 header during a rolling deploy | Certain, once | Written only when br, deflate or a user compressor is chosen, so only the deploy that turns one on sees misses |
Debugging is harder under hashNames |
Certain | describeKey. Logs use the stored names; events keep the plaintext |
| Small-value overhead (+1–2 µs CPU per op; +56 B per entry) | Certain | Measured and documented. The overhead is under the cost of a networked round trip by two orders of magnitude |
evidence/encryption/ indexes each script,
its command and its captured output. The prior art behind §4.2 (XAES-256-GCM,
NIST SP 800-38D) is cited, not copied.