Crittografia progressiva di file grandi: stream e crittografa
Cripta progressivamente file grandi usando API di streaming. Elabora file multi-GB senza esaurire la memoria.
La crittografia progressiva (streaming) elabora un file blocco per blocco senza caricare mai l'intero payload in memoria. Per un upload da 10 GB nel browser, questa è la differenza tra un'app che funziona e una che va in crash. Il pattern: leggi un chunk via File.stream(), cifralo con AES-256-GCM usando un nonce univoco, indirizza il ciphertext direttamente a uno stream di upload tramite fetch con body ReadableStream, rilascia il buffer e vai avanti. La memoria rimane sotto 4-16 MB indipendentemente dalla dimensione del file. Il crypto_secretstream_xchacha20poly1305 di libsodium aggiunge una vera semantica di streaming AEAD inclusa la rilevazione dei troncamenti. Ecco l'implementazione concreta, con numeri che reggono sull'hardware reale.
Perché la cifratura bufferizzata non funziona
Un FileReader.readAsArrayBuffer(file) su un file da 10 GB alloca 10 GB in memoria browser. Su un desktop Chrome con 32 GB di RAM potrebbe funzionare. Su Safari mobile con un limite di 400 MB per tab, va in crash prima di completare. Su Firefox, un ArrayBuffer oltre 2 GB supera i limiti interni di V8 e lancia RangeError.
Anche sull'hardware che regge l'allocazione, tenere 10 GB blocca il garbage collection e innesca paginazione patologica. La risposta corretta è non allocare mai il buffer completo.
Il pattern di streaming
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));
}
Due API chiave: File.stream() fornisce un ReadableStream del contenuto del file; fetch con body ReadableStream trasmette l'upload senza bufferizzare l'intero corpo. duplex: "half" è necessario in Chrome 105+ per i body di richiesta in streaming. Uso di memoria: in qualsiasi momento, un chunk sorgente, un resto bufferizzato, un chunk cifrato. Picco ~12-16 MB per chunk da 4 MB.
Gestione del nonce nello streaming
Ogni chunk ha bisogno di un nonce univoco. Tre approcci:
Counter-based: incorpora l'indice del chunk nel nonce da 96 bit. Imposta i 32 bit superiori a un prefisso casuale (per evitare collisioni tra file che usano la stessa chiave), i 64 bit inferiori all'indice del chunk.
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;
}
Casuale per chunk: crypto.getRandomValues(new Uint8Array(12)). Sicuro per chiavi per-file; le collisioni birthday tra chunk si manifestano a ~2^48. Salva il nonce accanto al ciphertext di ogni chunk.
Per una chiave fresca per file, il counter-based è il più semplice e non richiede di memorizzare un nonce separato per chunk.
Attacchi di troncamento e come rilevarli
Un gap critico nel chunked AES-GCM ingenuo: un attaccante può eliminare i chunk finali e ogni chunk sopravvissuto decifra correttamente. La rilevazione richiede di legare i chunk insieme.
Opzione 1: includi il conteggio totale dei chunk nell'AAD di ogni chunk. Il destinatario verifica che il conteggio corrisponda a quanto ricevuto.
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
);
Opzione 2: usa crypto_secretstream_xchacha20poly1305 di libsodium. Catena i chunk crittograficamente ed emette un marcatore TAG_FINAL che il destinatario verifica. Al momento della decifratura, le chiamate pull del destinatario verificano la catena e rilevano i chunk finali mancanti.
Decifratura streaming lato destinatario
Pattern simmetrico lato destinatario: il body della risposta viene letto come stream, i chunk vengono riassemblati nel buffer, ogni chunk cifrato da 4 MB + 16 byte del tag GCM viene decifrato. I callback onChunk possono instradare i byte decifrati alla File System Access API per scritture dirette su disco, o concatenarli in un Blob per il download nativo del browser.
Scrittura su disco tramite File System Access API
Per download molto grandi, caricare il risultato decifrato in un Blob vanifica lo scopo dello streaming. La File System Access API (Chrome 86+, Safari parziale via OPFS) permette al destinatario di scegliere un file locale e scrivere i chunk direttamente:
const handle = await window.showSaveFilePicker({
suggestedName: "file-decifrato",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
La memoria rimane limitata perché i chunk vanno su disco immediatamente. Firefox non supporta ancora showSaveFilePicker su desktop. Usa come fallback la costruzione di un Blob in memoria (accettabile per file sotto alcune centinaia di MB).
Upload in streaming con fetch
Chrome 105+ e Firefox 127+ supportano i body di richiesta in streaming con duplex: "half". Per gli upload multipart compatibili con S3, ogni parte è una richiesta separata. Suddividi lo stream cifrato in parti da 5-25 MB (la dimensione minima di parte S3 è 5 MB, la massima è 5 GB) e completa con la chiamata finale CompleteMultipartUpload. Funziona su tutti i browser e fornisce riprendibilità gratuita.
Benchmark su un file da 10 GB
Su un MacBook Pro 2024 (M3 Max) con SSD veloce: lettura raw da disco via File.stream() a 2,5 GB/s, AES-256-GCM via Web Crypto a 1,7 GB/s, la pipeline combinata a 1,1 GB/s (limitata dalla catena seriale), upload su Gigabit Ethernet a 115 MB/s (network-bound), picco di memoria 14 MB indipendentemente dalla dimensione del file. Su mobile i numeri sono circa il 30-50% del desktop. Un file da 10 GB si carica in ~90 secondi su Gigabit, ~15 minuti su una connessione domestica tipica. La cifratura non è il collo di bottiglia — lo è la rete.
Recupero dagli errori
Le interruzioni di rete durante un upload da 10 GB sono frequenti. Strategie:
- Upload riprendibili via multipart: ogni parte è indipendente; ricarica solo la parte fallita.
- Protocollo tus: standard aperto di upload riprendibile supportato da aziende come Vimeo; nativo per lo streaming.
- Mantieni aperto il file handle sorgente: se
File.sliceè ripetibile, riparti dall'ultimo chunk riuscito.
HexaTransfer supporta file fino a 10 GB in una singola tab del browser perché questa pipeline streaming mantiene la memoria limitata e gestisce le interruzioni con multipart retry.
Il succo in breve
Non allocare l'intero file. Leggi a chunk, cifra a chunk, carica a chunk, libera ogni chunk man mano che vai. Lega i chunk crittograficamente con AAD o streaming AEAD per difenderti dal troncamento. Aggiungi una progress bar. Testa su mobile, non solo su desktop.
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