Vai al contenuto
HexaTransfer
Torna al blog
Crittografia e sicurezza

Ottimizzazione performance crittografia: crypto veloce nel browser

Ottimizza le performance di crittografia per trasferimenti grandi. Crittografia streaming, Web Workers e elaborazione a pezzi.

Cifrare un file da 5 GB nel browser senza bloccare l'interfaccia richiede una combinazione precisa di tecniche: Web Crypto API per AES-256-GCM accelerato via hardware (1-2 GB/s con AES-NI), elaborazione a blocchi da 1-4 MB per mantenere il consumo di memoria sotto controllo, Web Workers per non congestionare il thread principale, lettura in streaming con File.stream() al posto di FileReader.readAsArrayBuffer(), e gestione attenta dei nonce per abilitare la cifratura parallela dei chunk. Le librerie crypto in puro JavaScript girano 10-20x più lentamente rispetto alla Web Crypto API e vanno riservate ai primitivi che il browser non espone nativamente. Ecco come raggiungere velocità nell'ordine delle centinaia di megabyte al secondo su hardware reale.

Prima di tutto: misura

Prima di ottimizzare, misura. Su un MacBook Air M2 2024 con Chrome 120, cifrare un buffer da 1 GB con AES-256-GCM via Web Crypto gira a circa 1,7 GB/s. Lo stesso su un Android di fascia media (Pixel 7) arriva a ~600 MB/s. Un laptop Intel del 2015 senza accelerazione hardware tocca ~250 MB/s.

Il dato chiave è questo: Web Crypto AES-GCM raramente è il collo di bottiglia nei flussi di trasferimento cifrato. Il vero limite è di solito la lettura del file, il marshaling JavaScript tra ArrayBuffer, o il caricamento in rete. Ottimizza prima quelli.

Benchmark da eseguire direttamente nell'app:

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`);

Eseguilo su Chrome, Firefox, Safari. Chrome su Apple Silicon sarà il più rapido; Firefox leggermente più lento; Safari su Intel un po' dietro. Su mobile i numeri scendono al 30-50% rispetto al desktop.

Web Crypto vince sulle librerie JavaScript pure

Per AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA e SHA-256/384/512, la Web Crypto API nativa usa l'accelerazione hardware disponibile: AES-NI su x86, estensioni crypto ARMv8 su mobile. Librerie JavaScript come @noble/ciphers o crypto-js non hanno accesso a queste istruzioni e girano nell'interprete puro.

Velocità tipica per AES-256-GCM su input da 1 MB:

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

Su un file da 5 GB, la differenza tra Web Crypto e puro JS è circa 3 secondi contro 50 secondi — visibile all'utente. Usa sempre Web Crypto per gli algoritmi che supporta. Passa alle librerie WASM (libsodium.js, argon2-browser) solo per gli algoritmi che il browser non offre nativamente: ChaCha20-Poly1305, Argon2id, X25519 su browser datati.

Elaborazione a chunk per file grandi

File oltre i ~500 MB non entrano comodamente in un singolo ArrayBuffer sulla maggior parte dei dispositivi. Suddividili in blocchi da 1-4 MB e cifra ciascuno.

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;
}

Perché 4 MB? Chunk troppo piccoli (es. 64 KB) generano overhead per ogni chiamata alla Web Crypto API che diventa dominante sugli input piccoli. Chunk troppo grandi (es. 64 MB) non si adattano bene alla cache L2/L3 e creano pressione sulla memoria. Il range 1-4 MB è il punto di equilibrio su desktop e mobile.

Web Workers per non bloccare l'interfaccia

La crittografia sul thread principale blocca rendering e input. Per qualsiasi operazione che dura più di ~500 ms, scaricala su un 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]);
};

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

Trasferisci gli ArrayBuffer con il secondo argomento di postMessage — sposta la proprietà (zero-copy) invece di clonarli. Senza trasferimento, clonare un chunk da 4 MB aggiunge ~20 ms di overhead a chunk. La Web Crypto API è disponibile nei Worker, quindi la cifratura effettiva gira lì con le stesse performance del thread principale.

Crittografia parallela dei chunk

La cifratura AES-GCM per chunk è indipendente purché i nonce non collidano. Derivando i nonce in modo deterministico dall'indice del chunk, puoi cifrare più blocchi in parallelo su più Worker. I rendimenti decrescenti si manifestano oltre ~4 Worker sull'hardware tipico: Web Crypto è così veloce che il collo di bottiglia si sposta sulla lettura del file e sul passaggio di messaggi tra thread. Misura prima di aggiungere complessità; a volte la cifratura sequenziale su un singolo Worker è altrettanto rapida di quella parallela multi-Worker per via dell'overhead.

Streaming con ReadableStream per file enormi

Per file molto grandi (20+ GB), evita di caricare anche solo i chunk tutti in memoria contemporaneamente. Usa 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();

Questo mantiene il consumo di memoria limitato a un chunk alla volta. Il browser legge dal disco, cifra, scrive nello stream di upload e legge il chunk successivo. L'uso di memoria rimane sotto 10 MB indipendentemente dalla dimensione del file.

Pipeline upload: cifra e carica in parallelo

La cifratura gira in parallelo con l'upload, non in sequenza. Mentre il chunk N viene caricato, il chunk N+1 viene cifrato. Mantenere 4-8 upload concorrenti verso una singola origin tiene il canale pieno senza superare i limiti del browser (6 connessioni per origin in Chrome/Firefox). TCP slow-start e overhead del TLS handshake rallentano gli upload iniziali: la concorrenza riduce questo impatto mantenendo il flusso continuo.

WebAssembly per i primitivi mancanti

Per la derivazione della chiave con Argon2id o la cifratura ChaCha20-Poly1305, Web Crypto non offre supporto nativo. Le librerie WASM colmano il gap: libsodium.js fornisce Argon2id, XChaCha20-Poly1305 e crypto_secretstream; argon2-browser si occupa solo di Argon2 ma pesa meno; @noble/hashes offre Argon2 in puro JS con bundle minimo ma è più lenta. Le versioni WASM raggiungono il 60-80% della velocità nativa per la maggior parte dei carichi crypto. Una derivazione Argon2id di 1-2 secondi all'upload è accettabile; 5 secondi in puro JS non lo è.

Carica WASM dinamicamente per non bloccare il render iniziale della pagina:

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

Progress e gestione della memoria

Le cifrature lunghe richiedono feedback visivo, altrimenti l'utente pensa che l'app si sia bloccata. Conta i byte cifrati e invia eventi di progresso, limitando gli aggiornamenti UI a ~10 Hz tramite requestAnimationFrame. Su Safari iOS, con un limite di ~200-400 MB per tab, rilascia i riferimenti ai chunk non appena li hai caricati: tenere 20 chunk cifrati da 4 MB in memoria vuol dire ~80 MB bloccati. Passa ogni chunk all'uploader e imposta chunks[i] = null subito dopo per permettere al garbage collector di lavorare.

Obiettivi su hardware reale

Per un trasferimento di un file cifrato da 1 GB in una tab del browser: cifratura in 1-3 secondi (accelerazione hardware attiva), upload in 30 secondi su connessione da 300 Mbps, totale circa 35 secondi, memoria sotto 50 MB di picco con streaming corretto, interfaccia sempre reattiva con thread principale mai bloccato oltre 50 ms. Manca uno di questi obiettivi e l'utente se ne accorge. HexaTransfer supporta file fino a 10 GB direttamente nel browser perché Web Crypto combinato con lo streaming a chunk mantiene tutto il percorso efficiente.

Provalo su hexatransfer.com — gratis, senza registrazione, fino a 10 GB.

Invia file di grandi dimensioni in modo sicuro con crittografia end-to-end

Trasferisci file fino a 10 GB gratuitamente con crittografia end-to-end. Nessun account necessario. I tuoi file vengono crittografati nel browser prima del caricamento — nessun altro può leggerli.

Invia un file