Saltearse al contenido

Verificar firma HMAC

Tu URL de webhook es pública por necesidad — INGALCA Pay tiene que poder pegarle. Eso significa que cualquiera podría intentar mandarte requests falsos simulando pagos exitosos. La firma HMAC-SHA256 te permite verificar que cada webhook realmente viene de nosotros.

Cómo se calcula

La firma cubre el timestamp y el body juntos, unidos por un punto:

firma = "sha256=" + hmac_sha256(secret, timestamp + "." + body_json_exacto)
  • secret — tu webhook secret (whsec_...). Lo configurás en Configuración → Webhooks → Rotar secret. Es distinto por entorno.
  • timestamp — el valor del header X-Ingalca-Timestamp (unix timestamp, como string). Va firmado dentro del HMAC, así que un atacante no puede alterarlo para burlar el chequeo anti-replay.
  • body_json_exacto — el cuerpo de la request byte por byte como llega. No lo parsees ni lo reformatees antes de firmar; usá el raw body.

Cómo verificar

import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.INGALCA_WEBHOOK_SECRET;
// Importante: usar raw body, NO express.json() acá
app.post('/webhooks/ingalca', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-ingalca-signature'];
const timestamp = req.headers['x-ingalca-timestamp'];
// Firmá timestamp + "." + body. req.body es un Buffer (raw); actualizamos
// el HMAC en dos pasos para no coercionar el Buffer a string.
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(timestamp + '.');
hmac.update(req.body);
const expected = 'sha256=' + hmac.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
// ... procesar event ...
res.sendStatus(200);
});

Qué NO hacer

  • No uses == para comparar firmas. Es vulnerable a timing attacks. Usá crypto.timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python).
  • No re-serialices el body. Si convertís el JSON a objeto y lo volvés a stringify, vas a alterar espacios/orden y la firma no va a matchear.
  • No expongas el secret en logs. Trátalo como una contraseña.

Rotar el secret

Si sospechás que tu secret se filtró, rotalo desde Configuración → Webhooks. El cambio es inmediato — los próximos webhooks van firmados con el secret nuevo. Asegurate de actualizar tu env var antes de rotar, sino vas a empezar a rechazar webhooks legítimos.

Replay attacks

El header X-Ingalca-Timestamp te permite rechazar webhooks demasiado viejos:

const timestamp = parseInt(req.headers['x-ingalca-timestamp'], 10);
const ageSeconds = Math.floor(Date.now() / 1000) - timestamp;
if (ageSeconds > 300) {
return res.status(401).send('webhook too old');
}

5 minutos de tolerancia suele ser razonable. Más tiempo te deja vulnerable a replays; menos tiempo puede causar falsos negativos por desfase de reloj.