Firmar y verificar webhooks

La URL que recibe los webhooks está abierta a internet: cualquiera que la conozca puede mandarle un POST que se parezca a un aviso de Aloha. La firma es lo que distingue uno del otro. Sin comprobarla, una integración acredita órdenes contra un cuerpo JSON que puede haber escrito cualquiera.

Lo que sigue es cómo se construye esa firma, cómo se comprueba, y —al final— el único caso en el que hoy no se puede comprobar, que conviene conocer antes de elegir por dónde recibir.

Las cabeceras de la entrega

CabeceraQué trae
Content-TypeSiempre application/json
User-AgentAlohaPay-Webhooks/1.0
X-Webhook-IdEl identificador de esta entrega. Se mantiene igual en todos los reintentos de la misma entrega y es distinto para cada destino. Es un UUID, sin prefijo
X-Webhook-TimestampEl momento del envío, en segundos Unix. Cada intento lleva el suyo, recién tomado
X-Webhook-Signaturesha256= seguido de 64 caracteres hexadecimales en minúscula
X-Webhook-RetryEl número de intento. Solo la llevan los reintentos, con valores de 2 a 5; la primera entrega no la lleva

X-Webhook-Retry es la manera directa de distinguir una entrega nueva de la repetición de una anterior, sin consultar nada.

Cómo se construye la firma

Se firma la concatenación del timestamp, un punto y el cuerpo, con HMAC-SHA256 y el secret del endpoint:

firma = sha256= + hmac_sha256( "{timestamp}.{cuerpo}", secret )

El timestamp es el valor exacto de la cabecera X-Webhook-Timestamp, como texto. Firmarlo junto al cuerpo es lo que impide cambiarlo por otro: alterar la hora invalida la firma igual que alterar el importe.

Comprobarlo son cinco pasos:

  1. Leer X-Webhook-Timestamp y X-Webhook-Signature. Si falta cualquiera de las dos, se rechaza.
  2. Comprobar que el timestamp no se aleja del reloj propio más de 5 minutos, en ninguna de las dos direcciones. Esto es lo que impide que alguien capture una entrega legítima y la reenvíe mañana.
  3. Componer {timestamp}.{cuerpo} con el cuerpo crudo.
  4. Calcular el HMAC-SHA256 con el secret del endpoint y anteponerle sha256=.
  5. Comparar con lo recibido usando una comparación en tiempo constante.

El cuerpo crudo, no el JSON reinterpretado

Es el error que más tiempo cuesta encontrar, porque el código parece correcto y falla solo con algunos pagos.

Aloha firma los bytes exactos que van en la petición. Quien reciba el aviso, lo interprete como JSON y vuelva a serializarlo para firmarlo, obtiene otros bytes en cuanto el payload trae una barra, un acento o una eñe: cada serializador los escapa a su manera. La firma deja de coincidir y quien recibe acaba descartando un aviso legítimo, pero solo en los casos que contengan alguno de esos caracteres —una descripción con tilde, una URL dentro de un campo—, así que el fallo parece intermitente.

Con una excepción que conviene conocer antes de probarlo, porque si no la prueba engaña: Aloha firma con el json_encode de PHP y sus opciones por omisión, así que un receptor en PHP que reserialice con esas mismas opciones reproduce los bytes exactos y ve pasar la comprobación. Rompen las demás, que son casi todas —PHP con JSON_UNESCAPED_SLASHES o JSON_UNESCAPED_UNICODE, el JSON.stringify de JavaScript, el json.dumps de Python y el encoding/json de Go—. Que reserializar funcione en PHP no dice que sea seguro; dice que ese serializador coincidió.

La regla: hay que guardar el cuerpo tal como llegó, antes de que el framework lo interprete, y firmar eso. Cada framework tiene su manera de conservarlo, y cada ejemplo de abajo la usa.

La ventana de 5 minutos es una comprobación propia

Los 5 minutos del paso 2 los aplica quien recibe, no Aloha: no hay nada del lado del emisor que compruebe la antigüedad del timestamp ni descarte una entrega por ella. Por eso vale la pena implementarla, y por eso conviene que el reloj del servidor esté sincronizado —un reloj desfasado rechaza avisos perfectamente válidos, y el síntoma es que "la firma no coincide" cuando en realidad sí coincide—.

Un reintento no tropieza con esa ventana: cada intento se firma de nuevo, con la hora de ese momento. Un aviso que llega tres horas tarde por reintentos trae un timestamp reciente, no el del primer intento.

Verificar en Node

Con Express, el cuerpo crudo se conserva con express.raw en la ruta del webhook:

const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRETO = process.env.ALOHA_WEBHOOK_SECRET;

function verificarFirma(cuerpoCrudo, firma, timestamp, secreto) {
  if (!Buffer.isBuffer(cuerpoCrudo)) return false;
  if (typeof firma !== 'string' || typeof timestamp !== 'string') return false;

  const enviado = Number.parseInt(timestamp, 10);
  if (!Number.isInteger(enviado)) return false;

  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - enviado) > 300) return false;

  const hmac = crypto.createHmac('sha256', secreto);
  hmac.update(`${timestamp}.`);
  hmac.update(cuerpoCrudo);
  const esperada = `sha256=${hmac.digest('hex')}`;

  const a = Buffer.from(esperada, 'utf8');
  const b = Buffer.from(firma, 'utf8');

  // timingSafeEqual lanza si los buffers miden distinto, así que la longitud se
  // compara antes. No filtra nada: la firma siempre mide lo mismo.
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

app.post(
  '/webhooks/aloha',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const valida = verificarFirma(
      req.body,
      req.get('X-Webhook-Signature'),
      req.get('X-Webhook-Timestamp'),
      SECRETO
    );

    if (!valida) return res.status(401).end();

    const evento = JSON.parse(req.body.toString('utf8'));

    // Contestar primero, trabajar después.
    res.status(200).end();
    encolarParaProcesar(evento);
  }
);

hmac.update recibe el cuerpo como búfer, sin convertirlo a texto: así no hay ninguna decisión de codificación entre los bytes que llegaron y los que se firman.

Verificar en PHP

<?php

function verificarFirma(
    string $cuerpoCrudo,
    ?string $firma,
    ?string $timestamp,
    string $secreto
): bool {
    if ($firma === null || $timestamp === null || ! ctype_digit($timestamp)) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $esperada = 'sha256=' . hash_hmac(
        'sha256',
        $timestamp . '.' . $cuerpoCrudo,
        $secreto
    );

    return hash_equals($esperada, $firma);
}

$cuerpoCrudo = file_get_contents('php://input');
$firma       = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null;
$timestamp   = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? null;
$secreto     = (string) getenv('ALOHA_WEBHOOK_SECRET');

if (! verificarFirma($cuerpoCrudo, $firma, $timestamp, $secreto)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($cuerpoCrudo, true);

// Contestar rápido: aquí solo se encola, el trabajo de verdad va aparte.
http_response_code(200);

// PHP no manda la respuesta hasta que acaba el script, así que bajo PHP-FPM
// hay que cerrarla a mano: si no, lo que venga después sigue corriendo dentro
// de los 10 segundos de espera.
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

encolarParaProcesar($evento);

file_get_contents('php://input') devuelve el cuerpo sin interpretar, que es justo lo que hay que firmar. En un framework, el cuerpo crudo tiene su propio acceso —en Laravel, $request->getContent()— y no sirve volver a serializar lo ya interpretado.

El fastcgi_finish_request() no es adorno: a diferencia de Node, donde res.end() suelta la conexión de verdad, PHP retiene la respuesta hasta el final del script, así que sin esa llamada el encolado cuenta dentro del tiempo de espera de la entrega.

Por qué la comparación no usa ==

Las dos funciones de arriba, crypto.timingSafeEqual y hash_equals, comparan en tiempo constante: tardan lo mismo coincidan los primeros caracteres o no. Una comparación normal se detiene en la primera diferencia, y esa diferencia de microsegundos, medida muchas veces, deja adivinar la firma correcta carácter a carácter. Es un ataque real y la defensa cuesta lo mismo que la comparación ingenua, así que no hay motivo para no aplicarla.

El único matiz es la longitud. crypto.timingSafeEqual lanza una excepción si los búferes miden distinto, de ahí la comprobación previa del ejemplo; hash_equals devuelve false por su cuenta. Comparar la longitud primero no filtra nada útil: la firma de Aloha mide siempre lo mismo, sha256= y 64 caracteres hexadecimales.

El secreto solo se entrega en dos respuestas

El secret de un endpoint son 64 caracteres hexadecimales en minúscula, sin prefijo y sin separador. La API lo devuelve solo en dos sitios:

  • La de POST /v1/webhooks, al crear el endpoint.
  • La de POST /v1/webhooks/{id}/rotate-secret, al rotarlo.

Ninguna otra operación de la API lo devuelve, ni siquiera consultar el endpoint. Un secreto que no se guarde en el momento en que se lee no se recupera: se rota, y se vuelve a guardar.

Dos cosas más que conviene tener presentes:

  • El secret del endpoint no es la Clave API y no tiene nada que ver con ella. Se filtran por separado y se rotan por separado; revocar la clave no cambia el secreto ni detiene las entregas. Está explicado en Autenticación y Claves API.
  • Cualquier Clave API de la cuenta con el Permiso webhooks:write puede rotar cualquier endpoint de la cuenta, también uno que no registró ella. Quien rota se queda con el secreto nuevo; quien registró el endpoint no se entera. Con varias claves en juego, conviene que rotar sea una operación con dueño.

Rotar el secreto

La rotación es una sola llamada, sin cuerpo:

curl -X POST https://api.sandbox.aloha.co/api/external/v1/webhooks/550e8400-e29b-41d4-a716-446655440000/rotate-secret \
  -H "X-API-KEY: pay_aBcD1234_test_CLAVE_DE_EJEMPLO"
{
  "success": true,
  "message": "Webhook secret rotated successfully",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "secret": "9b8a7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f30291a8b7c6d5e4f3029"
  }
}

El corte no es instantáneo, y esa es la parte que hay que planificar. Rotar escribe el secreto nuevo, y nada más: no toca las entregas que ya estaban encoladas cuando se rotó. Esas conservan el secreto que estaba vigente en el momento en que se encolaron, así que pueden llegar firmadas con el anterior. Todo lo que se encole a partir de la rotación, y todos los reintentos, llevan el nuevo.

De ahí la manera de rotar sin perder avisos:

  1. Guardar el secreto nuevo sin borrar el anterior.
  2. Verificar cada entrega contra el nuevo y, si no encaja, contra el anterior. Es exactamente la misma función de arriba, llamada dos veces.
  3. Cuando hayan drenado las entregas que estaban encoladas al rotar —normalmente cuestión de segundos o de minutos, no de horas—, borrar el secreto anterior y volver a verificar contra uno solo.

El paso 3 no es opcional. Aceptar dos secretos es un estado transitorio de una rotación, no una configuración: si la rotación fue la respuesta a una filtración, el secreto filtrado sigue sirviendo mientras se siga aceptando. Quien rote por sospecha de filtración tiene que decidir a conciencia cuál de los dos riesgos prefiere durante ese rato: aceptar el viejo un poco más, o dejar de aceptarlo ya y descartar las entregas en vuelo que lleguen firmadas con él. Descartarlas no pierde el hecho —el recurso se puede consultar—, pero sí pierde el aviso.

La entrega al webhook_url de una Solicitud de Pago no se puede verificar hoy

Todo lo anterior vale para un endpoint registrado, el que se crea con POST /v1/webhooks y tiene su propio secret.

No vale para la otra vía. Cuando una Solicitud de Pago se crea con webhook_url, el aviso que va a esa dirección se firma con una clave interna de la plataforma, no con un secreto de la cuenta. La cabecera X-Webhook-Signature viene, pero no hay ningún valor que la cuenta pueda usar para comprobarla: esa clave no se entrega, no se puede consultar y no se puede rotar.

Así que, hoy:

  • Una integración que necesite comprobar el origen de lo que recibe usa un endpoint registrado. Es la única de las dos vías cuya firma se puede comprobar, y lo que llega por ella se comprueba siempre salvo en el caso que cierra esta página: un reintento de un endpoint ya borrado, que se firma con la clave interna.
  • Quien use webhook_url no puede tratar el contenido del aviso como un hecho. El uso seguro de esa dirección es tomarla como una señal de «hay algo que mirar» y confirmar contra la API —GET /v1/payment-links/{id}— antes de acreditar nada. Nunca como la fuente del importe, del Estado ni de los datos del pagador.

Hay un segundo camino hasta esa misma clave, y se llega a él sin haber usado nunca webhook_url: si se borra un endpoint mientras tiene reintentos en curso, esos reintentos siguen saliendo —la entrega guarda su propia URL— y se firman también con la clave interna, porque ya no queda endpoint del que leer el secreto. Para retirar un endpoint que está fallando conviene desactivarlo primero, dejar que drenen los reintentos y borrarlo después.

El webhook_url tiene además otra limitación que conviene conocer antes de apoyarse en él: no es un recurso de la API. No aparece en la lista de endpoints, no se puede desactivar ni borrar desde /v1/webhooks, y tampoco mueve el last_triggered_at de ningún endpoint. Vive en la Solicitud de Pago que lo lleva.

Esto es una limitación conocida, no una decisión de diseño. Mientras siga así, la recomendación de arriba es la que hay.

Seguir por aquí

  • Webhooks — los seis eventos, qué trae cada payload y cómo se entregan.
  • Inicio rápido — registrar el endpoint y guardar el secret de la respuesta que lo entrega.
  • Autenticación y Claves API — qué Permiso hace falta para cada operación de webhook.
  • Errores — qué contesta la API cuando una de estas llamadas falla.

Did this page help you?