Cada entrega llega con un header X-KycAr-Signature. Sin validarlo, tu endpoint
acepta como verificación aprobada cualquier POST que llegue de internet a esa
URL.
El header
X-KycAr-Signature: t=1787234567,v1=6a1f3d2c9b8e7a5f4d3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b0a9f8e7d6
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
t | integer | siempre | Momento del envío, en segundos unix. |
v1 | hex | siempre | HMAC-SHA256 en hexadecimal minúscula, 64 caracteres. Firmado con el secreto vigente. |
v2 | hex | sólo tras rotar | El mismo HMAC con el secreto anterior, durante las 24 h de doble firma después de un roll-secret. |
El algoritmo
Tres pasos, sin ambigüedad:
-
Material a firmar: la concatenación de
t, un punto, y el cuerpo crudo de la request, exactamente como llegó:<t> + "." + <raw_body> -
Clave: el secreto
whsec_…completo, con el prefijo, tal como te lo mostró la API al crear o rotar el endpoint. -
Firma:
HMAC-SHA256(clave, material), en hexadecimal minúscula.
La firma es válida si alguna de las firmas del header (v1 o v2) coincide
con la que calculaste, y si |ahora − t| está dentro de tu tolerancia.
Tolerancia de reloj
t está dentro del material firmado: un atacante no puede reusar una entrega
vieja cambiando el timestamp, porque la firma dejaría de validar.
La tolerancia recomendada es 300 segundos. Rechazá lo que caiga fuera de esa ventana aunque la firma sea correcta: eso es lo que corta un ataque de repetición.
Node.js
Sin dependencias, sólo node:crypto.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* @param {string} secret tu whsec_... completo (con prefijo)
* @param {string} header valor de X-KycAr-Signature
* @param {string} rawBody el cuerpo crudo, sin parsear ni re-serializar
*/
export function verifyKycarSignature(secret, header, rawBody) {
let t;
const signatures = [];
for (const piece of header.split(",")) {
const eq = piece.indexOf("=");
if (eq <= 0) return false;
const key = piece.slice(0, eq).trim();
const value = piece.slice(eq + 1).trim();
if (key === "t") t = value;
if (key === "v1" || key === "v2") signatures.push(value);
}
if (t === undefined || signatures.length === 0 || !/^\d{1,12}$/.test(t)) return false;
const timestamp = Number(t);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
let match = false;
for (const candidate of signatures) {
if (candidate.length !== expected.length || !/^[0-9a-f]+$/.test(candidate)) continue;
if (timingSafeEqual(Buffer.from(candidate, "hex"), Buffer.from(expected, "hex"))) {
match = true; // sin early return: mismo tiempo con una o dos firmas
}
}
return match;
}
Con Express
express.raw deja el cuerpo como Buffer: es lo que necesitás para firmar.
import express from "express";
const app = express();
app.post(
"/hooks/kycar",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-KycAr-Signature") ?? "";
const rawBody = req.body.toString("utf8");
if (!verifyKycarSignature(process.env.KYCAR_WEBHOOK_SECRET, header, rawBody)) {
return res.status(400).send("firma inválida");
}
const event = JSON.parse(rawBody);
// Deduplicá por el id del evento: la entrega es at-least-once.
if (yaProcesado(event.id)) return res.status(200).send("ok");
// Encolá y respondé rápido: tenés 10 segundos.
encolar(event);
res.status(200).send("ok");
},
);
Con Next.js (route handler)
export async function POST(request) {
const rawBody = await request.text(); // crudo, antes de cualquier parseo
const header = request.headers.get("x-kycar-signature") ?? "";
if (!verifyKycarSignature(process.env.KYCAR_WEBHOOK_SECRET, header, rawBody)) {
return new Response("firma inválida", { status: 400 });
}
const event = JSON.parse(rawBody);
await encolar(event);
return new Response("ok", { status: 200 });
}
Python
Sólo librería estándar.
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
_TS_RE = re.compile(r"^\d{1,12}$")
_HEX_RE = re.compile(r"^[0-9a-f]+$")
def verify_kycar_signature(secret: str, header: str, raw_body: bytes) -> bool:
"""secret: tu whsec_... completo. raw_body: el cuerpo crudo, sin parsear."""
timestamp_raw = None
signatures = []
for piece in header.split(","):
key, sep, value = piece.partition("=")
if not sep:
return False
key = key.strip()
value = value.strip()
if key == "t":
timestamp_raw = value
elif key in ("v1", "v2"):
signatures.append(value)
if timestamp_raw is None or not signatures or not _TS_RE.match(timestamp_raw):
return False
timestamp = int(timestamp_raw)
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
match = False
for candidate in signatures:
if len(candidate) != len(expected) or not _HEX_RE.match(candidate):
continue
if hmac.compare_digest(candidate, expected):
match = True # sin break: mismo tiempo con una o dos firmas
return match
Con FastAPI
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
@app.post("/hooks/kycar")
async def kycar_webhook(request: Request, x_kycar_signature: str = Header(default="")):
raw_body = await request.body() # bytes crudos
if not verify_kycar_signature(SECRET, x_kycar_signature, raw_body):
raise HTTPException(status_code=400, detail="firma inválida")
event = json.loads(raw_body)
if ya_procesado(event["id"]):
return {"ok": True}
await encolar(event)
return {"ok": True}
Con Django
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def kycar_webhook(request):
header = request.headers.get("X-KycAr-Signature", "")
if not verify_kycar_signature(SECRET, header, request.body):
return HttpResponseBadRequest("firma inválida")
event = json.loads(request.body)
encolar(event)
return HttpResponse("ok")
Rotación con doble firma
Después de POST /v1/webhook-endpoints/{id}/roll-secret, durante 24 horas el
header trae dos firmas:
X-KycAr-Signature: t=1787234567,v1=<HMAC con el secreto nuevo>,v2=<HMAC con el anterior>
Las funciones de arriba ya lo manejan: prueban todas las firmas del header contra tu secreto y aceptan si alguna coincide. Eso te permite rotar así, sin ventana de caída:
- Rotás y guardás el secreto nuevo. Tu servicio sigue con el viejo: valida por
v2. - Desplegás el secreto nuevo. Ahora valida por
v1. - Antes de
previous_secret_expires_at, borrás el viejo.
Si preferís aceptar los dos secretos al mismo tiempo durante la transición,
llamá a la función una vez por secreto y aceptá si alguna devuelve true.
Errores frecuentes
| Campo | Tipo | Síntoma | Descripción |
|---|---|---|---|
El body se re-serializa | Firma nunca válida | todas fallan | Tu framework parseó el JSON y firmaste el resultado de JSON.stringify. Guardá el crudo antes de parsear. |
Se firma sin el prefijo | Firma nunca válida | todas fallan | La clave del HMAC es el secreto completo, incluido whsec_. No lo recortes. |
Se firma sólo el cuerpo | Firma nunca válida | todas fallan | El material es <t>.<raw_body>, con el timestamp del header adelante y un punto en el medio. |
Reloj desincronizado | Rechazos intermitentes | válidas pero fuera de ventana | Tu servidor está corrido más de 5 minutos. Sincronizá por NTP antes de ampliar la tolerancia. |
Un proxy modifica el cuerpo | Firma nunca válida | todas fallan | Algún intermediario recomprime o reescribe el JSON. Verificá lo más cerca posible del borde. |