La crittografia sbagliata funziona benissimo
Un errore crittografico non fa crashare niente. Il testo si cifra, si decifra, il test passa, l'utente accede: ciò che manca è la proprietà di sicurezza, e non c'è un messaggio d'errore che lo dica. Per questo l'OWASP Top 10:2025 ha una categoria dedicata, A04:2025 Cryptographic Failures, e le sue domande di verifica sono quasi tutte «che cosa hai usato, e come?»: vettori di inizializzazione ignorati o riusati, una modalità insicura come ECB, password usate come chiavi senza una funzione di derivazione, numeri casuali non progettati per scopi crittografici.
In questo articolo ogni errore viene riprodotto su dati di prova e poi corretto, con la prova che la correzione regge. Tutto gira con il modulo node:crypto di Node v24.11.0, senza dipendenze esterne, senza rete e senza server; ogni uscita riportata è identica in due esecuzioni consecutive (dove un valore dipende dalla macchina, l'uscita confronta soglie e lo dice). Non c'è niente da installare: se hai Node 24.7 o successivo, puoi rieseguire ogni listato. Gli errori sono quattro: la modalità di cifratura, il vettore di inizializzazione, la funzione di hash delle password e la qualità dei token.
ECB: i blocchi uguali restano uguali
La modalità ECB cifra ogni blocco da 16 byte separatamente con la stessa chiave. Lo dice la specifica NIST SP 800-38A (edizione 2001): con una data chiave, un dato blocco di testo in chiaro viene sempre cifrato nello stesso blocco di cifrato, e se questa proprietà è indesiderata ECB non va usata. Nella prova il testo ha quattro blocchi, di cui il primo e il terzo identici, come due righe uguali di un file di stipendi. Il programma conta i blocchi di cifrato duplicati con AES-256 in ECB e con AES-256-GCM.
import crypto from 'node:crypto';
const key = crypto.randomBytes(32);
// 4 blocchi da 16 byte: il primo e il terzo sono identici, come due righe uguali in un file cifrato
const block = (s) => Buffer.from(s.padEnd(16, '.'));
const plaintext = Buffer.concat([block('STIPENDIO: 1000'), block('STIPENDIO: 2500'), block('STIPENDIO: 1000'), block('FINE')]);
const duplicateBlocks = (buf) => {
const seen = new Set();
let dup = 0;
for (let i = 0; i < buf.length; i += 16) {
const h = buf.subarray(i, i + 16).toString('hex');
if (seen.has(h)) dup++; else seen.add(h);
}
return dup;
};
// ECB: ogni blocco è cifrato da solo, quindi blocchi uguali producono cifrati uguali
const ecb = crypto.createCipheriv('aes-256-ecb', key, null);
const ecbOut = Buffer.concat([ecb.update(plaintext), ecb.final()]);
// GCM con IV casuale di 12 byte: lo schema corretto
const iv = crypto.randomBytes(12);
const gcm = crypto.createCipheriv('aes-256-gcm', key, iv);
const gcmOut = Buffer.concat([gcm.update(plaintext), gcm.final()]);
console.log('blocchi duplicati nel testo in chiaro :', duplicateBlocks(plaintext));
console.log('blocchi duplicati nel cifrato ECB :', duplicateBlocks(ecbOut));
console.log('blocchi duplicati nel cifrato GCM :', duplicateBlocks(gcmOut));
// Output:
// blocchi duplicati nel testo in chiaro : 1
// blocchi duplicati nel cifrato ECB : 1
// blocchi duplicati nel cifrato GCM : 0
Nel testo in chiaro c'è un blocco duplicato; nel cifrato ECB il duplicato è ancora lì, quindi chi osserva il cifrato vede dove il dato si ripete senza conoscere la chiave. Nel cifrato GCM i duplicati sono zero. Il dettaglio da non perdere nel listato è createCipheriv('aes-256-ecb', key, null): ECB non prende un IV, e proprio l'assenza dell'IV è il difetto. Se una libreria ti lascia scegliere la cifratura con un nome breve come aes-256, leggi nella sua documentazione quale modalità applica e scrivila sempre per esteso nel codice: contare i blocchi duplicati, come qui, è una prova che si automatizza in due righe.
GCM: un IV ripetuto rivela i messaggi
AES-GCM è la scelta giusta per cifrare dati applicativi perché cifra e autentica insieme. Ha una condizione che non si negozia, e la norma la scrive senza giri di parole: la NIST SP 800-38D (novembre 2007), §8, richiede che la probabilità di invocare la cifratura con lo stesso IV e la stessa chiave su due insiemi di dati distinti non superi 2-32. Il §8.3 aggiunge che con IV generati a caso il numero totale di invocazioni con una chiave non deve superare 232. In pratica: IV da 12 byte presi da crypto.randomBytes e una chiave che si cambia ben prima di quel numero.
Il listato mostra che cosa succede violando la regola. Con lo stesso IV, GCM produce lo stesso flusso di chiave per i due messaggi, quindi lo XOR dei due cifrati è lo XOR dei due testi in chiaro: se l'attaccante conosce o indovina uno dei due (un'intestazione di cortesia, un formato fisso), ricava l'altro senza la chiave. Qui il testo noto è l'apertura di un messaggio e il segreto è un PIN. Poi il blocco corretto cifra con IV nuovo a ogni messaggio e dimostra anche l'altra metà del vantaggio di GCM: un bit alterato nel cifrato fa fallire la decifratura.
import crypto from 'node:crypto';
const key = crypto.randomBytes(32);
const encrypt = (iv, text) => {
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
return { ct: Buffer.concat([c.update(text), c.final()]), tag: c.getAuthTag() };
};
const xor = (a, b) => Buffer.from(a.map((x, i) => x ^ b[i]));
// ERRORE: lo stesso IV usato per due messaggi con la stessa chiave
const fixedIv = Buffer.alloc(12, 7);
const known = Buffer.from('Gentile cliente, il suo codice:'); // testo che l'attaccante conosce o indovina
const secret = Buffer.from('PIN: 4921, scade tra 10 minuti!'); // stessa lunghezza, il segreto
const a = encrypt(fixedIv, known), b = encrypt(fixedIv, secret);
// Senza conoscere la chiave: ct_a XOR ct_b = known XOR secret, e quindi known XOR (ct_a XOR ct_b) = secret
const recovered = xor(known, xor(a.ct, b.ct));
console.log('IV ripetuto, segreto recuperato :', recovered.toString());
// CORRETTO: IV nuovo e casuale a ogni messaggio, trasportato insieme al cifrato
const sealed = (text) => {
const iv = crypto.randomBytes(12);
const { ct, tag } = encrypt(iv, text);
return { iv, ct, tag };
};
const m = sealed(secret);
const open = ({ iv, ct, tag }) => {
const d = crypto.createDecipheriv('aes-256-gcm', key, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString();
};
console.log('IV casuale, decifrato dal titolare:', open(m));
// L'autenticazione: un solo bit alterato nel cifrato e GCM rifiuta il messaggio
const tampered = { ...m, ct: Buffer.from(m.ct) };
tampered.ct[0] ^= 1;
try { open(tampered); } catch (e) { console.log('cifrato alterato :', e.message); }
// Lo stesso testo cifrato due volte con IV casuali dà due cifrati diversi
console.log('stesso testo, due cifrature uguali?', sealed(secret).ct.equals(sealed(secret).ct));
// Output:
// IV ripetuto, segreto recuperato : PIN: 4921, scade tra 10 minuti!
// IV casuale, decifrato dal titolare: PIN: 4921, scade tra 10 minuti!
// cifrato alterato : Unsupported state or unable to authenticate data
// stesso testo, due cifrature uguali? false
La prima riga dell'uscita è il danno: il PIN viene recuperato in chiaro, senza usare la chiave. Le altre tre sono la correzione: il titolare decifra, il cifrato alterato viene rifiutato con Unsupported state or unable to authenticate data (il testo dell'errore è di Node, e cambia con la versione), e due cifrature dello stesso testo sono diverse. Nel codice l'IV viaggia insieme al cifrato e al tag, perché non è un segreto: serve solo a non ripetersi. Va ricordato che l'attacco con IV ripetuto, per la specifica, riguarda anche l'autenticazione: il §8 parla di attacchi di falsificazione. Se hai già cifrato dati con un IV costante, il rimedio è cambiare chiave e ricifrare.
Password: SHA-256 è veloce, ed è il problema
Il Password Storage Cheat Sheet dell'OWASP dice due cose che il listato verifica. La prima: serve un sale unico per ogni password, per impedire le tabelle precalcolate. La seconda: gli hash veloci come SHA-256 non vanno bene, perché permettono all'attaccante un numero enorme di tentativi. Nella prova un archivio «rubato» contiene SHA-256 senza sale di password di dizionario: Anna e Carla hanno scelto la stessa password e si vede dall'hash, e una tabella di cinque parole le recupera tutte. Poi lo stesso materiale viene salvato con Argon2id.
Per Argon2id il foglio OWASP indica come configurazione minima 19 MiB di memoria, 2 iterazioni e parallelismo 1. Node ha crypto.argon2Sync dalla versione 24.7.0; il parametro memory è espresso in blocchi da 1 KiB, quindi 19 MiB sono 19456, e nonce (il sale) deve essere lungo almeno 8 byte. Il confronto del risultato usa crypto.timingSafeEqual, adatto perché tagLength fissa la lunghezza del tag.
import crypto from 'node:crypto';
// Archivio "rubato" con SHA-256 senza sale. Gli utenti hanno scelto password di dizionario.
const sha = (p) => crypto.createHash('sha256').update(p).digest('hex');
const users = { anna: sha('sunshine'), bruno: sha('qwerty123'), carla: sha('sunshine') };
console.log('anna e carla hanno lo stesso hash?', users.anna === users.carla);
const dictionary = ['password', 'sunshine', 'qwerty123', '123456', 'letmein'];
const table = new Map(dictionary.map((w) => [sha(w), w])); // tabella precalcolata
for (const [u, h] of Object.entries(users)) console.log(` ${u}: ${table.get(h) ?? 'non trovata'}`);
// Argon2id (Node >= 24.7): parametri minimi indicati dal Password Storage Cheat Sheet OWASP:
// 19 MiB di memoria (19456 blocchi da 1 KiB), 2 passate, parallelismo 1
const params = { memory: 19456, passes: 2, parallelism: 1, tagLength: 32 };
const hashPassword = (pw) => {
const nonce = crypto.randomBytes(16); // sale casuale, diverso per ogni utente
const tag = crypto.argon2Sync('argon2id', { message: pw, nonce, ...params });
return { nonce, tag };
};
const verify = (pw, rec) => {
const tag = crypto.argon2Sync('argon2id', { message: pw, nonce: rec.nonce, ...params });
return crypto.timingSafeEqual(tag, rec.tag); // stessa lunghezza garantita: tagLength fisso
};
const a = hashPassword('sunshine'), c = hashPassword('sunshine');
console.log('Argon2id, stessa password: hash uguali?', a.tag.equals(c.tag));
console.log('verifica corretta / errata:', verify('sunshine', a), verify('sunshine1', a));
// Costo del guess: quante verifiche al secondo per un attaccante che prova password in sequenza
const rate = (fn, ms) => { let n = 0; const end = performance.now() + ms; while (performance.now() < end) { fn(n++); } return Math.round(n / (ms / 1000)); };
const fast = rate((i) => sha('guess' + i), 300);
const slow = rate((i) => crypto.argon2Sync('argon2id', { message: 'guess' + i, nonce: a.nonce, ...params }), 1000);
// Il valore esatto cambia da macchina a macchina e da esecuzione a esecuzione: si confrontano soglie larghe
console.log('SHA-256 oltre 50 000 guess/s su un thread :', fast > 50_000);
console.log('Argon2id sotto 200 guess/s su un thread :', slow < 200);
// Output:
// anna e carla hanno lo stesso hash? true
// anna: sunshine
// bruno: qwerty123
// carla: sunshine
// Argon2id, stessa password: hash uguali? false
// verifica corretta / errata: true false
// SHA-256 oltre 50 000 guess/s su un thread : true
// Argon2id sotto 200 guess/s su un thread : true
Il primo blocco di righe è l'archivio debole: hash identici per password identiche, e tutte e tre le password trovate in una tabella. Con Argon2id le stesse password producono hash diversi perché il sale è diverso, la verifica accetta la password giusta e rifiuta quella sbagliata. Le ultime due righe confrontano il costo di un tentativo: SHA-256 supera i 50.000 tentativi al secondo su un solo thread, Argon2id con questi parametri resta sotto i 200. Sono soglie larghe, non misure: il numero esatto varia da macchina a macchina e da esecuzione a esecuzione, ed è per questo che non lo riporto. La conseguenza è però il motivo di tutto: ogni tentativo che costa memoria e tempo al server costa lo stesso a chi attacca.
Se Argon2id non è disponibile, il cheat sheet indica come alternativa scrypt con costo minimo 217, blocco 8 e parallelismo 1; per sistemi che richiedono la conformità FIPS-140 indica PBKDF2 con almeno 600.000 iterazioni e HMAC-SHA-256; per bcrypt un fattore di lavoro di almeno 10 e un limite di 72 byte sulla password. Conviene scegliere l'algoritmo prima di scrivere il codice di login, perché migrare gli hash esistenti richiede di rifare l'hash al primo accesso di ogni utente.
Token: casuale non significa «difficile da indovinare per me»
Il terzo errore è nel modo di generare i token di reset o di invito. Un token costruito con l'email e l'ora, passato per un hash, sembra casuale ma è deterministico: chi conosce l'email e l'ora approssimativa, che la risposta HTTP rivela nell'intestazione Date, può riprodurlo. È il caso di CWE-330, «Use of Insufficiently Random Values», e di CWE-338 se il generatore è non crittografico. La prova usa un istante fisso per essere ripetibile e lascia all'«attaccante» una finestra di più o meno due secondi.
import crypto from 'node:crypto';
// ERRORE: token di reset password derivato da dati che l'attaccante può ricostruire
const weakToken = (email, nowMs) => crypto.createHash('sha256').update(`${email}:${nowMs}`).digest('hex').slice(0, 32);
const victim = 'anna@example.test';
const issuedAt = 1_800_000_000_123; // istante di emissione (fisso per rendere la prova ripetibile)
const stolenFromEmail = weakToken(victim, issuedAt); // il token che finirà nella mail di Anna
// L'attaccante conosce l'email e sa, dall'intestazione Date della risposta, l'ora entro +-2 secondi
let tries = 0, found = null;
for (let t = issuedAt - 2000; t <= issuedAt + 2000 && !found; t++) {
tries++;
if (weakToken(victim, t) === stolenFromEmail) found = t;
}
console.log('token prevedibile: trovato dopo', tries, 'tentativi, istante', found === issuedAt ? 'corretto' : 'sbagliato');
// CORRETTO: 32 byte da un generatore crittografico, 256 bit di entropia, nessuna relazione con l'ora
const strongToken = () => crypto.randomBytes(32).toString('base64url');
console.log('token casuale:', strongToken().length, 'caratteri, due emissioni uguali?', strongToken() === strongToken());
// Token firmato (HMAC) e sua verifica. timingSafeEqual lancia un'eccezione se le lunghezze differiscono
const secret = crypto.randomBytes(32);
const sign = (payload) => crypto.createHmac('sha256', secret).update(payload).digest();
const verifyNaive = (payload, mac) => crypto.timingSafeEqual(sign(payload), mac);
try { verifyNaive('uid=7', Buffer.from('troppo-corto')); } catch (e) { console.log('lunghezza diversa senza controllo:', e.code); }
const verifySafe = (payload, mac) => {
const expected = sign(payload);
return mac.length === expected.length && crypto.timingSafeEqual(expected, mac);
};
console.log('MAC giusto :', verifySafe('uid=7', sign('uid=7')));
console.log('MAC sbagliato:', verifySafe('uid=7', sign('uid=8')));
console.log('MAC di lunghezza errata:', verifySafe('uid=7', Buffer.from('x')));
// Output:
// token prevedibile: trovato dopo 2001 tentativi, istante corretto
// token casuale: 43 caratteri, due emissioni uguali? false
// lunghezza diversa senza controllo: ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
// MAC giusto : true
// MAC sbagliato: false
// MAC di lunghezza errata: false
Il token prevedibile cade in 2001 tentativi: l'istante vero sta a metà della finestra di 4 secondi, 2000 millisecondi dopo il suo inizio, quindi la ricerca si ferma al 2001° candidato: un lavoro di pochi millisecondi. Il token da randomBytes(32) è lungo 43 caratteri in base64url e non ha relazioni con l'ora. La parte finale del listato riguarda la verifica di un MAC: timingSafeEqual confronta in tempo costante (contro CWE-208, «Observable Timing Discrepancy»), ma lancia ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH se le due lunghezze differiscono. Chi omette il controllo di lunghezza ottiene un'eccezione non gestita (tipicamente un 500) invece di un rifiuto, e quella differenza di comportamento dice all'attaccante che il formato è sbagliato. Tre risposte (giusto, sbagliato, lunghezza errata) devono sempre produrre lo stesso esito di rifiuto.
Tre parole che si confondono e una lista di controllo
- Hash: unidirezionale, serve a verificare (password, integrità), con un costo scelto da te
- Cifratura: reversibile con una chiave, serve a custodire un dato che poi leggerai
- Codifica (base64, hex): reversibile da chiunque, nessuna protezione
Le tre voci non hanno un ordine di importanza e si confondono di continuo: si cifra una password che andava hashata, e quindi chi ruba la chiave le legge tutte; si chiama «cifrato» un token in base64, che chiunque decodifica. Prima di scrivere una riga di crittografia vale la domanda che le separa: ti serve poter ritrovare il dato originale, o solo verificare che chi lo presenta lo conosce?
Restano fuori da questa prova la gestione delle chiavi (CWE-321, «Use of Hard-coded Cryptographic Key», si evita con ciò che spiega Secret management e rotazione delle credenziali), la configurazione TLS, la Web Crypto API del browser, le firme RSA ed EC e la crittografia post-quantistica: argomenti da trattare con le rispettive fonti, non con un cenno. I JWT, dove gli stessi errori (chiavi deboli, algoritmo scelto dal token) hanno effetto diretto, sono in API Security: OAuth 2.1, JWT e rate limiting; le sessioni e i cookie, in Autenticazione sicura; l'articolo precedente, Supply chain security, riguarda le librerie che userai per tutto questo, e il successivo, DevSecOps per developer, come far verificare queste regole alla pipeline.
Che cosa sai fare adesso
Sai riconoscere in una revisione i quattro errori che non danno sintomi: ECB, IV riusato, hash veloce per le password, token derivato da dati noti. Sai cifrare con AES-256-GCM e IV casuale e riconoscere un cifrato alterato, salvare una password con Argon2id con i parametri minimi dell'OWASP e verificarla con un confronto a tempo costante, generare un token con randomBytes. Sai anche scrivere per ciascuno una prova che fallisce sul codice sbagliato, ed è la parte che si può automatizzare nella pipeline.
Domande frequenti
Perché non si usa SHA-256 per salvare le password?
Perché è progettato per essere veloce, e un attaccante con l'archivio può provare enormi quantità di password al secondo. L'OWASP indica Argon2id, o in alternativa scrypt, bcrypt o PBKDF2, tutti con un costo regolabile e un sale per utente.
Che cosa succede se in AES-GCM riuso l'IV?
La riservatezza dei messaggi cifrati con quella chiave viene compromessa: lo XOR dei due cifrati è lo XOR dei due testi, e la NIST SP 800-38D avverte che la violazione espone anche ad attacchi di falsificazione. L'IV deve essere unico per chiave.
Perché AES-ECB è considerato insicuro?
Perché lo stesso blocco di testo produce sempre lo stesso blocco di cifrato con la stessa chiave, quindi la struttura dei dati resta visibile. La NIST SP 800-38A lo dice esplicitamente.
Come si genera un token di reset sicuro?
Con un generatore crittografico, per esempio crypto.randomBytes(32) in Node, senza derivarlo da email o ora. Si salva sul server il suo hash con scadenza breve e lo si invalida al primo uso.
Nella stessa serie trovi anche OWASP Top 10 2025, XSS, CSRF e CSP e SQL injection e validazione dell'input.
Fonti
- OWASP Top 10:2025, A04 Cryptographic Failures - le domande di verifica su IV, ECB, password come chiavi e casualità (pagina letta), consultata il 2026-11-13
- NIST SP 800-38A: Recommendation for Block Cipher Modes of Operation (2001) - la proprietà di ECB e l'avvertenza sul suo uso (testo letto), consultata il 2026-11-13
- NIST SP 800-38D: Galois/Counter Mode and GMAC (novembre 2007) - §8 unicità dell'IV (probabilità non oltre 2-32) e §8.3 limite di 232 invocazioni con IV casuali (testo letto), consultata il 2026-11-13
- OWASP Password Storage Cheat Sheet - sale unico, rifiuto degli hash veloci e parametri minimi di Argon2id, scrypt, bcrypt e PBKDF2 (pagina letta), consultata il 2026-11-13
- Node.js v24 API: crypto - firma e parametri di crypto.argon2Sync, aggiunto nella v24.7.0, consultata il 2026-11-13
- CWE-330: Use of Insufficiently Random Values - il nome ufficiale della debolezza dei token prevedibili; analoghi i CWE-338, 208 e 321 citati nel testo, consultata il 2026-11-13
I quattro listati sono stati eseguiti due volte ciascuno con uscita identica; sono prove didattiche su dati inventati e non sostituiscono la revisione di un esperto. Non sono stati eseguiti né trattati: TLS, Web Crypto, firme asimmetriche, post-quantistico.

Commenti
Caricamento commenti...
Accedi per commentare