Tiempo real
Verificar la firma de un webhook
Cómo comprobar que un envío viene de SafeOnuba: HMAC-SHA256 sobre el cuerpo en bruto, comparación en tiempo constante, ventana de 5 minutos y rotación del secreto.
En esta página
Tu receptor está en una URL pública, así que cualquiera puede mandarle un POST. La firma es lo que demuestra que el envío viene de SafeOnuba y que nadie lo ha tocado por el camino. No proceses ningún evento sin verificarla.
Cómo se firma
Cada envío lleva dos cabeceras:
X-SafeOnuba-Timestamp: el momento de la firma, en segundos Unix.X-SafeOnuba-Signature:v1=seguido de la firma en hexadecimal.
La firma es un HMAC-SHA256, con el secreto del destino (el whsec_… que te dio la API al crearlo) como clave, sobre el timestamp, un punto y el cuerpo tal cual llegó:
v1=hex( HMAC_SHA256( secreto, timestamp + "." + cuerpo_en_bruto ) )Qué tiene que hacer tu receptor
- Leer el cuerpo en bruto, como bytes, antes de que ningún framework lo convierta en JSON. Si lo parseas y lo vuelves a serializar, cambian los espacios o el orden de las claves y la firma deja de cuadrar.
- Rechazar si el timestamp se desvía más de 5 minutos de tu reloj, en cualquier sentido. Evita que alguien reenvíe un evento antiguo capturado. Mantén el reloj del servidor sincronizado por NTP.
- Calcular la firma con tu secreto sobre
timestamp + "." + cuerpo. - Compararla en tiempo constante con cada firma de la cabecera. Una comparación normal (
==) tarda distinto según cuántos caracteres coinciden, y eso filtra información. - Aceptar si cuadra cualquiera de las firmas de la cabecera. Durante una rotación del secreto llegan dos, separadas por coma.
Si la firma no es válida, responde 401 y descarta el cuerpo.
Receptor completo
Los tres ejemplos hacen lo mismo: verifican, dejan el evento en tu cola (encolar, que pones tú) y responden 204 enseguida.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRETO = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_…
const TOLERANCIA_S = 5 * 60;
export function firmaValida(cuerpo, marca, cabecera, secreto = SECRETO) {
if (!cuerpo || !marca || !cabecera) return false;
// 1. El timestamp, a menos de 5 minutos de nuestro reloj.
const t = Number(marca);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCIA_S) return false;
// 2. La firma esperada, sobre el cuerpo en bruto (un Buffer).
const esperada = createHmac('sha256', secreto).update(`${marca}.`).update(cuerpo).digest();
// 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante.
return cabecera.split(',').some((parte) => {
const [version, hex] = parte.trim().split('=');
if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false;
return timingSafeEqual(Buffer.from(hex, 'hex'), esperada);
});
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/safeonuba') {
res.writeHead(404).end();
return;
}
const trozos = [];
req.on('data', (trozo) => trozos.push(trozo));
req.on('end', () => {
const cuerpo = Buffer.concat(trozos); // en bruto: nada de JSON.parse antes de verificar
const valida = firmaValida(
cuerpo,
req.headers['x-safeonuba-timestamp'],
req.headers['x-safeonuba-signature'],
);
if (!valida) {
res.writeHead(401).end();
return;
}
encolar(JSON.parse(cuerpo.toString('utf8'))); // se procesa después, fuera de la petición
res.writeHead(204).end();
});
}).listen(8080);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRETO = os.environ["SAFEONUBA_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCIA_S = 5 * 60
app = Flask(__name__)
def firma_valida(cuerpo: bytes, marca: str | None, cabecera: str | None) -> bool:
if not marca or not cabecera:
return False
# 1. El timestamp, a menos de 5 minutos de nuestro reloj.
try:
t = int(marca)
except ValueError:
return False
if abs(time.time() - t) > TOLERANCIA_S:
return False
# 2. La firma esperada, sobre el cuerpo en bruto.
esperada = hmac.new(SECRETO, marca.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
# 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante.
for parte in cabecera.split(","):
version, _, firma = parte.strip().partition("=")
if version == "v1" and hmac.compare_digest(firma.lower(), esperada):
return True
return False
@app.post("/safeonuba")
def recibir():
cuerpo = request.get_data() # en bruto, antes de tocar request.json
if not firma_valida(
cuerpo,
request.headers.get("X-SafeOnuba-Timestamp"),
request.headers.get("X-SafeOnuba-Signature"),
):
abort(401)
encolar(request.get_json()) # se procesa después, fuera de la petición
return "", 204using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secreto = Encoding.UTF8.GetBytes(builder.Configuration["SAFEONUBA_WEBHOOK_SECRET"]!); // whsec_…
app.MapPost("/safeonuba", async (HttpRequest req) =>
{
// En bruto: se lee el cuerpo entero antes de deserializar nada.
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var cuerpo = ms.ToArray();
if (!FirmaValida(cuerpo, req.Headers["X-SafeOnuba-Timestamp"], req.Headers["X-SafeOnuba-Signature"], secreto))
return Results.Unauthorized();
Encolar(JsonDocument.Parse(cuerpo)); // se procesa después, fuera de la petición
return Results.NoContent();
});
app.Run();
static bool FirmaValida(byte[] cuerpo, string? marca, string? cabecera, byte[] secreto)
{
if (string.IsNullOrEmpty(marca) || string.IsNullOrEmpty(cabecera)) return false;
// 1. El timestamp, a menos de 5 minutos de nuestro reloj.
if (!long.TryParse(marca, out var t)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 5 * 60) return false;
// 2. La firma esperada, sobre el cuerpo en bruto.
var firmado = Encoding.UTF8.GetBytes(marca + ".").Concat(cuerpo).ToArray();
var esperada = HMACSHA256.HashData(secreto, firmado);
// 3. Vale cualquiera de las firmas de la cabecera, comparadas en tiempo constante.
foreach (var parte in cabecera.Split(','))
{
var trozos = parte.Trim().Split('=', 2);
if (trozos.Length != 2 || trozos[0] != "v1") continue;
byte[] recibida;
try { recibida = Convert.FromHexString(trozos[1]); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(recibida, esperada)) return true;
}
return false;
}Rotar el secreto
Rota el secreto si crees que se ha filtrado, cuando se va alguien que lo conocía o simplemente cada cierto tiempo. No se corta nada:
-
Llama a
POST /v1/webhooks/{id}/rotate-secret. La respuesta trae el secreto nuevo (secret, solo esta vez) y hasta cuándo sigue firmando el anterior (previous_valid_until). -
Durante las 24 horas siguientes, cada envío lleva dos firmas en
X-SafeOnuba-Signature, una con cada secreto, separadas por coma:X-SafeOnuba-Signature v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39 -
Cambia el secreto de tu receptor por el nuevo en cualquier momento de esas 24 horas. Como el código de arriba acepta si cuadra cualquiera de las firmas, funciona igual antes y después del cambio.
-
Pasadas las 24 horas, el anterior deja de firmar.
Una segunda rotación retira el primer secreto: nunca hay más de dos a la vez.
Errores frecuentes
- Verificar sobre el JSON reserializado. La firma se calcula sobre los bytes exactos que llegaron.
- Comparar con
==. UsatimingSafeEqual,hmac.compare_digestoCryptographicOperations.FixedTimeEquals. - Ignorar el timestamp. Sin la ventana de 5 minutos, un evento capturado se puede reenviar cuando se quiera.
- Quedarse solo con la primera firma. En una rotación llegan dos, y la que cuadra con tu secreto puede ser la segunda.
- Responder después de procesar. Si tu sala tarda más de 5 segundos, el envío cuenta como fallido y se reintenta. Verifica, encola y responde.