Zero-knowledge upload via AES-GCM-256 + PBKDF2
Every queued file is encrypted in the browser with AES-GCM-256 before upload begins; your backend only ever sees ciphertext. Decryption requires the password and the per-file salt + IV emitted as X-Mu-Encryption-* headers. Compliance-friendly default for HIPAA / GDPR confidentiality workflows. None of Uppy, FilePond, Dropzone, or FineUploader offer this built-in.
The password never leaves the browser. Server only sees ciphertext + the salt & IV needed to decrypt later.
Try it: drop a file, hit upload, inspect the request body in DevTools -> Network. Payload is unintelligible binary; six headers carry the metadata needed to decrypt later.
Configuration
new MultipleUpload('#uploader', {
uploadUrl: '/api/upload',
encrypt: true, // or 'aes-gcm-256'
encryptPassword: 'shhh',
// OR for a prompt-driven UI:
// encryptPassword: () => promptUser(),
// OR for a pre-derived CryptoKey:
// encryptKey: cryptoKey,
encryptIterations: 200000 // PBKDF2 default
});Wire-protocol headers (sent on every chunk / single upload)
X-Mu-Encryption-Algo: aes-gcm-256 X-Mu-Encryption-Salt: <16 random bytes, base64url> X-Mu-Encryption-IV: <12 random bytes, base64url> X-Mu-Encryption-Iter: 200000 X-Mu-Encryption-Original-Name: <URL-encoded cleartext filename> X-Mu-Encryption-Original-Size: <cleartext byte count>
Decryption in the browser
// Static helper: pass ciphertext blob, password, salt, iv (from response headers) const cleartext = await MultipleUpload.decryptFile( ciphertextBlob, password, saltFromHeader, ivFromHeader, 200000 // iterations, must match upload ); // cleartext is a Blob - show it, save it, or pipe into a viewer
Decryption in Node.js
import { webcrypto as crypto } from 'node:crypto';
async function decrypt(ciphertext, password, saltB64, ivB64, iterations = 200000) {
const b64u = s => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
const baseKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password),
'PBKDF2', false, ['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: b64u(saltB64), iterations, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 }, false, ['decrypt']
);
return Buffer.from(await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: b64u(ivB64) }, key, ciphertext
));
}Algorithm details
- Cipher: AES-256-GCM (authenticated encryption - tamper-evident).
- KDF: PBKDF2-HMAC-SHA256, 200,000 iterations by default (tunable).
- Salt: 128-bit random per file. Never reused.
- IV: 96-bit random per file. Never reused with the same key.
- Auth tag: 128-bit GCM tag appended to ciphertext (handled by SubtleCrypto).
Safety
- Insecure context (http://): SubtleCrypto unavailable -> encrypt task fails fast with
encryption_failed. No silent fallback to cleartext. - Missing password: queued files fail with a clear error before any byte leaves the browser.
- Resume after reload: with
persistStatethe ciphertext is what's persisted - re-derivation isn't needed on resume.