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| Componente | Significato |
|---|---|
t | Timestamp Unix in secondi usato nella firma. Coincide con X-BP-Timestamp. |
v1 | Firma 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...80afLa 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
| Causa | Effetto | Rimedio |
|---|---|---|
| Middleware JSON che consuma lo stream | Il corpo grezzo non è più disponibile | Registra il parser raw prima del parser JSON, solo su quella rotta |
| Proxy che riformatta il JSON | Byte diversi da quelli firmati | Configura il proxy in modalità pass-through |
| WAF che normalizza o riordina i campi | Byte diversi | Escludi il percorso del webhook dalla normalizzazione |
| Ricodifica del charset | I caratteri accentati cambiano rappresentazione | Tratta il corpo come bytes fino alla verifica |
| Rimozione dello spazio bianco | Byte diversi | Nessuna 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.
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
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
Verifica in ordine:
- Il segreto usato è quello del webhook giusto? Ogni endpoint ha un segreto diverso.
- Hai copiato il segreto completo, incluso il prefisso
whsec_? - Stai firmando
${timestamp}.${rawBody}e non solorawBody? - Il timestamp usato nella firma è quello dell'header, non l'ora corrente?
- La codifica è esadecimale minuscolo, non Base64?
Prova a stampare temporaneamente la stringa firmata e confrontala con la lunghezza del corpo
ricevuto. Se la lunghezza differisce da Content-Length, un middleware ha alterato il corpo.
Sintomo: il corpo grezzo risulta vuoto oppure è un oggetto già deserializzato.
Causa: in Express, app.use(express.json()) registrato globalmente consuma lo stream prima
della tua rotta.
Soluzione: registra express.raw({ type: 'application/json' }) solo sulla rotta del
webhook e posiziona express.json() dopo. In NestJS abilita rawBody: true in
NestFactory.create. In ASP.NET Core chiama request.EnableBuffering() prima di leggere.
In Django usa request.body prima di qualsiasi accesso a request.POST.
Sintomo: la firma funziona in locale con un tunnel ma fallisce in produzione.
Causa: un reverse proxy, un API gateway o un WAF applica normalizzazione JSON, compressione, riscrittura di caratteri o rimozione di campi.
Verifica: confronta il Content-Length ricevuto dalla tua applicazione con quello
registrato nei log consegne della Console. Se differiscono, il corpo è stato modificato.
Soluzione: configura il percorso /webhooks/blupantheon in modalità pass-through, senza
ispezione del corpo e senza trasformazioni. Su NGINX assicurati di non usare direttive che
modificano il body e di propagare il corpo invariato con proxy_pass.
Sintomo: la firma fallisce solo su payload che contengono nomi italiani con accenti o simboli come €.
Causa: conversione del corpo in stringa con un charset diverso da UTF-8, oppure doppia codifica.
Soluzione: calcola l'HMAC direttamente sui byte ricevuti. In Python usa bytes senza
.decode(). In PHP file_get_contents restituisce già byte. In Java usa
request.getInputStream() e non getReader(). In Node.js usa il Buffer e converti con
toString('utf8') solo dopo aver verificato.
Sintomo: rifiuti sistematici con motivo timestamp-fuori-tolleranza, oppure rifiuti
sporadici sotto carico.
Causa: orologio del server non sincronizzato, oppure la richiesta è rimasta in coda oltre 5 minuti prima di essere elaborata.
Verifica:
timedatectl status
chronyc trackingSoluzione: attiva NTP (chrony o systemd-timesyncd). Verifica la firma subito
all'ingresso della richiesta, prima di qualunque coda interna, e mai in un worker asincrono che
può accumulare ritardo.
Comportamento atteso. Il replay genera un nuovo t e quindi una nuova firma, oltre a un
nuovo X-BP-Delivery. Il campo id dell'evento resta invece invariato.
Se la tua logica di business non deve rieseguire l'operazione, deduplica anche sul campo id
del payload, come descritto in Webhook.
Usa openssl per riprodurre esattamente il calcolo della piattaforma:
#!/usr/bin/env bash
set -euo pipefail
SECRET="whsec_xxxxxxxxxxxxxxxx"
TIMESTAMP="$(date +%s)"
BODY='{"id":"evt_test","event":"webhook.test","apiVersion":"2026-09-01","createdAt":"2026-09-04T09:00:00.000Z","tenantId":"bp_ten_4a91c0e5","projectId":"bp_proj_7f3a9c21","data":{}}'
SIGNATURE="$(printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r \
| cut -d' ' -f1)"
curl -sS -i -X POST "http://localhost:3000/webhooks/blupantheon" \
-H "Content-Type: application/json" \
-H "X-BP-Event: webhook.test" \
-H "X-BP-Delivery: $(uuidgen | tr 'A-Z' 'a-z')" \
-H "X-BP-Timestamp: $TIMESTAMP" \
-H "X-BP-Attempt: 1" \
-H "X-BP-Api-Version: 2026-09-01" \
-H "X-BP-Signature: t=$TIMESTAMP,v1=$SIGNATURE" \
--data-raw "$BODY"Il risultato atteso è 202. Se ottieni 401, il problema è nel tuo codice di verifica, non
nella piattaforma.