OTP and simple electronic signature: verification pattern and audit trail
How to build a traceable acknowledgement confirmation β OTP code via email, document hash, audit trail β with no Firebase Authentication, no SMS, and without calling it a "digital signature" if it isn't one.
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.
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.
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' });
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.
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 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
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.