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 chacha20poly1305 or aes-gcm and 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.

Layout of a chunked encrypted file The file starts with a header holding a format version, a random file nonce prefix and the wrapped key or salt. Each chunk follows as ciphertext plus a 16-byte tag. Chunk nonces combine the prefix with the chunk index and a final-chunk flag, so chunks cannot be reordered or truncated. encrypted file: header, then chunks of ciphertext + 16-byte tag header chunk 0 + tag chunk 1 + tag chunk 2 + tag last + tag 0 header end EOF

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 || tag for each chunk of exactly chunk_size plaintext 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.

Encrypting and uploading a large file in chunks The worker writes the header, then reads the file one chunk at a time, encrypts each chunk with a nonce built from the file prefix, chunk index and last flag, and uploads the ciphertext immediately. Memory stays bounded by one chunk. header + random prefix authenticated with each chunk read 1 MiB chunk file.slice() encrypt_chunk nonce = prefix | index | last upload ciphertext as it is produced repeat to EOF last flag on final chunk

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.

Client-side encryption throughput Megabytes per second encrypting a large file in 1 MiB chunks with ChaCha20-Poly1305 in Wasm without and with SIMD, AES-GCM through WebCrypto, and a typical home upload link for comparison. MB/s ChaCha20-Poly1305, Wasm 310 MB/s ChaCha20-Poly1305, Wasm SIMD 520 MB/s AES-GCM, WebCrypto 1,800 MB/s typical upload link 12 MB/s

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.

← Back to Cryptography & Untrusted Code