Progressieve encryptie van grote bestanden: stream en versleutel
Versleutel grote bestanden progressief met streaming API's. Verwerk multi-GB bestanden zonder geheugengebrek.
Progressieve (streaming) versleuteling verwerkt een bestand chunk voor chunk zonder ooit de volledige payload in het geheugen te laden. Voor een upload van 10 GB in een browser is dit het verschil tussen een app die werkt en een die crasht. Het patroon: lees een chunk via File.stream(), versleutel het met AES-256-GCM met een unieke nonce, pipe de ciphertext direct naar een uploadstroom via fetch met een ReadableStream-body, geef de buffer vrij en ga verder. Geheugen blijft begrensd op 4-16 MB ongeacht de bestandsgrootte. libsodium's crypto_secretstream_xchacha20poly1305 voegt correcte streaming AEAD-semantiek toe inclusief afkappingsdetectie. Hier is de concrete implementatie, met cijfers die standhouden op echte hardware.
Waarom gebufferde versleuteling faalt
Een FileReader.readAsArrayBuffer(file) op een bestand van 10 GB wijst 10 GB in het browsergeheugen toe. Op desktop Chrome met 32 GB RAM kan dit werken. Op mobiele Safari met een geheugengrens van 400 MB per tab crasht het voor de voltooiing. Op Firefox raakt een ArrayBuffer boven de 2 GB interne V8-grenzen en gooit RangeError.
Zelfs op hardware die de toewijzing aankan, blokkeert het vasthouden van 10 GB de garbage collection en triggert pathologische paging. Het juiste antwoord is de volledige buffer nooit toe te wijzen.
Het streaming patroon
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
async function enqueueEncrypted(controller, chunk, index, key) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(index));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
controller.enqueue(new Uint8Array(ct));
}
Twee sleutel-API's: File.stream() geeft een ReadableStream van bestandsinhoud; fetch met een ReadableStream-body streamt de upload zonder de volledige body te bufferen. duplex: "half" is vereist in Chrome 105+ voor streaming verzoekbodies.
Geheugengebruik: op elk moment één bronchunk, één gebufferd restant, één versleuteld chunk. Piek ~12-16 MB voor een chunkgrootte van 4 MB.
Nonce-beheer in stromen
Elke chunk heeft een unieke nonce nodig. Drie benaderingen:
Teller-gebaseerd: codeer de chunk-index in de 96-bits nonce. Stel de bovenste 32 bits in op een willekeurig prefix (om botsingen te vermijden over bestanden die dezelfde sleutel gebruiken), lagere 64 bits op de chunk-index.
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
Willekeurig per chunk: crypto.getRandomValues(new Uint8Array(12)). Veilig voor per-bestand sleutels; verjaardag-botsingen over chunks treffen ~2^48. Sla de nonce op naast de ciphertext van elk chunk.
Afgeleid via HKDF: gebruik HKDF om per-chunk sleutels af te leiden, gebruik dan een vaste nonce. Overkill voor de meeste gevallen.
Voor een verse per-bestand sleutel is teller-gebaseerd het eenvoudigst en vermijdt de noodzaak om een aparte nonce per chunk op te slaan.
Afkapingsaanvallen en hoe ze te detecteren
Een kritische leemte in naïeve gesegmenteerde AES-GCM: een aanvaller kan afsluitende chunks weggooien en elk overgebleven chunk ontsleutelt prima. Detectie vereist het aan elkaar binden van chunks.
Optie 1: voeg het totale chunk-aantal toe in de AAD van elk chunk. De ontvanger verifieert dat het aantal overeenkomt met wat ontvangen is.
const aad = new TextEncoder().encode(JSON.stringify({
totalChunks,
fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
key,
chunk
);
Optie 2: gebruik libsodium's crypto_secretstream_xchacha20poly1305. Het ketent chunks cryptografisch en zendt een TAG_FINAL-markering uit die de ontvanger verifieert:
const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Voor elk chunk, push met TAG_MESSAGE
// Voor het laatste chunk, push met TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
state, lastChunk, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);
Bij ontsleuteling verifiëren de pull-aanroepen van de ontvanger de keten en detecteren ontbrekende staartchunks. Dit is de overzichtelijkste optie als je bereid bent libsodium.js te verzenden.
Streaming ontsleuteling aan de ontvangstzijde
Symmetrisch patroon aan de ontvangstzijde:
async function streamDecrypt(downloadURL, key, onChunk) {
const response = await fetch(downloadURL);
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // plus GCM-tag
while (true) {
const { done, value } = await reader.read();
if (done) break;
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer);
newBuf.set(value, buffer.length);
buffer = newBuf;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
const iv = makeNonce(chunkIndex++);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
onChunk(new Uint8Array(pt));
}
}
// Verwerk het laatste gedeeltelijke chunk
if (buffer.length > 0) {
const iv = makeNonce(chunkIndex);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
onChunk(new Uint8Array(pt));
}
}
Aan de ontvangstzijde kunnen onChunk-callbacks ontsleutelde bytes doorsluizen naar de File System Access API voor directe schijfwrites, of samenvoegen in een Blob voor een browser-native download.
Schrijven naar schijf via File System Access API
Voor zeer grote downloads verslaat het laden van het volledige ontsleutelde resultaat in een Blob het doel van streaming. De File System Access API (Chrome 86+, gedeeltelijk Safari via OPFS) laat de ontvanger een lokaal bestand kiezen en chunks direct schrijven:
const handle = await window.showSaveFilePicker({
suggestedName: "ontsleuteld-bestand",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
Geheugen blijft begrensd omdat chunks onmiddellijk naar schijf gaan. De UI toont realistische voortgang. Gebruikers kunnen halverwege de download annuleren.
Firefox ondersteunt showSaveFilePicker nog niet op desktop. Gebruik als terugval het bouwen van een Blob in het geheugen (prima voor bestanden onder een paar honderd MB) of het Origin Private File System voor multi-GB Firefox-workflows.
Upload streaming via fetch
Chrome 105+ en Firefox 127+ ondersteunen streaming verzoekbodies met duplex: "half". Daarvóór moesten uploads ofwel complete buffers zijn of multipart met handmatig afgehandelde gesegmenteerde overdrachtscodering.
Voor S3-compatibele multipart-uploads wordt elk deel als een apart verzoek geüpload. Splits de versleutelde stroom in delen van 5-25 MB (S3 minimale deelgrootte is 5 MB, maximum is 5 GB) en sluit af met de definitieve CompleteMultipartUpload-aanroep. Dit werkt in alle browsers en geeft je gratis hervatten bij onderbrekingen.
Voortgangsrapportage over stromen
Bijhouden van verwerkte bytes:
let processed = 0;
const onChunk = (chunkSize) => {
processed += chunkSize;
updateProgressBar(processed / file.size);
};
Begrens voortgangsupdates tot 10-20 Hz met requestAnimationFrame om verspilde herverfingeringen te vermijden. Op bestanden van 10 GB bij verwerkingssnelheid van 100 MB/s zijn dat nog steeds 100 updates per seconde aan ruwe gebeurtenissen, veel meer dan de UI nodig heeft.
Benchmarks op een bestand van 10 GB
Op een MacBook Pro uit 2024 (M3 Max) met een snelle SSD: onbewerkt lezen van schijf via File.stream() draait op 2,5 GB/s, AES-256-GCM via Web Crypto op 1,7 GB/s, de gecombineerde pipeline op 1,1 GB/s (begrensd door de seriële keten), upload over Gigabit Ethernet op 115 MB/s (netwerkgebonden), geheugen maximaal 14 MB ongeacht bestandsgrootte. Mobiele cijfers zijn ruwweg 30-50% van desktop. Een bestand van 10 GB uploadt in ~90 seconden op Gigabit, ~15 minuten op een typische thuisverbinding. Versleuteling is niet de bottleneck — het netwerk is dat.
Foutherstel
Netwerkonderbrekingen tijdens een upload van 10 GB zijn gebruikelijk. Strategieën:
- Hervatbare uploads via multipart: elk deel is onafhankelijk; herlaad alleen het mislukte deel.
- tus-protocol: open hervatbaar uploadstandaard ondersteund door bedrijven als Vimeo; stream-native.
- Houd bronbestandshandle open: als
File.sliceherhaalbaar is, begin opnieuw bij het laatste succesvolle chunk.
Het 10 GB-limiet van HexaTransfer is haalbaar in een enkele browsertab omdat deze streaming pipeline geheugen begrensd houdt en onderbrekingen netjes afhandelt met multipart-herpoging. Hetzelfde patroon schaalt op naar grotere limieten als je backend het ondersteunt.
De korte versie
Wijs niet het hele bestand toe. Lees in chunks, versleutel in chunks, upload in chunks, geef elk chunk vrij terwijl je doorgaat. Bind chunks cryptografisch met AAD of streaming AEAD om afkapping te verslaan. Voortgangsbalken overal. Test op mobiel, niet alleen op desktop.
Probeer het op hexatransfer.com — gratis, zonder account, tot 10 GB.
Verstuur grote bestanden veilig met end-to-end-versleuteling
Draag bestanden tot 10 GB gratis over met end-to-end-versleuteling. Geen account nodig. Uw bestanden worden in uw browser versleuteld voordat ze worden geüpload — niemand anders kan ze lezen.
Een bestand verzenden