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".

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.

L'architettura del sistema

Il flusso si articola in tre layer distinti:

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.

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.

Node.js — generazione OTP con hash+salt (Netlify Function)
// 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.

Node.js — verifica OTP timing-safe
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.

Node.js — chiamata EmailJS server-side con private key
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.

JavaScript — hash SHA-256 del PDF con Web Crypto API
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:

cors.json — configurazione CORS per Firebase Storage
[
  {
    "origin": ["https://[APP_DOMAIN]"],
    "method": ["GET"],
    "maxAgeSeconds": 3600,
    "responseHeader": ["Content-Type", "Content-Disposition"]
  }
]
Shell — applicazione e verifica CORS con gsutil
# 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.

Node.js — login Firebase REST API per ottenere ID token (no service account)
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 Security Rules — aggiunta della nuova collezione
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.

JavaScript — generazione pagina certificato con pdf-lib
// 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.

JavaScript — apertura URL asincrono senza popup blocker
// ❌ 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.