Tutorial crittografia lato client: costruiscilo da zero
Tutorial passo passo per implementare crittografia lato client. Cripta i file nel browser prima che lascino il dispositivo.
La cifratura file lato client nel browser richiede circa 80 righe di JavaScript usando la Web Crypto API. Il pattern: genera una chiave AES-256-GCM nel browser, cifra il file con un nonce a 96 bit casuale, carica il ciphertext via HTTPS/TLS 1.3 e condividi l'URL risultante con la chiave incorporata nel fragment identifier (#key=...) — che il browser non trasmette mai al server. Il destinatario decifra nel browser usando quel fragment. Questo tutorial illustra un'implementazione funzionante, incluso il chunking per file di grandi dimensioni, la derivazione di chiavi da password via PBKDF2 a 600.000 iterazioni, e gli errori più comuni che fanno fallire i primi tentativi.
L'architettura in uno schema
[Browser mittente] [Server] [Browser destinatario]
Legge File → Chiave AES (casuale) Accetta POST GET ciphertext
Cifra con AES-256-GCM Conserva il blob Estrae chiave da URL #fragment
POST ciphertext Nessuna chiave, nessun testo Decifra nel browser
Costruisce URL con #key=... Restituisce URL di download Salva file su disco
Il server è un semplice blob store. Vede solo ciphertext e non può decifrare. La chiave di decrittografia vive nel fragment dell'URL, che i browser trattano in modo speciale: non viene mai inviato nella richiesta HTTP. Questa è la base di ogni servizio di trasferimento file zero-knowledge, incluso HexaTransfer.
Passo 1: generare una chiave simmetrica
async function generateKey() {
return await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable per poterla esportare nell'URL
["encrypt", "decrypt"]
);
}
Il flag extractable: true è necessario perché dobbiamo serializzare la chiave nel fragment dell'URL. Se stai costruendo un flusso in cui la chiave vive solo in memoria, impostalo a false.
Passo 2: leggere il file come ArrayBuffer
async function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
Questo carica l'intero file in memoria. Va bene per file sotto i 500 MB. Per file più grandi, passa direttamente alla sezione sullo streaming.
Passo 3: cifrare il buffer
async function encryptFile(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
// Prepend IV al ciphertext per consentire al destinatario di estrarlo
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return combined.buffer;
}
Il nonce (IV) è a 96 bit (12 byte), secondo NIST SP 800-38D. Non è segreto, ma deve essere unico per ogni chiave. I nonce casuali sono sicuri perché si genera una chiave fresca per ogni file. Anteporre l'IV al ciphertext è una convenzione comune; il destinatario lo separa prima di decifrare.
Passo 4: caricare il ciphertext
async function uploadCiphertext(ciphertext) {
const response = await fetch("/api/upload", {
method: "POST",
body: ciphertext,
headers: { "Content-Type": "application/octet-stream" },
});
const { fileId } = await response.json();
return fileId;
}
Il server riceve un blob binario, gli assegna un ID, lo conserva e restituisce quell'ID. Nessun header rivela il nome del file, nessun parametro query porta la chiave. Se l'hard drive del server venisse rubato domani, un attaccante vedrebbe solo testo incomprensibile.
Passo 5: costruire l'URL di condivisione con la chiave nel fragment
async function buildShareURL(fileId, key) {
const rawKey = await crypto.subtle.exportKey("raw", key);
const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `${location.origin}/f/${fileId}#${keyBase64}`;
}
La codifica base64url (con - e _ al posto di + e /) evita problemi di escaping nell'URL. Il padding = viene rimosso per pulizia visiva.
Il fragment (#...) è il meccanismo chiave. Quando il destinatario carica l'URL, il browser mantiene il fragment lato client. La richiesta GET per /f/{fileId} non include #keyBase64 nella riga di richiesta, quindi il server non viene mai a conoscenza della chiave. Verifica tu stesso aprendo gli strumenti di sviluppo del browser su qualsiasi URL con fragment e osservando la scheda Network.
Passo 6: decrittografia lato destinatario
async function downloadAndDecrypt() {
const fileId = location.pathname.split("/").pop();
const keyBase64 = location.hash.slice(1);
const rawKey = Uint8Array.from(
atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
c => c.charCodeAt(0)
);
const key = await crypto.subtle.importKey(
"raw", rawKey, "AES-GCM", false, ["decrypt"]
);
const response = await fetch(`/api/download/${fileId}`);
const combined = new Uint8Array(await response.arrayBuffer());
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv }, key, ciphertext
);
const blob = new Blob([plaintext]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "downloaded-file";
a.click();
}
Il tag di autenticazione GCM viene verificato durante decrypt(). Se il ciphertext è stato manomesso, la chiamata lancia OperationError — una modalità di errore netta.
Chiavi derivate da password via PBKDF2
Se gli utenti forniscono una password invece di una chiave casuale, deriva la chiave AES via PBKDF2:
async function deriveKey(password, salt) {
const passwordKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password),
"PBKDF2", false, ["deriveKey"]
);
return await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
600.000 iterazioni di PBKDF2-SHA-256 è la baseline OWASP 2023. Il salt deve essere di 16 byte casuali e conservato insieme al ciphertext (non è segreto, deve solo essere unico). Per nuovo codice, considera Argon2id tramite una libreria come argon2-browser — resiste molto meglio agli attacchi GPU rispetto a PBKDF2.
File di grandi dimensioni in streaming
I file superiori a 500 MB devono essere suddivisi in blocchi. Leggili via File.stream(), cifra ogni blocco e caricali sequenzialmente:
async function encryptStream(file, key) {
const reader = file.stream().getReader();
const chunks = [];
let chunkIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const iv = new Uint8Array(12);
// Codifica l'indice del blocco nel nonce per garantire l'unicità
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, value
);
chunks.push({ iv, ct });
}
return chunks;
}
Derivare il nonce dall'indice del blocco garantisce l'unicità senza tracciare stato esterno. Il riassemblaggio lato destinatario decifra i blocchi in ordine e li concatena.
Per streaming AEAD vero e proprio, crypto_secretstream_xchacha20poly1305 di libsodium tramite libsodium.js è più pulito e rileva gli attacchi di troncamento. La Web Crypto API non ha un primitivo equivalente nel 2026.
Test e insidie comuni
Errori da evitare:
- Usare
Math.random()per chiavi o nonce: usa semprecrypto.getRandomValues(). - Riutilizzare un nonce con la stessa chiave: rompe la sicurezza GCM. Le chiavi per-file casuali rendono questo sicuro; i flussi per-blocco necessitano di nonce unici per ogni blocco.
- Non verificare HTTPS:
crypto.subtleè undefined su origini non sicure. Testa su localhost o con un certificato self-signed durante lo sviluppo. - Conservare le chiavi in
localStorage: qualsiasi XSS sulla tua origine può leggerla. Usa invece il pattern con il fragment dell'URL, oppure chiavi non-extractable. - Dimenticare di includere l'IV con il ciphertext: la decrittografia fallisce senza messaggi di errore utili. Anteponi o serializza sempre l'IV insieme al ciphertext.
- Gestire male il fragment: non inviare accidentalmente l'URL (con fragment) a un servizio di terze parti. Condividi solo tramite canali end-to-end se il fragment è sensibile.
Responsabilità lato server
In un'architettura di cifratura lato client, il server ha un ruolo semplice: accettare POST, conservare il blob, restituire ID, servire GET per il blob, eliminare alla scadenza. Nessuna operazione crittografica. Cosa deve fare il server oltre allo storage:
- Imporre limiti di dimensione file (prevenire abusi)
- Limitare il rate di upload e download
- Impostare una conservazione breve (7 giorni è un default ragionevole, come HexaTransfer)
- Registrare solo il necessario (timestamp di upload, nessun IP se l'approccio è privacy-first)
- Servire tramite TLS 1.3 con HSTS
- Header CORS che limitano le origini se l'API viene chiamata solo dai tuoi domini
Mettere tutto insieme
Un'app funzionante minimale entra in un singolo file HTML più un backend Express da 50 righe. Dipendenze totali: nessuna lato client (Web Crypto è nativa), Express più multer lato server. La cifratura è robusta quanto il primitivo AES-256-GCM, perché è letteralmente quello che stai usando. Non c'è nessun algoritmo segreto da sbagliare, solo i primitivi da usare correttamente.
Le parti più difficili sono i casi limite: file grandi, flussi da password a chiave, esperienza utente del destinatario quando la decrittografia fallisce, gestione elegante dei link scaduti. Il nucleo crittografico è semplice.
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