Il contesto e la distinzione terminologica
L'esigenza era inviare ai membri del team dei documenti interni — regolamenti, aggiornamenti procedurali — e raccogliere una conferma tracciabile che fossero stati letti e accettati. Il punto di partenza era una proposta classica: Firebase Authentication con OTP via SMS, salvataggio su Firestore, generazione di un certificato PDF.
Prima ancora di scrivere una riga di codice, è valsa la pena chiarire la terminologia. In Italia "firma digitale" è un termine legale preciso (firma elettronica qualificata secondo il Codice dell'Amministrazione Digitale, rilasciata da un certificatore accreditato tipo Aruba o InfoCert, con smart card o firma remota). Quello che si costruisce qui è una firma elettronica semplice con audit trail: nome, timestamp, IP, hash del documento, codice OTP verificato. È un approccio comune e ragionevole per uso interno, ma usare il termine sbagliato nell'interfaccia crea aspettative legali che il sistema non può soddisfare. Nella UI ho usato "conferma di presa visione con verifica via codice OTP".
Context and the terminology distinction
The requirement was to send internal documents to team members — regulations, procedural updates — and collect traceable confirmation that they had been read and accepted. The starting point was a classic proposal: Firebase Authentication with SMS OTP, Firestore storage, PDF certificate generation.
Before writing a single line of code, it was worth clarifying the terminology. In Italy, "firma digitale" (digital signature) is a precise legal term (qualified electronic signature under the Digital Administration Code, issued by an accredited certifier such as Aruba or InfoCert, with smart card or remote signing). What is built here is a simple electronic signature with audit trail: name, timestamp, IP, document hash, verified OTP code. It's a common and reasonable approach for internal use, but using the wrong term in the UI creates legal expectations the system cannot meet. In the UI I used "acknowledgement confirmation with OTP code verification".
Perché non Firebase Authentication per l'OTP
La proposta originale prevedeva Firebase Authentication con Phone Auth (OTP via SMS). Il problema: gli utenti sono già identificati dentro la PWA con il proprio account, non serve un secondo livello di autenticazione "ufficiale". E Firebase Phone Auth ha un costo non banale sugli SMS italiani, richiede configurazione reCAPTCHA e numeri di telefono verificati.
La soluzione più semplice: generare il codice OTP a 6 cifre server-side in una Netlify Function, salvare solo l'hash (mai il codice in chiaro) su Firestore con scadenza di 5 minuti, e inviarlo via email attraverso EmailJS — canale già in uso nella PWA. Zero nuova infrastruttura, zero costi aggiuntivi.
Why not Firebase Authentication for OTP
The original proposal included Firebase Authentication with Phone Auth (SMS OTP). The problem: users are already identified inside the PWA with their own account, a second "official" authentication layer isn't needed. And Firebase Phone Auth has a non-trivial cost for Italian SMS messages, requires reCAPTCHA configuration and verified phone numbers.
The simpler solution: generate the 6-digit OTP server-side in a Netlify Function, save only the hash (never the plaintext code) to Firestore with a 5-minute expiry, and send it via email using EmailJS — a channel already in use in the PWA. Zero new infrastructure, zero extra costs.
L'architettura del sistema
Il flusso si articola in tre layer distinti:
System architecture
The flow is structured in three distinct layers:
| Layer | Componente | Responsabilità |
|---|---|---|
| Admin | UI + Firebase Storage | Carica PDF in NuoviAccordi/{opKey}/, crea record Firestore, notifica l'utente via push FCM |
| Server | Netlify Function firma-otp.js |
Genera OTP, salva hash+salt su Firestore, invia email, verifica codice, cattura IP, aggiorna record finale |
| Operatore | UI PWA + pdf-lib CDN | Visualizza PDF in iframe, calcola hash SHA-256 client-side, inserisce OTP, riceve conferma, apre certificato |
La struttura dati su Firestore è una singola collezione firmeDocumenti con chiave composta {docId}__{opKey}. Il record viene creato dall'admin al momento dell'invio con stato in_attesa e aggiornato dalla Netlify Function dopo la verifica OTP riuscita con stato firmato, includendo timestamp, IP, hash PDF e path del certificato.
The Firestore data structure is a single firmeDocumenti collection with composite key {docId}__{opKey}. The record is created by the admin at send time with status in_attesa and updated by the Netlify Function after successful OTP verification with status firmato, including timestamp, IP, PDF hash and certificate path.
La Netlify Function: generazione e verifica OTP
La function gestisce due azioni distinte sullo stesso endpoint: richiedi (genera e invia il codice) e verifica (controlla il codice inserito dall'utente). Entrambe sono protette da un header x-internal-secret condiviso, stesso pattern già usato nelle altre function del progetto.
Generazione: hash + salt, mai il codice in chiaro
Il codice OTP a 6 cifre viene generato con crypto.randomInt, ma su Firestore non viene mai salvato il codice in chiaro. Viene salvato solo il suo hash SHA-256 con salt casuale — così anche in caso di accesso non autorizzato al database, il codice non è recuperabile.
The Netlify Function: OTP generation and verification
The function handles two distinct actions on the same endpoint: richiedi (generate and send the code) and verifica (check the code entered by the user). Both are protected by a shared x-internal-secret header, the same pattern already used in the project's other functions.
Generation: hash + salt, never plaintext
The 6-digit OTP code is generated with crypto.randomInt, but the plaintext code is never saved to Firestore. Only its SHA-256 hash with a random salt is stored — so even in case of unauthorised database access, the code cannot be recovered.
// 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
await fsSetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`, {
otpHash, salt, scadenza,
tentativi: 0,
stato: 'in_attesa'
});
Verifica: timing-safe comparison e max 5 tentativi
La verifica usa crypto.timingSafeEqual per confrontare gli hash, evitando timing attacks dove un confronto stringa normale potrebbe rivelare informazioni sulla lunghezza della corrispondenza. Dopo 5 tentativi falliti il record viene invalidato automaticamente.
Verification: timing-safe comparison and max 5 attempts
Verification uses crypto.timingSafeEqual to compare hashes, avoiding timing attacks where a normal string comparison could reveal information about match length. After 5 failed attempts the record is automatically invalidated.
const rec = await fsGetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`);
// 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)) {
// Incrementa contatore tentativi
await fsSetDoc(idToken, key, { ...rec, tentativi: rec.tentativi + 1 });
return err(401, `Codice errato. Tentativi rimasti: ${4 - rec.tentativi}`);
}
// OTP corretto — cattura IP e registra firma
const ip = event.headers['x-forwarded-for']?.split(',')[0].trim() || 'sconosciuto';
L'IP non può essere catturato dal client in modo affidabile — il client non conosce il proprio IP pubblico. Viene letto server-side dall'header x-forwarded-for al momento della verifica OTP, garantendo che sia autentico e non manipolabile dal client.
EmailJS server-side: la private key
EmailJS è già usato nella PWA dal browser — ma le chiamate client-side usano la public key, che è sufficiente solo quando la richiesta arriva dall'origin autorizzato nel dashboard EmailJS. Da una Netlify Function (origin: server, non il dominio della app), serve invece la private key, che bypassa il controllo sull'origin.
La private key si trova su EmailJS → Account → API Keys. Va salvata come variabile d'ambiente su Netlify (EMAILJS_PRIVATE_KEY) e non va mai messa nel codice frontend. La chiamata da server usa l'endpoint REST di EmailJS con il campo accessToken al posto della publicKey.
EmailJS server-side: the private key
EmailJS is already used in the PWA from the browser — but client-side calls use the public key, which is sufficient only when the request comes from the origin authorised in the EmailJS dashboard. From a Netlify Function (origin: server, not the app's domain), the private key is needed instead, which bypasses the origin check.
The private key is found at EmailJS → Account → API Keys. It should be saved as a Netlify environment variable (EMAILJS_PRIVATE_KEY) and must never be put in frontend code. The server-side call uses the EmailJS REST endpoint with the accessToken field instead of publicKey.
const emailRes = await fetch('https://api.emailjs.com/api/v1.0/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service_id: process.env.EMAILJS_SERVICE_ID,
template_id: process.env.EMAILJS_TEMPLATE_OTP || 'template_otp_firma',
user_id: process.env.EMAILJS_PUBLIC_KEY,
// accessToken = private key — bypassa il check sull'origin
accessToken: process.env.EMAILJS_PRIVATE_KEY,
template_params: {
to_email: emailOperatore,
nome: nomeOperatore,
codice: otp,
documento: titoloDoc
}
})
});
// Il template EmailJS deve avere le variabili: {{to_email}}, {{nome}}, {{codice}}, {{documento}}
Hash SHA-256 del PDF: crypto.subtle lato client
Prima che l'utente possa richiedere il codice OTP, il PDF viene scaricato da Firebase Storage e l'hash SHA-256 viene calcolato client-side con la Web Crypto API — nessuna libreria esterna, disponibile su tutti i browser moderni. L'hash viene poi inviato alla Netlify Function insieme alla richiesta di verifica, che lo salva nel record di firma. Questo garantisce che il documento specifico visualizzato sia quello attestato nell'audit trail.
PDF SHA-256 hash: crypto.subtle on the client
Before the user can request the OTP code, the PDF is downloaded from Firebase Storage and the SHA-256 hash is computed client-side with the Web Crypto API — no external library, available in all modern browsers. The hash is then sent to the Netlify Function along with the verification request, which saves it in the signature record. This ensures that the specific document viewed is the one attested in the audit trail.
async function calcolaHashPdf(pdfBlob) {
const arrayBuffer = await pdfBlob.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('');
}
// Scarica blob da Storage e calcola hash prima di mostrare il documento
const pdfRef = storageRef(storage, item.pdfPath);
const url = await getDownloadURL(pdfRef);
const res = await fetch(url);
const blob = await res.blob();
_firmaPdfBytes = await blob.arrayBuffer(); // conserva per pdf-lib
_firmaHashPdf = await calcolaHashPdf(blob);
Il problema CORS di Firebase Storage
Il primo tentativo di scaricare il PDF con fetch(downloadURL) falliva silenziosamente — nessun errore visibile in console, ma _firmaPdfBytes restava null e il certificato non veniva mai generato. Il motivo: Firebase Storage non configura automaticamente le policy CORS per le richieste fetch() da origini esterne. Il browser blocca la risposta prima ancora che JavaScript possa vederla.
La soluzione è una configurazione one-time sul bucket tramite gsutil (Google Cloud SDK). Non richiede nessuna modifica al codice — è una configurazione lato bucket:
The Firebase Storage CORS problem
The first attempt to download the PDF with fetch(downloadURL) failed silently — no visible error in console, but _firmaPdfBytes stayed null and the certificate was never generated. The reason: Firebase Storage does not automatically configure CORS policies for fetch() requests from external origins. The browser blocks the response before JavaScript can even see it.
The solution is a one-time bucket configuration via gsutil (Google Cloud SDK). No code changes required — it's a bucket-level configuration:
[
{
"origin": ["https://[APP_DOMAIN]"],
"method": ["GET"],
"maxAgeSeconds": 3600,
"responseHeader": ["Content-Type", "Content-Disposition"]
}
]
# Applica la configurazione al bucket
gsutil cors set cors.json gs://[FIREBASE_PROJECT_ID].firebasestorage.app
# Verifica che sia stata applicata correttamente
gsutil cors get gs://[FIREBASE_PROJECT_ID].firebasestorage.app
# Output atteso:
# [{"maxAgeSeconds": 3600, "method": ["GET"], "origin": ["https://[APP_DOMAIN]"], ...}]
L'<iframe> per la preview del PDF funziona senza CORS configurato perché è una navigazione browser, non una fetch(). Il CORS è richiesto solo per i download programmati via JavaScript. Questo spiega perché la preview è visibile ma i byte non sono leggibili dal codice.
Il limite 4KB delle env var Netlify: addio service account
Netlify Functions hanno un limite di 4KB totali per le variabili d'ambiente iniettate in ogni function. Una chiave privata RSA per un service account Firebase occupa circa 1.7-2KB — sommata ad altre variabili già presenti, il limite viene superato e il deploy fallisce con errore HTTP 400.
La soluzione: eliminare il service account e autenticarsi su Firebase tramite le stesse credenziali dell'account condiviso già usato dalla PWA. Questo restituisce un ID token che soddisfa le regole di sicurezza Firestore (request.auth != null) senza necessitare di nessun PEM da 1.7KB.
The Netlify 4KB env var limit: goodbye service account
Netlify Functions have a 4KB total limit for environment variables injected into each function. A Firebase service account RSA private key takes up about 1.7-2KB — combined with other already-present variables, the limit is exceeded and the deploy fails with an HTTP 400 error.
The solution: eliminate the service account and authenticate to Firebase using the same shared account credentials already used by the PWA. This returns an ID token that satisfies Firestore security rules (request.auth != null) without needing any 1.7KB PEM.
async function getFirebaseToken() {
const res = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword` +
`?key=${process.env.FIREBASE_WEB_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: process.env.FB_AUTH_EMAIL,
password: process.env.FB_AUTH_PASSWORD,
returnSecureToken: true
})
}
);
const data = await res.json();
return data.idToken; // valido 1h, soddisfa request.auth != null
}
// Uso: l'ID token va nell'header Authorization delle chiamate Firestore REST
const idToken = await getFirebaseToken();
const doc = await fsGetDoc(idToken, `firmeDocumenti/${docId}__${opKey}`);
Questo pattern è appropriato per app interne con un singolo account condiviso. Per app multi-utente dove ogni utente ha un account Firebase separato, il pattern corretto è passare dal client il proprio ID token alla Function (che lo verifica prima di agire), anziché fare login con credenziali fisse server-side.
Regole Firestore: deny-all di default
A differenza del Realtime Database (che ha blocchi di regole con fallback), Firestore nega di default qualsiasi accesso a collezioni non esplicitamente nominate nelle regole. Aggiungere una nuova collezione (firmeDocumenti) senza aggiornare le regole genera un errore "Missing or insufficient permissions" invisibile in superficie — il documento sembra non salvato ma l'errore è di sicurezza, non di codice.
Firestore rules: deny-all by default
Unlike the Realtime Database (which has rule blocks with fallbacks), Firestore denies by default any access to collections not explicitly named in the rules. Adding a new collection (firmeDocumenti) without updating the rules generates a "Missing or insufficient permissions" error that's invisible on the surface — the document appears unsaved but the error is a security one, not a code one.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /activityLog/{doc} {
allow read, write: if request.auth != null;
}
// Nuova collezione — stesso pattern: autenticato = autorizzato
match /firmeDocumenti/{doc} {
allow read, write: if request.auth != null;
}
}
}
pdf-lib: aggiungere una pagina certificato al PDF
Dopo la verifica OTP riuscita, il PDF originale viene modificato client-side con pdf-lib — una libreria puro JavaScript (nessuna dipendenza nativa, ~230KB gzip) caricata lazy dal CDN solo al momento della firma. La libreria carica il PDF originale, aggiunge una nuova pagina con i dati dell'audit trail, e restituisce i byte del PDF modificato per l'upload in Storage.
pdf-lib: appending a certificate page to the PDF
After successful OTP verification, the original PDF is modified client-side with pdf-lib — a pure JavaScript library (no native dependencies, ~230KB gzip) lazily loaded from CDN only at signing time. The library loads the original PDF, adds a new page with audit trail data, and returns the bytes of the modified PDF for upload to Storage.
// Carica pdf-lib lazy da CDN
async function _ensurePdfLib() {
if (window.PDFLib) return;
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
}
async function _firmaGeneraCertificato(item, firma) {
if (!_firmaPdfBytes) return; // CORS non configurato
await _ensurePdfLib();
const { PDFDocument, StandardFonts, rgb } = PDFLib;
const pdfDoc = await PDFDocument.load(_firmaPdfBytes,
{ ignoreEncryption: true }); // alcuni PDF richiedono questo flag
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const page = pdfDoc.addPage([595, 842]); // A4 in punti
const { width, height } = page.getSize();
const draw = (text, y, size=10, color=rgb(0.2,0.2,0.2)) =>
page.drawText(text, { x:50, y, size, font, color });
draw('CERTIFICATO DI PRESA VISIONE', height-80, 16, rgb(0,0,0));
draw(`Operatore: ${firma.nome}`, height-130, 11);
draw(`Documento: ${item.titolo}`, height-155, 11);
draw(`Data e ora: ${firma.dataLeggibile}`, height-180, 11);
draw(`Autenticazione: OTP via email`, height-205, 11);
draw(`Versione documento: ${firma.versione}`, height-230, 10);
draw(`IP: ${firma.ip}`, height-250, 10);
draw(`Hash SHA-256: ${firma.hashPdf}`, height-280, 7.5);
const pdfBytes = await pdfDoc.save();
const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' });
const certRef = storageRef(storage, `Accordi/${opKey}/${nome}_firmato.pdf`);
await uploadBytes(certRef, pdfBlob);
}
window.open() e i popup blocker: il fix sincrono
Un bug sottile che non genera errori visibili: il bottone "Apri" sui documenti firmati non faceva nulla. Il motivo è che window.open() chiamato all'interno di una .then() o di una funzione async non è più considerato "attivato dall'utente" dai browser moderni — l'attivazione del click originale scade prima che la promise si risolva. Il popup viene bloccato silenziosamente.
Il fix: aprire la finestra in modo sincrono nel click handler (mentre l'attivazione utente è ancora valida), poi navigarla all'URL quando arriva.
window.open() and popup blockers: the synchronous fix
A subtle bug that generates no visible errors: the "Open" button on signed documents did nothing. The reason is that window.open() called inside a .then() or an async function is no longer considered "user-activated" by modern browsers — the original click activation expires before the promise resolves. The popup is silently blocked.
The fix: open the window synchronously in the click handler (while user activation is still valid), then navigate it to the URL when it arrives.
// ❌ Bloccato dai popup blocker — window.open dentro .then() è async
btn.onclick = async () => {
const url = await getDownloadURL(ref);
window.open(url, '_blank'); // troppo tardi, attivazione scaduta
};
// ✅ Funziona — finestra aperta in modo sincrono, navigata dopo
btn.onclick = async () => {
const win = window.open('', '_blank'); // apre subito, sincrono
const url = await getDownloadURL(ref); // poi risolve l'URL
if (win) win.location.href = url; // e naviga la finestra già aperta
};
Lo stesso problema si presenta con qualsiasi window.open() chiamato dopo una operazione asincrona — fetch, Firebase getDownloadURL, IndexedDB. Il pattern "apri subito con URL vuoto, naviga dopo" funziona universalmente. In alternativa, usare un <a target="_blank"> programmato è ugualmente efficace e non richiede il workaround.
Il risultato: flusso completo e audit trail
Il sistema completo funziona così: l'admin carica il PDF, seleziona il destinatario e clicca Invia. Il sistema crea il record Firestore, carica il file in Storage e invia una notifica push all'operatore. L'operatore apre la sezione dedicata nella PWA, visualizza il PDF in anteprima, richiede il codice OTP via email, lo inserisce, e alla verifica riuscita il sistema:
- Salva su Firestore: nome, timestamp, IP, hash SHA-256 del PDF visto, versione documento, OTP verificato
- Genera un PDF modificato (originale + pagina certificato) con pdf-lib
- Salva il certificato in Storage nella cartella dell'operatore
- Mostra il documento nella sezione Accordi sia lato admin che lato operatore
Il record Firestore è l'audit trail permanente — il file PDF con la pagina certificato è la rappresentazione leggibile degli stessi dati. Se in futuro serve aggiungere firma qualificata vera, il record Firestore contiene già tutti i dati necessari per un'integrazione con un certificatore accreditato.
The result: complete flow and audit trail
The complete system works as follows: the admin uploads the PDF, selects the recipient and clicks Send. The system creates the Firestore record, uploads the file to Storage and sends a push notification to the operator. The operator opens the dedicated section in the PWA, views the PDF preview, requests the OTP code via email, enters it, and on successful verification the system:
- Saves to Firestore: name, timestamp, IP, SHA-256 hash of the viewed PDF, document version, verified OTP
- Generates a modified PDF (original + certificate page) with pdf-lib
- Saves the certificate to Storage in the operator's folder
- Shows the document in the Accordi section on both admin and operator sides
The Firestore record is the permanent audit trail — the PDF with the certificate page is the human-readable representation of the same data. If a real qualified signature needs to be added in future, the Firestore record already contains all the data needed for integration with an accredited certifier.