Esiste una categoria di attacchi che non sfrutta alcun errore nel codice. Il codice è corretto, fa esattamente quello che deve, non ha falle logiche. L'informazione esce da un'altra parte: dal tempo che il sistema impiega a rispondere.
Il meccanismo#
Quasi ogni confronto fra due sequenze, in qualunque linguaggio, è scritto così — e per il novantanove per cento degli usi è la scelta giusta:
// Come confronta due stringhe qualunque linguaggio, per ragioni// perfettamente sensate: appena trova una differenza, si ferma.function uguali(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; // ← esce al PRIMO carattere sbagliato } return true;}// Il tempo di esecuzione è proporzionale al numero di caratteri iniziali// corretti. Non è un bug: è un canale laterale. Il codice non rivela il// segreto, lo rivela il tempo che impiega a dire di no.Uscire appena si trova una differenza è un'ottimizzazione ovvia. Il problema è che il quando si esce dipende dal contenuto del segreto, e il tempo di esecuzione è osservabile:
segreto = "a3f9c2..."prova "0..." → esce a i=0 → ~40 nsprova "1..." → esce a i=0 → ~40 nsprova "a..." → esce a i=1 → ~52 ns ← misurabilmente più lentoprova "a0.." → esce a i=1 → ~52 nsprova "a3.." → esce a i=2 → ~64 ns ← ancora più lentoUn carattere alla volta, l'attaccante tiene quello che rallenta la risposta.Per un token esadecimale da 32 caratteri: 16 valori × 32 posizioni = 512tentativi mirati, invece dei 16^32 di una ricerca esaustiva.La conseguenza è la parte importante: l'attacco trasforma una ricerca esponenziale in una ricerca lineare. Un token esadecimale da 32 caratteri ha 16³² combinazioni — un numero che non si esaurisce. Ma indovinandolo un carattere alla volta bastano 16 tentativi per posizione, 512 in tutto. È la differenza fra impossibile e un minuto di lavoro.
«Ma in rete il rumore copre tutto»#
È l'obiezione standard, ed è vera solo in parte. Il jitter della rete è di ordini di grandezza superiore ai nanosecondi in gioco — ma è rumore, e il rumore si media. Un attaccante che ripete la stessa misura mille volte estrae la differenza sistematica da sotto la varianza casuale.
Non è teoria: lo studio di riferimento di Crosby, Wallach e Riedi (2009) misurò differenze di circa 100 nanosecondi su rete locale e nell'ordine delle decine di microsecondi su Internet, con un numero di campioni del tutto praticabile. E da allora sono cambiate due cose, entrambe a favore dell'attaccante: la rete è più stabile e i server sono più spesso a un salto di distanza, nello stesso datacenter.
Il confronto a tempo costante#
const crypto = require('crypto');// Confronta SEMPRE tutti i byte, qualunque sia il risultato. Nessun ritorno// anticipato, nessun ramo che dipenda dal contenuto.crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));// Non è JavaScript che gira in tempo costante: è una chiamata a// CRYPTO_memcmp di OpenSSL, codice nativo scritto apposta perché il// compilatore non lo trasformi in un'uscita anticipata.Il punto da capire è perché serve una funzione apposita invece di scrivere un ciclo senza uscita anticipata. In JavaScript non avete alcun controllo su cosa fa il compilatore JIT: può ricostruire un salto condizionato, può vettorizzare, può eliminare un ciclo il cui risultato considera già determinato. timingSafeEqual non è JavaScript: è un ponte verso CRYPTO_memcmp di OpenSSL, scritto in C proprio perché nessun ottimizzatore lo tocchi.
La trappola della lunghezza#
Qui casca la maggior parte delle implementazioni che pure hanno fatto la cosa giusta:
// ✗ Trappola: timingSafeEqual SOLLEVA un'eccezione se i buffer hanno// lunghezza diversa. Un try/catch che ritorna false ricrea esattamente// il canale laterale che si voleva chiudere — sulla lunghezza.function sbagliato(a, b) { try { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } catch { return false; // ← immediato: rivela che le lunghezze differiscono }}// ✓ Corretto: si confrontano IMPRONTE, che hanno sempre la stessa lunghezza.// È il pattern noto come "double HMAC" o "double hash": qualunque cosa// entri, si confrontano 32 byte contro 32 byte.function corretto(a, b) { const ha = crypto.createHash('sha256').update(a, 'utf8').digest(); const hb = crypto.createHash('sha256').update(b, 'utf8').digest(); return crypto.timingSafeEqual(ha, hb);}timingSafeEqual richiede buffer della stessa lunghezza e lancia un'eccezione altrimenti. Il try/catch che sembra la gestione naturale dell'errore è precisamente il ritorno anticipato che si voleva evitare: risponde in un tempo diverso e rivela che la lunghezza non corrisponde.
La soluzione è il pattern del doppio hash (o doppio HMAC): si applica un digest a entrambi i valori e si confrontano i digest. Qualunque cosa entri — un token da 8 caratteri o da 800 — si confrontano sempre 32 byte contro 32 byte, e la lunghezza dell'input smette di essere osservabile.
Dove serve davvero: le firme dei webhook#
Nella pratica quotidiana il caso più frequente non sono le password — quelle passano già per un hash lento, che a tempo costante lo è per costruzione. È la verifica delle firme sulle richieste in arrivo: pagamenti, notifiche di consegna, integrazioni fra servizi.
// Il caso in cui questo conta di più, e in cui viene sbagliato più spesso:// la verifica della firma di un webhook. Qui il "segreto" è una firma HMAC// che l'attaccante può tentare di indovinare a colpi di richieste.function firmaValida(corpoGrezzo, intestazione, segreto) { const attesa = crypto .createHmac('sha256', segreto) .update(corpoGrezzo, 'utf8') // il corpo GREZZO, non il JSON riserializzato .digest(); let ricevuta; try { ricevuta = Buffer.from(intestazione, 'hex'); } catch { return false; } if (ricevuta.length !== attesa.length) return false; // qui la lunghezza // è pubblica: è fissa return crypto.timingSafeEqual(attesa, ricevuta);}Un secondo dettaglio in quel codice, che non riguarda i tempi ma è altrettanto sbagliato altrettanto spesso: l'HMAC va calcolato sul corpo grezzo della richiesta, esattamente i byte ricevuti. Se il framework ha già eseguito il parsing del JSON e voi riserializzate l'oggetto per firmarlo, basta una differenza nell'ordine delle chiavi o negli spazi perché la firma non corrisponda mai — o, peggio, perché corrisponda per payload diversi.
La fuga che rientra dalla finestra#
Questa è la parte che rende il resto quasi decorativo. Si può confrontare il token in tempo perfettamente costante e avere comunque una richiesta il cui tempo totale dice se il token esisteva:
// ✗ Il confronto è a tempo costante, ma l'intera richiesta non lo è.// Token inesistente: si esce dopo una query.// Token esistente ma sbagliato: query + caricamento utente + permessi.// La differenza è di millisecondi — mille volte più grande del canale// laterale che avete appena chiuso con tanta cura.async function autentica(token) { const sessione = await db.sessione(hash(token)); if (!sessione) return null; // ← veloce const utente = await db.utente(sessione.utenteId); // ← lento const permessi = await db.permessi(utente.id); // ← ancora più lento return { utente, permessi };}// ✓ Il ramo negativo deve costare quanto quello positivo. Il modo più// robusto non è aggiungere ritardi finti (che si mediano via), ma fare// comunque il lavoro su dati fittizi.const SESSIONE_FINTA = { utenteId: UTENTE_INESISTENTE };async function autenticaPari(token) { const sessione = await db.sessione(hash(token)) || SESSIONE_FINTA; const utente = await db.utente(sessione.utenteId); const permessi = utente ? await db.permessi(utente.id) : await db.permessiVuoti(); if (sessione === SESSIONE_FINTA || !utente) return null; return { utente, permessi };}I nanosecondi del confronto sono stati chiusi con cura; poi il ramo «token valido» esegue due query in più e il tempo di risposta differisce di millisecondi. Il canale laterale non è stato chiuso: è stato spostato in un punto mille volte più facile da misurare.
È lo stesso principio per cui, nell'articolo sulle password, il ramo «utente inesistente» calcola comunque un hash che poi butta via.
Vederlo con i propri occhi#
Il modo più rapido per convincersi che il fenomeno è reale è misurarlo. Ci vogliono dieci righe:
// Vedere il canale laterale con i propri occhi: 200 000 ripetizioni per// candidato, si tiene la MEDIANA (la media è dominata dai valori anomali// del garbage collector e dello scheduler del sistema operativo).const SEGRETO = 'a3f9c2d18e0b47f6a3f9c2d18e0b47f6';function misura(candidato, giri = 200_000) { const t = []; for (let g = 0; g < giri; g++) { const t0 = process.hrtime.bigint(); uguali(candidato, SEGRETO); t.push(Number(process.hrtime.bigint() - t0)); } t.sort((x, y) => x - y); return t[t.length >> 1]; // mediana, in nanosecondi}for (const c of ['0'.repeat(32), 'a' + '0'.repeat(31), 'a3' + '0'.repeat(30)]) { console.log(c.slice(0, 4), '→', misura(c), 'ns');}node canale-laterale.js0000 → 41 nsa000 → 53 nsa300 → 66 ns# +12 ns per ogni carattere indovinato: e' tutto quello che serve.# In locale e' evidente. In rete serve mediare su piu' campioni,# ma non e' una barriera: e' solo un numero di tentativi piu' alto.Dove applicarlo e dove no#
| Confronto | Tempo costante? | Perché |
|---|---|---|
| Firma HMAC di un webhook | sì | l'attaccante può inviare tentativi illimitati e osservarne i tempi |
| Token di sessione o di reset | sì | stessa ragione; e nel database va comunque l'impronta, non il token |
| Chiave API | sì | idem |
| Codice 2FA / OTP | sì | lo spazio è già piccolo: qualunque aiuto lo rende attaccabile |
| Password (via bcrypt/Argon2) | già incluso | bcrypt.compare lo fa internamente: non riscrivetelo |
| Nome utente, email, slug | no | non sono segreti; usare timingSafeEqual qui è solo rumore nel codice |
| Esistenza di un account | a livello di richiesta | il confronto non c'entra: va pareggiato il tempo dell'intero percorso |
L'ultima riga è la sintesi dell'articolo. Il confronto a tempo costante è una riga di codice; ragionare su quali informazioni il vostro sistema espone attraverso il tempo è un'abitudine, e serve molto più spesso della funzione.