← blog
OTP Firma elettronica Audit trail Sicurezza

OTP e firma elettronica semplice: pattern di verifica e audit trail

Come costruire una conferma di presa visione tracciabile — codice OTP via email, hash del documento, audit trail — senza Firebase Authentication, senza SMS e senza chiamarla "firma digitale" se non lo è.

La distinzione terminologica da chiarire prima

In Italia "firma digitale" è un termine legale preciso (firma elettronica qualificata secondo il Codice dell'Amministrazione Digitale, rilasciata da un certificatore accreditato, con smart card o firma remota). Un pattern con OTP + hash + audit trail è una firma elettronica semplice: nome, timestamp, IP, hash del documento, codice verificato. È un approccio ragionevole per uso interno — ma usare il termine sbagliato nell'interfaccia crea aspettative legali che il sistema non soddisfa. Nella UI, meglio "conferma di presa visione con verifica via codice".

Perché non Firebase Auth / SMS OTP

Se gli utenti sono già identificati dentro l'app con un proprio account, un secondo livello di autenticazione "ufficiale" via SMS è spesso ridondante — oltre ad avere un costo non banale sugli SMS e richiedere configurazione reCAPTCHA. Il pattern più semplice: generare l'OTP a 6 cifre server-side, salvarne solo l'hash, e inviarlo via email attraverso un servizio già in uso nell'app. Zero infrastruttura nuova.

Generazione: hash + salt, mai il codice in chiaro

Anche in caso di accesso non autorizzato al database, il codice non deve essere recuperabile: si salva solo l'hash SHA-256 con salt casuale, con scadenza breve (5 minuti).

Node.js — generazione OTP con hash+salt
// Genera codice a 6 cifre
const otp     = String(crypto.randomInt(100000, 999999));
const salt    = crypto.randomBytes(16).toString('hex');
const otpHash = crypto.createHash('sha256')
  .update(otp + salt)
  .digest('hex');
const scadenza = Date.now() + 5 * 60 * 1000; // 5 minuti

// Salva solo hash+salt, mai il codice in chiaro
await saveRecord({
  otpHash, salt, scadenza,
  tentativi: 0,
  stato: 'in_attesa'
});

Verifica: timing-safe comparison e limite tentativi

Un confronto stringa "normale" (===) può rivelare, tramite il tempo di esecuzione, informazioni sulla lunghezza della corrispondenza — un timing attack. crypto.timingSafeEqual confronta in tempo costante. Dopo 5 tentativi falliti il record va invalidato automaticamente.

Node.js — verifica OTP timing-safe
const rec = await getRecord(recordKey);

// Controllo scadenza
if (Date.now() > rec.scadenza) {
  return err(400, 'Codice scaduto');
}
// Controllo max tentativi
if (rec.tentativi >= 5) {
  return err(400, 'Troppi tentativi');
}

// Timing-safe compare — evita timing attacks
const hashInput  = crypto.createHash('sha256').update(codice + rec.salt).digest('hex');
const bufInput   = Buffer.from(hashInput,   'hex');
const bufStored  = Buffer.from(rec.otpHash, 'hex');

if (bufInput.length !== bufStored.length ||
    !crypto.timingSafeEqual(bufInput, bufStored)) {
  await saveRecord({ ...rec, tentativi: rec.tentativi + 1 });
  return err(401, `Codice errato. Tentativi rimasti: ${4 - rec.tentativi}`);
}

// OTP corretto — cattura IP server-side e registra
const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'sconosciuto';

Perché l'IP va letto server-side: il client non conosce in modo affidabile il proprio IP pubblico. Va letto dall'header x-forwarded-for al momento della verifica, garantendo che sia autentico e non manipolabile dal client.

Hash SHA-256 del documento: crypto.subtle lato client

Prima che l'utente possa richiedere l'OTP, il documento va scaricato e il suo hash SHA-256 calcolato client-side con la Web Crypto API — nessuna libreria esterna. L'hash va inviato insieme alla richiesta di verifica e salvato nel record: garantisce che il documento specifico visualizzato sia quello attestato nell'audit trail.

JavaScript — hash SHA-256 con Web Crypto API
async function calcolaHashDocumento(blob) {
  const arrayBuffer = await blob.arrayBuffer();
  const hashBuffer  = await crypto.subtle.digest('SHA-256', arrayBuffer);
  const hashArray   = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

Trappola CORS su Firebase Storage: un fetch(downloadURL) può fallire silenziosamente — nessun errore visibile in console, ma il blob resta vuoto. Firebase Storage non configura CORS automaticamente per fetch da origini esterne. Serve una configurazione one-time del bucket con gsutil, nessuna modifica al codice.

Checklist per un audit trail credibile

  • Terminologia corretta nell'interfaccia — mai "firma digitale" se non lo è legalmente
  • Codice OTP mai salvato in chiaro, solo hash+salt con scadenza breve
  • Confronto timing-safe e limite tentativi per bloccare il brute force
  • IP catturato server-side, mai fidandosi di un valore inviato dal client
  • Hash del documento calcolato client-side e salvato nel record di firma
  • Record di audit trail immutabile una volta scritto: nome, timestamp, IP, hash, esito

Domande frequenti

Questo sistema equivale a una firma digitale legale?

No. È una firma elettronica semplice con audit trail (nome, timestamp, IP, hash, OTP verificato), non una firma elettronica qualificata secondo il Codice dell'Amministrazione Digitale, che richiede un certificatore accreditato.

Perché salvare solo l'hash del codice OTP e non il codice stesso?

Così, anche in caso di accesso non autorizzato al database, il codice non è recuperabile. Si salva l'hash SHA-256 con salt casuale, mai il valore in chiaro.

Perché usare crypto.timingSafeEqual invece di un confronto stringa normale?

Un confronto stringa normale può rivelare, tramite il tempo di esecuzione, informazioni sulla lunghezza della corrispondenza — un timing attack. timingSafeEqual confronta in tempo costante.

Articolo correlato
Il sistema completo: audit trail Firestore, hash PDF e certificato con pdf-lib