Ga naar inhoud
HexaTransfer
Terug naar blog
Encryptie & beveiliging

Encryptie prestatie-optimalisatie: snelle crypto in de browser

Optimaliseer encryptieprestaties voor grote overdrachten. Streaming encryptie, Web Workers en chunk-verwerking.

Een bestand van 5 GB versleutelen in een browser zonder de UI te laten vastlopen vereist een specifieke set technieken: de Web Crypto API voor hardwareversneld AES-256-GCM (1-2 GB/s met AES-NI), gesegmenteerde verwerking in blokken van 1-4 MB om geheugen te begrenzen, Web Workers om de main thread responsief te houden, streaming reads via File.stream() in plaats van FileReader.readAsArrayBuffer() en zorgvuldig nonce-beheer zodat chunks parallel kunnen worden versleuteld. Pure-JavaScript cryptobibliotheken draaien 10-20x langzamer dan Web Crypto en moeten worden gereserveerd voor primitieven die de browser niet native blootstelt. Hier leer je hoe je meerdere honderden megabytes per seconde doorvoer bereikt op echte gebruikershardware.

Meet eerst de basislijn

Optimaliseer pas na meten. Op een MacBook Air M2 uit 2024 in Chrome 120 draait het versleutelen van een 1 GB buffer met AES-256-GCM via Web Crypto in een strakke lus op ~1,7 GB/s. Dezelfde bewerking op een middenklasse Android-telefoon (Pixel 7) draait op ~600 MB/s. Een Intel-laptop uit 2015 met software-only AES bereikt ~250 MB/s.

Dit vertelt je: Web Crypto AES-GCM is niet de bottleneck voor de meeste versleutelde bestandsoverdrachtsstromen. De bottleneck is gewoonlijk het lezen van bestanden, JavaScript-marshaling tussen ArrayBuffers of het netwerk-upload. Optimaliseer die eerst.

Benchmarks om in-app uit te voeren:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 MB
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} MB/s`);

Draai op Chrome, Firefox, Safari. Chrome op Apple Silicon is het snelst; Firefox iets langzamer; Safari op Intel iets achter. Mobiel draait op ruwweg 30-50% van desktopsnelheid.

Gebruik Web Crypto, niet JavaScript-bibliotheken

Voor AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA en SHA-256/384/512 gebruikt de native Web Crypto API van de browser hardwareversnelling waar beschikbaar (AES-NI op x86, ARMv8 crypto-extensies op mobiel). JavaScript-bibliotheken als @noble/ciphers of pure-JS crypto-js kunnen deze instructies niet benaderen en draaien puur in de interpreter.

Typische snelheidsverhouding voor AES-256-GCM op 1 MB invoer:

  • Web Crypto (met hardwareversnelling): 1-2 GB/s
  • libsodium.js WASM: 400-800 MB/s
  • @noble/ciphers pure JS: 100-200 MB/s
  • crypto-js pure JS: 30-80 MB/s

Voor een bestand van 5 GB is het verschil tussen Web Crypto en pure JS ~3 seconden versus ~50 seconden. Zichtbaar voor de gebruiker. Gebruik altijd Web Crypto voor wat het ondersteunt. Gebruik WASM-bibliotheken (libsodium.js, argon2-browser) alleen voor algoritmen die Web Crypto niet heeft (ChaCha20-Poly1305, Argon2id, X25519 op oudere browsers).

Gesegmenteerde verwerking voor grote bestanden

Bestanden boven ~500 MB passen niet comfortabel in één ArrayBuffer op de meeste apparaten. Segmenteer ze in stukken van 1-4 MB en versleutel elk stuk.

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

Waarom 4 MB chunks? Kleinere chunks (bijv. 64 KB) hebben per-aanroep overhead van de Web Crypto API die domineert bij kleine invoer. Grotere chunks (bijv. 64 MB) passen minder goed in L2/L3-cache en hebben meer geheugendruk. 1-4 MB is typisch de sweet spot op zowel desktop als mobiel.

Web Workers om de UI responsief te houden

Versleuteling op de main thread blokkeert rendering en invoer. Voor alles boven ~500 ms werk verplaats je naar een Web Worker.

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// main thread
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* verwerk ct */ };

Overdracht van ArrayBuffers met het tweede argument aan postMessage — dit verplaatst eigendom (zero-copy) in plaats van te klonen. Zonder overdracht voegt het klonen van een chunk van 4 MB ~20 ms overhead toe per chunk.

Web Crypto API is beschikbaar in Workers, dus de eigenlijke versleuteling draait daar met identieke prestaties als de main thread.

Parallelle chunk-versleuteling

Per-chunk AES-GCM versleuteling is onafhankelijk zolang nonces niet botsen. Leid nonces deterministisch af van chunk-index en je kunt chunks parallel versleutelen over meerdere Workers. Afnemende meeropbrengst treedt op na ~4 workers op typische hardware omdat Web Crypto zo snel is dat de bottleneck verschuift naar bestandslezen en inter-thread berichtverzending. Benchmark vóór je je committeert aan complexiteit; soms is single-worker sequentiële versleuteling even snel als multi-worker parallel door overhead.

Streaming met Readable Streams

Voor werkelijk grote bestanden (20+ GB) vermijd je zelfs het laden van chunks tegelijk in het geheugen. Gebruik File.stream():

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

Dit houdt geheugengebruik begrensd op één chunk tegelijk. De browser leest van schijf, versleutelt, schrijft naar de uploadstroom, leest het volgende chunk. Geheugengebruik blijft onder 10 MB ongeacht de bestandsgrootte.

Upload-gelijktijdigheid

Versleuteling draait parallel met upload, niet serieel. Terwijl chunk N uploadt, wordt chunk N+1 versleuteld. Gebruik een pipeline:

const encryptQueue = [];
const uploadQueue = [];
const MAX_IN_FLIGHT = 4;

// Versleuteler produceert, uploader consumeert
async function pipeline() {
  // Start versleuteling van eerste chunks
  for (let i = 0; i < MAX_IN_FLIGHT; i++) startEncrypt(i);
  // Naarmate elk voltooit, start upload en stel volgende versleuteling in de wachtrij
  // (begrensde wachtrij houdt geheugengebruik voorspelbaar)
}

TCP slow-start en TLS-handshake overhead betekenen dat initiële uploads langzaam zijn. Vier tot acht gelijktijdige uploads naar één origin onderhouden houdt de pijp vol zonder browsergrenzen te overschrijden (6 verbindingen per origin in Chrome/Firefox).

WebAssembly voor ontbrekende primitieven

Voor Argon2id sleutelafleiding of ChaCha20-Poly1305 heeft Web Crypto geen native ondersteuning. WASM-bibliotheken vullen de leemte:

  • libsodium.js biedt Argon2id, XChaCha20-Poly1305, crypto_secretstream
  • argon2-browser biedt alleen Argon2 maar kleinere bundel
  • @noble/hashes biedt pure-JS Argon2 (langzamer) met kleine bundel

WASM-versies bereiken 60-80% van native snelheid voor de meeste cryptoworkloads. Voor bestandsoverdracht is een Argon2id-afleiding van 1-2 seconden bij upload en download acceptabel; een pure-JS afleiding van 5 seconden niet.

Laad WASM dynamisch zodat het de initiële paginaweergave niet blokkeert:

const sodium = await import('libsodium-wrappers');
await sodium.ready;

Voortgangsrapportage

Grote versleutelingen hebben voortgangsfeedback nodig, anders nemen gebruikers aan dat de app is vastgelopen. Tel versleutelde bytes en post voortgangsgebeurtenissen:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

Begrens UI-updates tot ~10 Hz via requestAnimationFrame of een eenvoudige tijdstempelcontrole; frequentere updates verspillen cycli aan herverfingeringen die mensen niet kunnen zien.

Geheugenplafond en GC-druk

Elke ArrayBuffer leeft totdat er niet meer naar wordt verwezen. Het vasthouden van 20 versleutelde chunks van 4 MB elk betekent ~80 MB gepind geheugen. Op mobiele browsers met krappe geheugenlimieten (iOS Safari begrenst op ~200-400 MB per tab) maakt dit uit.

Geef referenties snel vrij:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // laat GC opruimen
  const ct = await encrypt(chunk);
  await upload(ct);
}

Vermijd het bewaren van een volledige array van versleutelde chunks in het geheugen; stream ze naar de uploader en laat referenties los terwijl je doorgaat.

Praktische streefwaarden

Voor een versleutelde bestandsoverdracht van 1 GB in een browsertab: versleutelingstijd 1-3 seconden (hardwareversneld), uploadtijd 30 seconden op een verbinding van 300 Mbps, totaal ongeveer 35 seconden wandkloktijd (voornamelijk netwerkgebonden), geheugen onder 50 MB piek met correcte streaming, UI responsief gedurende de hele tijd (main thread nooit geblokkeerd >50 ms). Mis die streefwaarden en gebruikers merken het. Het 10 GB-limiet van HexaTransfer is haalbaar in een browsertab omdat Web Crypto plus gesegmenteerde streaming het hele pad efficiënt houdt.

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