Vai al contenuto
HexaTransfer
Torna al blog
Approfondimenti tecnici

Crittografia file in JavaScript: tutorial passo-passo

Crittografa file nel browser con JavaScript. Tutorial pratico su crittografia AES, derivazione delle chiavi e gestione sicura dei file.

Il Garante per la protezione dei dati personali ha più volte ribadito che la crittografia end-to-end è la misura tecnica più efficace per proteggere i dati personali durante il trasferimento — e JavaScript può cifrare un file interamente nel browser, senza alcun coinvolgimento del server. La pipeline standard: leggi il file come ArrayBuffer, deriva una chiave AES a 256 bit dalla password tramite PBKDF2-SHA256 (600.000 iterazioni), cifra con un IV casuale di 12 byte, e confeziona salt, IV e testo cifrato in un Blob scaricabile. Ogni browser principale supporta questo nativamente tramite window.crypto.subtle, e per file fino a 2-3 GB l'intera operazione si completa in meno di dieci secondi su un laptop moderno senza toccare alcuna libreria di terze parti.

Le scelte di algoritmo che contano

Scegli AES-GCM, non AES-CBC. GCM fornisce crittografia autenticata in un unico passaggio, rilevando le manomissioni con un tag di 128 bit, mentre CBC richiede un passaggio HMAC separato che la maggior parte dei tutorial sbaglia. Usa una chiave a 256 bit — la differenza di prestazioni rispetto a 128 bit è trascurabile sull'hardware con AES-NI. Scegli PBKDF2-SHA256 per la derivazione delle chiavi basata su password a meno che tu non possa distribuire Argon2id tramite WebAssembly, che è più robusto ma aggiunge 50 KB di download.

Evita queste: modalità ECB (fondamentalmente compromessa), padding manuale (un decennio di attacchi padding oracle su CBC), MD5 o SHA-1 (vulnerabili alle collisioni), e qualsiasi cosa dal pacchetto crypto-js senza capire che usa CBC con PKCS7 di default.

Leggere un file in memoria

L'API File offre tre modi per ottenere i byte:

const buf = await file.arrayBuffer();          // intero file
const stream = file.stream();                  // streaming
const text = await file.text();                // decodificato UTF-8

Per file superiori a 500 MB, arrayBuffer() spesso fallisce su Safari mobile. Usa lo streaming invece:

async function* chunks(file, size = 4 * 1024 * 1024) {
  for (let off = 0; off < file.size; off += size) {
    yield new Uint8Array(await file.slice(off, off + size).arrayBuffer());
  }
}

Ogni slice viene letta pigriamente dal disco, quindi la memoria di picco rimane limitata.

Derivare una chiave dalla password utente

Non passare mai una password grezza a encrypt. Deriva prima una chiave:

async function deriveKey(password, salt) {
  const enc = new TextEncoder();
  const material = await crypto.subtle.importKey(
    'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Genera un salt fresco di 16 byte per ogni file con crypto.getRandomValues(new Uint8Array(16)). Memorizza il salt insieme al testo cifrato — riutilizzare un salt tra i file vanifica lo scopo di PBKDF2. La cheat sheet OWASP per la memorizzazione delle password raccomanda attualmente 600.000 iterazioni per PBKDF2-SHA256, il che si traduce in circa 500 ms di derivazione della chiave su un telefono di fascia media.

Cifrare il file

Con una chiave a disposizione, la cifratura è una singola chiamata subtle.encrypt per buffer:

async function encryptBuffer(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, tagLength: 128 },
    key,
    plaintext
  );
  return { iv, ciphertext };
}

AES-256-GCM fallisce catastroficamente se riutilizzi una coppia (iv, chiave) — la collisione del keystream rivela entrambi i testi in chiaro. Un IV casuale a 96 bit offre circa 2^48 cifrature sicure con una chiave, sufficiente per la cifratura di file. Se cifri molti blocchi con la stessa chiave, deriva l'IV da un contatore più un prefisso casuale di 32 bit.

Confezionare l'output

Il decifrator ha bisogno del salt, dell'IV e del testo cifrato. Impacchettali in un singolo blob con un piccolo header:

function pack(salt, iv, ciphertext) {
  const magic = new TextEncoder().encode('ENC1');
  return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}

Versiona l'header (ENC1, ENC2…) così puoi migrare gli algoritmi in seguito senza rompere i vecchi file. Offri un download tramite:

const url = URL.createObjectURL(packed);
const a = document.createElement('a');
a.href = url; a.download = `${file.name}.enc`; a.click();
URL.revokeObjectURL(url);

Decifrare i file

La decifratura inverte il processo e lancia OperationError se la password è errata o il file è stato manomesso:

async function decryptFile(blob, password) {
  const buf = await blob.arrayBuffer();
  const view = new Uint8Array(buf);
  const magic = new TextDecoder().decode(view.slice(0, 4));
  if (magic !== 'ENC1') throw new Error('Formato sconosciuto');
  const salt = view.slice(4, 20);
  const iv = view.slice(20, 32);
  const ct = view.slice(32);
  const key = await deriveKey(password, salt);
  const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
  return new Blob([pt]);
}

Mostra un singolo messaggio di errore — "impossibile decifrare: password errata o file corrotto" — invece di distinguere tra errore del tag e errore strutturale. Questo elimina la perdita di informazioni analoga al padding oracle agli aggressori.

Cifrare file di grandi dimensioni senza esaurire la RAM

Per qualsiasi file sopra i 500 MB, non chiamare arrayBuffer() sull'intero file. Cifra i blocchi in modo indipendente con IV unici derivati da un contatore:

function ivForChunk(baseIV, index) {
  const iv = new Uint8Array(baseIV);
  const view = new DataView(iv.buffer);
  view.setUint32(8, index, false);
  return iv;
}

Incanala il file attraverso un TransformStream, cifra ogni blocco da 4 MB, e scrivi i risultati in un WritableStream puntato al disco tramite l'API File System Access. La memoria di picco rimane vicino ai 10 MB anche per un file da 20 GB. Il formato a blocchi deve registrare la dimensione e il conteggio dei blocchi nel suo header affinché il decifrator possa riassemblare correttamente.

Errori comuni che arrivano in produzione

Tre pattern appaiono nelle revisioni del codice crypto JavaScript:

Il primo: memorizzare la chiave grezza in localStorage per comodità. localStorage è sincrono, con scope per origine, e leggibile da qualsiasi XSS. Usa invece una CryptoKey non estraibile in IndexedDB.

Il secondo: usare Math.random() per IV o salt. Math.random() è prevedibile; usa sempre crypto.getRandomValues.

Il terzo: assumere che subtle.encrypt sia a tempo costante. Lo è nelle implementazioni native del browser, ma qualsiasi wrapper JavaScript attorno ad esso quasi certamente non lo è. Mantieni il tuo codice fuori dal percorso critico.

HexaTransfer applica esattamente questa pipeline — PBKDF2 a 600k iterazioni, AES-256-GCM, blocchi in streaming — quindi il server memorizza solo testo cifrato opaco. Prova su https://hexatransfer.com — gratuito, senza account, massimo 10 GB.

Testare l'implementazione

Scrivi un harness di test che esegua un ciclo completo su un blob casuale da 10 MB, muti un byte e verifichi che la decifratura lanci un'eccezione. Aggiungi fuzzing contro header malformati e testi cifrati troncati — il bug comune sono controlli sui limiti mancanti nelle chiamate slice() che crashano invece di rifiutare. Esegui benchmark su un iPhone SE, un Android di fascia media e un Chromebook; qualsiasi cosa che richieda più di 2 secondi di derivazione della chiave è troppo lenta per gli utenti mobili. Fai revisionare il codice da un secondo paio di occhi prima di andare in produzione — il codice crittografico sembra semplice e si rompe in modi sottili che i test perdono.

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