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

Página: https://safeonuba.com/desarrolladores/webhooks/verificar-firma · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar.

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ó**:

```text
v1=hex( HMAC_SHA256( secreto, timestamp + "." + cuerpo_en_bruto ) )
```

## Qué tiene que hacer tu receptor

1. **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.
2. **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.
3. **Calcular la firma** con tu secreto sobre `timestamp + "." + cuerpo`.
4. **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.
5. **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.

```javascript
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);
```

```python
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 "", 204
```

```csharp
using 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;
}
```

> **Si usas un framework, desactiva el parseo en esta ruta**
>
> Express, Fastify, Django REST o ASP.NET con `[FromBody]` convierten el cuerpo en un objeto antes de que llegue a tu código, y a partir de ahí ya no puedes verificar. En Express, por ejemplo, monta esta ruta con `express.raw({ type: 'application/json' })` en vez de `express.json()`.

## 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:

1. 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`).
2. Durante las **24 horas** siguientes, cada envío lleva **dos firmas** en `X-SafeOnuba-Signature`, una con cada secreto, separadas por coma:

   ```text
   v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
   ```

3. 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.
4. 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 `==`.** Usa `timingSafeEqual`, `hmac.compare_digest` o `CryptographicOperations.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.
