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".
The terminology distinction to clarify first
In Italy, "digital signature" is a precise legal term (qualified electronic signature under the Digital Administration Code, issued by an accredited certifier, with a smart card or remote signing). A pattern with OTP + hash + audit trail is a simple electronic signature: name, timestamp, IP, document hash, verified code. It's a reasonable approach for internal use — but using the wrong term in the UI creates legal expectations the system doesn't meet. In the UI, "acknowledgement confirmation with code verification" is the safer label.
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.
Why not Firebase Auth / SMS OTP
If users are already identified inside the app with their own account, a second "official" SMS authentication layer is often redundant — on top of a non-trivial SMS cost and reCAPTCHA setup. The simpler pattern: generate the 6-digit OTP server-side, store only its hash, and send it via email through a service already used in the app. Zero new infrastructure.
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).
Generation: hash + salt, never plaintext
Even in case of unauthorized database access, the code must not be recoverable: only its SHA-256 hash with a random salt is stored, with a short expiry (5 minutes).
// 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.
Verification: timing-safe comparison and attempt limit
A "normal" string comparison (===) can leak, through execution time, information about match length — a timing attack. crypto.timingSafeEqual compares in constant time. After 5 failed attempts the record should be automatically invalidated.
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.
Document SHA-256 hash: crypto.subtle on the client
Before the user can request the OTP, the document must be downloaded and its SHA-256 hash computed client-side with the Web Crypto API — no external library needed. The hash is sent along with the verification request and saved in the record: it ensures the specific document viewed is the one attested in the audit trail.
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
Checklist for a credible audit trail
- Correct terminology in the UI — never "digital signature" unless it legally is one
- OTP code never stored in plaintext, only hash+salt with a short expiry
- Timing-safe comparison and attempt limit to block brute force
- IP captured server-side, never trusting a client-supplied value
- Document hash computed client-side and saved in the signature record
- Audit trail record immutable once written: name, timestamp, IP, hash, outcome
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.
Frequently asked questions
Does this system count as a legal digital signature?
No. It's a simple electronic signature with audit trail (name, timestamp, IP, hash, verified OTP), not a qualified electronic signature under Italy's Digital Administration Code, which requires an accredited certifier.
Why store only the OTP code's hash and not the code itself?
So that even in case of unauthorized database access, the code isn't recoverable. Only the SHA-256 hash with a random salt is stored, never the plaintext value.
Why use crypto.timingSafeEqual instead of a normal string comparison?
A normal string comparison can leak, through execution time, information about match length — a timing attack. timingSafeEqual compares in constant time.
Articolo correlato
Il sistema completo: audit trail Firestore, hash PDF e certificato con pdf-lib
Related article
The full system: Firestore audit trail, PDF hash and pdf-lib certificate
→