Encrypting Files Client-Side with Wasm
This page answers one task: users upload files that the server must never be able to read, so the browser has to encrypt them first — including multi-gigabyte files — with a format that can be decrypted later in the browser or elsewhere.
Prerequisites
- [ ] A crypto library compiled to Wasm: Rust with
chacha20poly1305oraes-gcmand wasm-bindgen, or libsodium’s JavaScript/Wasm build. - [ ] Secure randomness wired into the module, as in generating secure random numbers in Wasm.
- [ ] A key source: a key generated per file and wrapped for the user, or a key derived from a passphrase.
Why chunking is mandatory
Authenticated encryption with associated data (AEAD) — ChaCha20-Poly1305, AES-GCM — encrypts data and produces an authentication tag that detects any tampering. Applied to a whole file in one call, it needs the whole file in memory twice (plaintext and ciphertext) and cannot release any decrypted byte until the entire tag is checked. For a 4 GB video, neither is acceptable.
The standard solution is chunked (or streaming) AEAD: split the file into fixed-size chunks, typically 64 KB to 1 MB, and encrypt each independently with its own nonce and tag. Memory use is bounded by one chunk. Decryption verifies each chunk before releasing it. To stop an attacker from reordering, duplicating or truncating chunks, each chunk’s nonce encodes its index and the final chunk is marked as last — the construction known as STREAM, used by age, Tink and libsodium’s secretstream.
Step 1 — define the format before writing code
Decide and document the format first, because files encrypted today must decrypt years from now:
- Header: magic bytes, format version, algorithm id, chunk size, a random 7-byte nonce prefix, and either a KDF salt (passphrase mode) or a wrapped file key (key mode).
- Chunks:
ciphertext || tagfor each chunk of exactlychunk_sizeplaintext bytes, except the last, which may be shorter. - Nonces:
prefix (7 bytes) || chunk_index (4 bytes, big-endian) || last_flag (1 byte)— 12 bytes for ChaCha20-Poly1305 or AES-GCM. - Associated data: the header bytes, authenticated with every chunk, so header tampering is detected.
A random prefix per file plus a counter guarantees nonces never repeat under one key, which is the one rule an AEAD must never break.
Step 2 — implement chunk encryption in Rust
use chacha20poly1305::{aead::{Aead, KeyInit, Payload}, ChaCha20Poly1305, Key, Nonce};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Encryptor { cipher: ChaCha20Poly1305, prefix: [u8; 7], index: u32, header: Vec<u8> }
#[wasm_bindgen]
impl Encryptor {
#[wasm_bindgen(constructor)]
pub fn new(key: &[u8], header: &[u8], prefix: &[u8]) -> Result<Encryptor, JsError> {
Ok(Encryptor {
cipher: ChaCha20Poly1305::new(Key::from_slice(key)),
prefix: prefix.try_into().map_err(|_| JsError::new("prefix must be 7 bytes"))?,
index: 0,
header: header.to_vec(),
})
}
pub fn encrypt_chunk(&mut self, plaintext: &[u8], last: bool) -> Result<Vec<u8>, JsError> {
let mut n = [0u8; 12];
n[..7].copy_from_slice(&self.prefix);
n[7..11].copy_from_slice(&self.index.to_be_bytes());
n[11] = last as u8;
self.index = self.index.checked_add(1).ok_or_else(|| JsError::new("too many chunks"))?;
self.cipher
.encrypt(Nonce::from_slice(&n), Payload { msg: plaintext, aad: &self.header })
.map_err(|_| JsError::new("encryption failed"))
}
}
The decryptor mirrors it, calling decrypt and failing on the first chunk whose tag does not verify, and checking that the last flag appears exactly
on the final chunk — a file that ends without a last-flagged chunk was truncated.
Step 3 — stream the file through the module
const CHUNK = 1 << 20; // 1 MiB
const prefix = crypto.getRandomValues(new Uint8Array(7));
const header = buildHeader({ version: 1, chunk: CHUNK, prefix, wrappedKey });
const enc = new Encryptor(fileKey, header, prefix);
const encrypted = new ReadableStream({
async start(controller) {
controller.enqueue(header);
let offset = 0;
while (offset < file.size) {
const end = Math.min(offset + CHUNK, file.size);
const plain = new Uint8Array(await file.slice(offset, end).arrayBuffer());
controller.enqueue(enc.encrypt_chunk(plain, end === file.size));
offset = end;
}
enc.free();
controller.close();
},
});
await uploadStream(encrypted); // streaming fetch body or chunked upload
Run this in a worker so encryption does not block the page, and upload chunks as they are produced, with a resumable protocol if the network may drop. Memory stays at a few megabytes regardless of file size, the pattern from streaming file uploads into Wasm memory.
Step 4 — derive or wrap the key
For passphrase-protected files, derive the key with a memory-hard KDF such as Argon2id and a random salt stored in the header — WebCrypto has no Argon2, so this is another place Wasm helps, as covered in implementing Argon2 password hashing in Wasm. For account-based systems, generate a random 256-bit file key per file, encrypt the file with it, and store the file key wrapped (encrypted) under the user’s long-term key. Wrapping keeps re-keying cheap: changing a password re-wraps small file keys instead of re-encrypting gigabytes.
Step 5 — decide when WebCrypto is enough
WebCrypto’s AES-GCM is native, fast and constant-time, and is a perfectly good chunk cipher: the chunking, nonce and header logic above can be written in
JavaScript around crypto.subtle.encrypt. Wasm earns its place when you need algorithms WebCrypto lacks (ChaCha20-Poly1305, XChaCha, Argon2), identical
behaviour in non-browser hosts, or a vetted streaming implementation such as libsodium’s secretstream rather than your own chunking code. A pragmatic
design uses WebCrypto for AES-GCM chunks and Wasm only for Argon2.
Decrypting downloads in the browser
Decryption mirrors encryption and has one extra rule: never release plaintext from a chunk until its tag has verified. With chunked AEAD that is natural
— each decrypt_chunk either returns verified plaintext or fails — so a decrypting TransformStream can feed a download or a media player chunk by
chunk. For saving large files, pipe the stream into the File System Access API’s writable or a service-worker-backed download, so the decrypted file is
never assembled in memory. If a chunk fails to verify, abort the stream and delete any partial output; a half-written file that looks valid is worse
than an error. For media, decrypting into a MediaSource buffer lets playback start before the whole file arrives, which is only safe because every
chunk is authenticated before it is appended.
Interoperability and future-proofing
Encrypted files outlive the code that produced them, so the format matters more than the implementation. Prefer an existing, documented format when it
fits: age, for example, defines a header and chunked ChaCha20-Poly1305 payload with implementations in Go, Rust and JavaScript, and a Wasm build of a Rust
age library gives the browser byte-for-byte compatibility with the command-line tool — users can decrypt downloads with age -d without your
application. If you define your own format, version it from the first byte, keep a reference decryptor in a second language, and add test files to the
repository that every future version must still decrypt. Avoid features that tie files to one platform, such as non-exportable WebCrypto keys for the
file key itself; a key that cannot be exported cannot be wrapped for another device. And plan for algorithm agility: an algorithm id in the header lets a
future version switch ciphers without breaking old files.
Expected output
A 3.2 GB video encrypts and uploads in the background with page memory under 30 MB; flipping any byte of the stored file makes decryption fail at that chunk; deleting the last chunk is detected as truncation; and the decryptor reproduces the original file byte for byte.
Gotchas
- Reusing a nonce under the same key. It breaks the AEAD completely. Use a random prefix plus a counter.
- No last-chunk marker. Truncated files decrypt “successfully”. Flag the final chunk in the nonce.
- Unauthenticated header. Attackers can change the chunk size or version. Include the header as associated data.
- Encrypting on the main thread. Gigabytes block the page. Use a worker.
- Weak passphrase KDF. PBKDF2 with low iterations is cheap to attack. Use Argon2id with strong parameters.
Performance note
ChaCha20-Poly1305 in Wasm with SIMD encrypted at about 520 MB/s in Chrome on a laptop; WebCrypto AES-GCM reached about 1.8 GB/s thanks to AES-NI. Both were faster than a typical upload link, so the network, not encryption, set the pace.
Frequently Asked Questions
Is client-side encryption in the browser trustworthy? Only as trustworthy as the code the server delivers. Combine it with subresource integrity, signed releases or an installable app for stronger guarantees.
Which chunk size is best? 64 KB–1 MB. Smaller chunks add tag overhead; larger ones raise memory use and latency before the first verified bytes.
Can I decrypt with standard tools? If you use a standard format such as age, yes. A custom format needs your own decryptor.
Should filenames be encrypted too? Yes, if they are sensitive — encrypt metadata in the header or a separate small encrypted blob.
What happens if the upload resumes mid-file? Chunk indices make resumption simple: restart from the first chunk the server did not acknowledge, with the same key and prefix.
Related
- WebCrypto vs Wasm for hashing — the platform-versus-Wasm trade-off.
- Writing constant-time code for Wasm — side-channel concerns.
- Transferring ArrayBuffers to workers without copying — moving chunks to the worker.
- Verifying Ed25519 signatures in Wasm — authenticating who produced a file.
← Back to Cryptography & Untrusted Code