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:

confronto.jsJavaScript
// 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:

la scala del canale laterale
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#

sicuro.jsJavaScript
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:

lunghezza.jsJavaScript
// ✗ 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.

webhook.jsJavaScript
// 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:

richiesta.jsJavaScript
// ✗ 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:

canale-laterale.jsJavaScript
// 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');}
misura
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.
La mediana e non la media: il garbage collector e lo scheduler producono valori anomali che la media assorbe e la mediana ignora.

Dove applicarlo e dove no#

ConfrontoTempo costante?Perché
Firma HMAC di un webhookl'attaccante può inviare tentativi illimitati e osservarne i tempi
Token di sessione o di resetstessa ragione; e nel database va comunque l'impronta, non il token
Chiave APIidem
Codice 2FA / OTPlo spazio è già piccolo: qualunque aiuto lo rende attaccabile
Password (via bcrypt/Argon2)già inclusobcrypt.compare lo fa internamente: non riscrivetelo
Nome utente, email, slugnonon sono segreti; usare timingSafeEqual qui è solo rumore nel codice
Esistenza di un accounta livello di richiestail 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.