Blu Pantheon
Automatización y RPA

Firma HMAC

Verificare la firma HMAC-SHA256 dei webhook Blu Pantheon in Node.js, Python, C# e PHP, con confronto a tempo costante e anti-replay.

Ogni webhook è firmato con HMAC-SHA256. La verifica della firma è l'unico meccanismo che garantisce che la richiesta provenga davvero da Blu Pantheon e che il corpo non sia stato alterato.

La verifica non è opzionale

Un endpoint che non verifica la firma accetta qualunque richiesta da chiunque conosca l'URL. In un'integrazione che scrive su ERP, CRM o protocollo informatico questo è un rischio inaccettabile.

Prima di iniziare

  • Il segreto whsec_xxxxxxxxxxxxxxxx copiato alla creazione del webhook
  • Accesso al corpo grezzo della richiesta nel tuo framework HTTP
  • Un orologio di sistema sincronizzato via NTP

Anatomia dell'header

X-BP-Signature: t=1767225600,v1=9f2c1e7b40a3d6c5b8e1f0a2d4c7b9e3f5a8c1d0e2b4f6a9c3d5e7b1f0a2c41d
ComponenteSignificato
tTimestamp Unix in secondi usato nella firma. Coincide con X-BP-Timestamp.
v1Firma HMAC-SHA256 in esadecimale minuscolo, schema versione 1.

Durante una rotazione del segreto l'header può contenere più valori v1, uno per ciascun segreto attivo:

X-BP-Signature: t=1767225600,v1=9f2c...c41d,v1=3b7e...80af

La verifica ha esito positivo se almeno uno dei valori v1 corrisponde al calcolo con uno dei segreti che conosci.

Stringa firmata

La stringa da firmare è la concatenazione del timestamp, un punto e il corpo grezzo:

${timestamp}.${rawBody}

Esempio concreto:

1767225600.{"id":"evt_01J9X4K2R7M3Q8YV5T0B6D2WQ","event":"session.created",...}

La firma è HMAC-SHA256(chiave = whsec_..., messaggio = stringa firmata) codificata in esadecimale minuscolo.

Il corpo deve essere grezzo, byte per byte

Non usare mai il JSON deserializzato e poi ri-serializzato. JSON.parse seguito da JSON.stringify cambia l'ordine delle chiavi, la spaziatura, l'escaping Unicode e la rappresentazione dei numeri. Il risultato è una firma diversa e una verifica sempre fallita.

Cosa altera il corpo grezzo

CausaEffettoRimedio
Middleware JSON che consuma lo streamIl corpo grezzo non è più disponibileRegistra il parser raw prima del parser JSON, solo su quella rotta
Proxy che riformatta il JSONByte diversi da quelli firmatiConfigura il proxy in modalità pass-through
WAF che normalizza o riordina i campiByte diversiEscludi il percorso del webhook dalla normalizzazione
Ricodifica del charsetI caratteri accentati cambiano rappresentazioneTratta il corpo come bytes fino alla verifica
Rimozione dello spazio biancoByte diversiNessuna trasformazione prima della verifica

Algoritmo di verifica

Leggi il corpo grezzo

Ottieni i byte esatti ricevuti, senza parsing e senza trasformazioni.

Estrai t e i valori v1

Suddividi l'header su virgola, poi ogni segmento su uguale. Raccogli tutti i v1.

Verifica la finestra temporale

Calcola la differenza assoluta tra t e l'ora corrente. Se supera 300 secondi rifiuta con 400. Questo blocca il replay di richieste catturate in passato.

Calcola la firma attesa

Concatena t, un punto e il corpo grezzo. Applica HMAC-SHA256 con il segreto. Codifica in esadecimale minuscolo.

Confronta a tempo costante

Usa una funzione di confronto a tempo costante. Un confronto normale con == è vulnerabile ad attacchi temporali che ricostruiscono la firma byte per byte.

Deduplica e rispondi

Se la firma è valida, controlla X-BP-Delivery contro le consegne già viste, accoda il lavoro e rispondi 202 senza attendere l'elaborazione.

Implementazioni complete

Express con express.raw registrato solo sulla rotta del webhook.

server.mjs
import express from 'express';
import crypto from 'node:crypto';

const app = express();

const TOLERANCE_SECONDS = 300;
const SECRETS = [
  process.env.BP_WEBHOOK_SECRET,
  process.env.BP_WEBHOOK_SECRET_NEXT,
].filter(Boolean);

if (SECRETS.length === 0) {
  throw new Error('Nessun segreto webhook configurato');
}

function parseSignatureHeader(header) {
  const result = { timestamp: null, signatures: [] };
  for (const segment of header.split(',')) {
    const separatorIndex = segment.indexOf('=');
    if (separatorIndex === -1) continue;
    const key = segment.slice(0, separatorIndex).trim();
    const value = segment.slice(separatorIndex + 1).trim();
    if (key === 't') result.timestamp = Number(value);
    if (key === 'v1') result.signatures.push(value);
  }
  return result;
}

function timingSafeEqualHex(a, b) {
  const bufferA = Buffer.from(a, 'utf8');
  const bufferB = Buffer.from(b, 'utf8');
  if (bufferA.length !== bufferB.length) return false;
  return crypto.timingSafeEqual(bufferA, bufferB);
}

export function verifySignature(rawBody, signatureHeader, nowSeconds) {
  if (!signatureHeader) {
    return { valid: false, reason: 'header-mancante' };
  }

  const { timestamp, signatures } = parseSignatureHeader(signatureHeader);

  if (!Number.isFinite(timestamp)) {
    return { valid: false, reason: 'timestamp-non-valido' };
  }
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) {
    return { valid: false, reason: 'timestamp-fuori-tolleranza' };
  }
  if (signatures.length === 0) {
    return { valid: false, reason: 'firma-assente' };
  }

  const signedPayload = `${timestamp}.${rawBody}`;

  for (const secret of SECRETS) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload, 'utf8')
      .digest('hex');

    for (const candidate of signatures) {
      if (timingSafeEqualHex(candidate, expected)) {
        return { valid: true, reason: 'ok' };
      }
    }
  }

  return { valid: false, reason: 'firma-non-corrispondente' };
}

app.post(
  '/webhooks/blupantheon',
  express.raw({ type: 'application/json', limit: '2mb' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf8');
    const now = Math.floor(Date.now() / 1000);
    const result = verifySignature(rawBody, req.get('X-BP-Signature'), now);

    if (!result.valid) {
      console.warn('Webhook rifiutato:', result.reason);
      const status = result.reason === 'timestamp-fuori-tolleranza' ? 400 : 401;
      return res.status(status).json({ error: result.reason });
    }

    const deliveryId = req.get('X-BP-Delivery');
    const event = JSON.parse(rawBody);

    if (await alreadyProcessed(deliveryId)) {
      return res.status(202).json({ status: 'duplicato-ignorato' });
    }
    await markReceived(deliveryId, event.id);

    setImmediate(() => {
      processEvent(event).catch((error) =>
        console.error('Elaborazione fallita', event.id, error),
      );
    });

    return res.status(202).json({ status: 'accettato' });
  },
);

// Il parser JSON generico va registrato DOPO la rotta raw.
app.use(express.json());

const processedDeliveries = new Set();
async function alreadyProcessed(deliveryId) {
  return processedDeliveries.has(deliveryId);
}
async function markReceived(deliveryId) {
  processedDeliveries.add(deliveryId);
}
async function processEvent(event) {
  console.log('Evento elaborato:', event.event, event.id);
}

app.listen(3000, () => console.log('In ascolto sulla porta 3000'));

Rotazione del segreto

ConsoleConfigurazioneWebhookRuota segreto

Genera il nuovo segreto

Dalla scheda del webhook scegli Ruota segreto. Imposta un periodo di sovrapposizione di 24 ore. Copia il nuovo valore.

Accetta entrambi i segreti

Configura BP_WEBHOOK_SECRET_NEXT con il nuovo valore. Il codice mostrato sopra prova tutti i segreti configurati e accetta se almeno uno corrisponde.

Verifica nei log

Durante la sovrapposizione l'header contiene due valori v1. Controlla che le consegne continuino a rispondere 202.

Completa la rotazione

Scaduta la sovrapposizione, sposta il nuovo valore in BP_WEBHOOK_SECRET, rimuovi BP_WEBHOOK_SECRET_NEXT e riavvia il servizio.

Risoluzione dei problemi

Passi successivi

En esta página