The one-shot API that can't encrypt a 4 GB file
Call crypto.subtle.encrypt on a 4 GB video and the tab dies. Not because the crypto is slow — because the Web Crypto API is one-shot. encrypt() takes a single ArrayBuffer and returns a single ArrayBuffer. There is no update(), no final(), no stream. To encrypt a file you have to hold the entire plaintext in memory, then hold the entire ciphertext next to it. Peak memory lands at roughly twice the file size, and you hit the tab's allocation ceiling long before you run out of disk.
Every client-side-encryption project walks into this wall. The fix is not a bigger buffer. It's refusing to treat the file as one object.
Why crypto.subtle.encrypt can't stream
The W3C shipped SubtleCrypto without streaming on purpose. There's an open issue from 2016 asking for it; it's still open in 2026. The data parameter is monolithic by design — the spec authors wanted a small, hard-to-misuse surface, and incremental AEAD is genuinely easy to get wrong. The result is an API that's fine for encrypting a session token and useless for a 2 GB upload.
You can't change the API, so you change the unit of work. Instead of encrypting one 4 GB plaintext, you encrypt a few thousand 4 MB plaintexts and stitch the ciphertexts together. Each encrypt() call is small, one-shot, and well within what any browser handles. The file streams through a fixed-size window, and memory stays flat no matter how big the file gets.
That's the whole idea. The hard part is doing it without quietly breaking the security properties that made you reach for AES-256-GCM in the first place.
The chunk format, byte for byte
GCM is an AEAD: every encryption produces ciphertext plus a 16-byte authentication tag, and it consumes a nonce. Split a file into chunks and each chunk becomes an independent AEAD message that needs its own nonce and its own tag. Beebeeb's shared crypto core writes each chunk as one self-describing frame:
nonce(12) || ciphertext || tag(16)
Twelve bytes of nonce, the AES-256-GCM ciphertext (same length as the chunk's plaintext), sixteen bytes of GCM tag — 28 bytes of overhead per chunk. A 1 GB file at 4 MB chunks pays about 7 KB total for that. Cheap.
The nonce is freshly random for every chunk, and this is the part you cannot get lazy about. AES-GCM with a 96-bit nonce fails catastrophically on reuse: encrypt two messages with the same key and nonce, and an attacker can recover the GCM authentication key and forge ciphertexts. The tempting "chunk index as counter" scheme becomes a trap the moment you re-encrypt a file or resume a partial upload and an index gets reused. A random 96-bit nonce per chunk, under a per-file key derived with HKDF, sidesteps the whole class of bug.
Does a fresh random nonce per chunk run out?
It has a ceiling, and the exact number is the point. NIST SP 800-38D caps a single AES-GCM key at 2^32 invocations when nonces are random — about 4.3 billion — to keep the probability of a repeated nonce under 2^-32. A per-file key resets that budget for every file, so the limit is per file, not per account. The math: a 99 TB file (beebeeb's self-serve ceiling — up to 99 TB self-serve, custom quote beyond) at 4 MB chunks is roughly 26 million chunks. That's three orders of magnitude under the bound, and the chunk size scales up for genuinely enormous files so the margin holds.
Saying the limit out loud is the discipline. "Random nonce per chunk" without the 2^32 arithmetic written down is how a project ships something that's safe for a 1 GB file and silently unsafe at petabyte scale.
Keep the tab alive: run it in a Web Worker
Chunking solves memory. It does nothing for jank. AES-256-GCM across a few thousand chunks is real CPU work, and on the main thread it freezes the UI — frozen scroll, a stuck progress bar, eventually the "page unresponsive" dialog. The encryption loop belongs in a Web Worker, off the main thread.
The shape that holds up in a browser:
- The worker keeps a handle to the
File/Bloband slices it withblob.slice(start, end), which is lazy — no bytes leave disk until you call.arrayBuffer()on the slice. - For each slice: read it, encrypt it into a
nonce || ct || tagframe, hand the frame to the uploader, drop the plaintext. Peak plaintext in memory is one chunk, never one file. - The main thread receives
postMessageprogress events and keeps painting. The user watches a live byte counter instead of a spinner that lies.
Beebeeb's web client runs its chunk loop in WASM inside that worker. Single-threaded WASM can't read a browser File the way a native binary reads a file descriptor, so the design inverts: JavaScript does the slicing and pushes each chunk into the WASM encryptor, which returns the finished Uint8Array frame with the nonce and tag already attached. No reassembly in JS means no chance of the JS side fumbling the wire format.
One encrypt loop, shared across clients
Here's the decision that keeps the whole thing honest. The chunk loop — nonce generation, framing, per-file key derivation, the end-of-stream integrity check — lives in one Rust crate, not copy-pasted into each client. Today two clients run it: the command-line client pulls chunks from a file descriptor, and the web app pushes chunks from a sliced Blob through WASM. The native bindings the mobile and desktop apps will use — UniFFI for Swift and Kotlin — already live in that same crate, so when those apps ship they reach the identical primitive rather than a re-implementation. The wire format is identical because it is literally the same code. A file encrypted by the CLI decrypts in the browser and back again, and a parity test fails the build if the two ever drift.
That matters more than it reads. The classic way client-side encryption rots is subtle divergence: one client picks a slightly different chunk size or nonce scheme than another, and a file written by one can't be read by the other — or worse, one of them reuses a nonce and nobody notices for a year. One loop, one wire format, no second implementation to drift. The shipped clients that run this — the web app and the CLI — are open source, so the claim is auditable rather than asserted.
The integrity guard most implementations skip
Per-chunk GCM tags authenticate each chunk on its own. They do not stop an attacker — or a buggy server — from dropping the last chunk, reordering the stream, or truncating it, because each surviving chunk still verifies fine. So the encryptor carries a plan: expected chunk count and expected total ciphertext (file_size + 28 * chunk_count). The finish() call refuses to succeed unless every planned chunk was emitted and the byte totals match. A source that shrank mid-upload gets rejected, not silently truncated.
It's honest about the edge it doesn't cover. If a file is an exact multiple of the chunk size and grows after the size was read, the plan-driven loop stops at the original count and ignores the appended bytes — caught on the next sync by content hashing, not by this guard. Naming that case in the code comments is what separates an engineering commitment from a marketing checkbox. "Encrypted" is the easy word; encrypted, streamed, authenticated end to end, and byte-identical on every client is the actual work.
If you want to poke at it, beebeeb is zero-knowledge on every tier, no exceptions — and the encryption above runs before a single byte leaves your machine. You can read how the keys are derived before you trust any of it.