Verificar Signatures HMAC
Cada webhook de DVS se firma con HMAC-SHA256 usando un secret entregado en el onboarding. Siempre verificar la signature antes de procesar — si se omite este paso, un atacante que conozca la URL del endpoint puede enviar eventos falsos.
El algoritmo
DVS calcula:
payload_to_sign = timestamp + "." + raw_body
signature = hmac_sha256_hex(secret, payload_to_sign)
Y envía dos headers:
X-DVS-Signature: t=1748884800,v1=a8d3e8b1c2...
X-DVS-Signature-Timestamp: 1748884800
Lo que corresponde hacer:
- Extraer
timestampdeX-DVS-Signature-Timestamp. - Construir el payload esperado:
timestamp + "." + raw_request_body. - Calcular
hmac_sha256_hex(your_secret, payload). - Compararlo contra la parte
v1=...deX-DVS-Signatureusando comparación de tiempo constante. - Rechazar si
|now - timestamp| > 300 segundos(anti-replay).
Reglas críticas
Usar el raw request body, no el JSON parseado. La signature es sobre los bytes exactos que DVS envió. Si se parsea el JSON y se re-serializa, los espacios en blanco y el orden de las keys cambian y la signature no coincidirá. Configurar el framework para entregar los bytes raw (express.raw(), FastAPI await request.body(), etc.).
Usar una comparación de tiempo constante. Usar hmac.compare_digest (Python), crypto.timingSafeEqual (Node), hash_equals (PHP), MessageDigest.isEqual (Java). Un == ingenuo es vulnerable a timing attacks.
Sincronizar el reloj del servidor con NTP. La ventana anti-replay de ±5 minutos asume tiempo preciso. Relojes desviados rechazarán eventos legítimos.
Implementaciones
- Python (FastAPI)
- Node.js (Express)
- PHP (Slim o puro)
- Java (Spring Boot)
- curl (depuración)
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, Header
DVS_WEBHOOK_SECRET = os.environ["DVS_WEBHOOK_SECRET"]
SIGNATURE_TOLERANCE_SECONDS = 300
app = FastAPI()
def verify_dvs_signature(
raw_body: bytes,
signature_header: str | None,
timestamp_header: str | None,
secret: str,
) -> bool:
if not signature_header or not timestamp_header:
return False
# 1) Ventana anti-replay
try:
ts = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > SIGNATURE_TOLERANCE_SECONDS:
return False
# 2) Parsear el header de signature (formato: "t=<ts>,v1=<hex>")
parts = {k: v for k, v in (kv.split("=", 1) for kv in signature_header.split(","))}
received = parts.get("v1")
if not received:
return False
# 3) Calcular la signature esperada
payload = f"{timestamp_header}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
# 4) Comparación de tiempo constante
return hmac.compare_digest(received, expected)
@app.post("/integrations/dvs/webhooks")
async def receive_dvs_webhook(
request: Request,
x_dvs_signature: str | None = Header(None),
x_dvs_signature_timestamp: str | None = Header(None),
x_dvs_event_id: str | None = Header(None),
x_dvs_event_type: str | None = Header(None),
):
raw_body = await request.body()
if not verify_dvs_signature(
raw_body, x_dvs_signature, x_dvs_signature_timestamp, DVS_WEBHOOK_SECRET
):
raise HTTPException(status_code=401, detail="Invalid signature")
if already_processed(x_dvs_event_id):
return {"status": "duplicate_ignored"}
enqueue_for_processing(x_dvs_event_id, x_dvs_event_type, raw_body)
return {"status": "received"}
import express from "express";
import crypto from "node:crypto";
const DVS_WEBHOOK_SECRET = process.env.DVS_WEBHOOK_SECRET;
const SIGNATURE_TOLERANCE_SECONDS = 300;
const app = express();
// IMPORTANTE: montar como raw para acceder a los bytes exactos para HMAC
app.use(
"/integrations/dvs/webhooks",
express.raw({ type: "application/json" }),
);
function verifyDvsSignature(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader || !timestampHeader) return false;
const ts = parseInt(timestampHeader, 10);
if (Number.isNaN(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > SIGNATURE_TOLERANCE_SECONDS) return false;
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
if (!parts.v1) return false;
const payload = Buffer.concat([
Buffer.from(`${timestampHeader}.`, "utf8"),
rawBody,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
const received = Buffer.from(parts.v1, "hex");
const expectedBuf = Buffer.from(expected, "hex");
if (received.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(received, expectedBuf);
}
app.post("/integrations/dvs/webhooks", (req, res) => {
const signature = req.header("X-DVS-Signature");
const timestamp = req.header("X-DVS-Signature-Timestamp");
const eventId = req.header("X-DVS-Event-Id");
const eventType = req.header("X-DVS-Event-Type");
if (!verifyDvsSignature(req.body, signature, timestamp, DVS_WEBHOOK_SECRET)) {
return res.status(401).send({ error: "Invalid signature" });
}
if (alreadyProcessed(eventId)) {
return res.status(200).send({ status: "duplicate_ignored" });
}
enqueueForProcessing(eventId, eventType, req.body);
res.status(200).send({ status: "received" });
});
app.listen(8080);
<?php
const DVS_WEBHOOK_SECRET = ""; // cargar desde getenv("DVS_WEBHOOK_SECRET")
const SIGNATURE_TOLERANCE_SECONDS = 300;
function verifyDvsSignature(
string $rawBody,
?string $signatureHeader,
?string $timestampHeader,
string $secret
): bool {
if (!$signatureHeader || !$timestampHeader) return false;
$ts = intval($timestampHeader);
if ($ts === 0) return false;
if (abs(time() - $ts) > SIGNATURE_TOLERANCE_SECONDS) return false;
$parts = [];
foreach (explode(",", $signatureHeader) as $kv) {
[$k, $v] = explode("=", $kv, 2);
$parts[$k] = $v;
}
if (!isset($parts["v1"])) return false;
$payload = $timestampHeader . "." . $rawBody;
$expected = hash_hmac("sha256", $payload, $secret);
return hash_equals($expected, $parts["v1"]);
}
// En el handler de la ruta:
$rawBody = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_DVS_SIGNATURE"] ?? null;
$timestamp = $_SERVER["HTTP_X_DVS_SIGNATURE_TIMESTAMP"] ?? null;
$eventId = $_SERVER["HTTP_X_DVS_EVENT_ID"] ?? null;
if (!verifyDvsSignature($rawBody, $signature, $timestamp, getenv("DVS_WEBHOOK_SECRET"))) {
http_response_code(401);
echo json_encode(["error" => "Invalid signature"]);
exit;
}
if (alreadyProcessed($eventId)) {
http_response_code(200);
echo json_encode(["status" => "duplicate_ignored"]);
exit;
}
enqueueForProcessing($eventId, $rawBody);
http_response_code(200);
echo json_encode(["status" => "received"]);
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
@RestController
public class DvsWebhookController {
private static final String SECRET = System.getenv("DVS_WEBHOOK_SECRET");
private static final long TOLERANCE_SECONDS = 300;
@PostMapping(value = "/integrations/dvs/webhooks", consumes = "application/json")
public ResponseEntity<?> receive(
@RequestBody byte[] rawBody,
@RequestHeader(value = "X-DVS-Signature", required = false) String signatureHeader,
@RequestHeader(value = "X-DVS-Signature-Timestamp", required = false) String timestampHeader,
@RequestHeader(value = "X-DVS-Event-Id", required = false) String eventId
) throws Exception {
if (!verify(rawBody, signatureHeader, timestampHeader, SECRET)) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
}
if (idempotencyStore.alreadyProcessed(eventId)) {
return ResponseEntity.ok(Map.of("status", "duplicate_ignored"));
}
queue.enqueue(eventId, rawBody);
return ResponseEntity.ok(Map.of("status", "received"));
}
private boolean verify(byte[] body, String sigHeader, String tsHeader, String secret) throws Exception {
if (sigHeader == null || tsHeader == null) return false;
long ts;
try { ts = Long.parseLong(tsHeader); } catch (Exception e) { return false; }
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCE_SECONDS) return false;
String v1 = null;
for (String part : sigHeader.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length == 2 && kv[0].equals("v1")) v1 = kv[1];
}
if (v1 == null) return false;
byte[] prefix = (tsHeader + ".").getBytes(StandardCharsets.UTF_8);
byte[] payload = new byte[prefix.length + body.length];
System.arraycopy(prefix, 0, payload, 0, prefix.length);
System.arraycopy(body, 0, payload, prefix.length, body.length);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(payload);
String expectedHex = bytesToHex(expected);
return MessageDigest.isEqual(expectedHex.getBytes(), v1.getBytes());
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
}
# Para verificar que se está calculando la misma signature que DVS envió:
TIMESTAMP="1748884800"
BODY='{"event_id":"evt_test","event_type":"test.ping","event_version":1}'
SECRET="whsec_xxxxxxxxxxxxxx"
EXPECTED=$(printf "%s.%s" "$TIMESTAMP" "$BODY" | \
openssl dgst -sha256 -hmac "$SECRET" -hex | \
awk '{print $NF}')
echo "Expected v1=$EXPECTED"
echo "Comparar contra la parte v1=... del header X-DVS-Signature enviado por DVS."
Solución de problemas
| Síntoma | Causa probable |
|---|---|
| El receptor retorna 401 en todos los webhooks | Secret incorrecto, o se está calculando el HMAC sobre el JSON parseado en lugar del raw body. |
| Algunos webhooks pasan y otros fallan (tras rotación) | Se olvidó soportar ambos secrets durante la ventana de gracia de 24h. Ver Rotación de Webhook Secrets. |
| Todos los webhooks rechazados con "timestamp too old" | El reloj del servidor está desviado. Instalar NTP / chrony / systemd-timesyncd. |
| Funciona localmente con ngrok, falla en producción | Secret distinto en el entorno de producción, o un proxy mutando el body (algunos load balancers/CDNs alteran el JSON). Evitar cualquier transformación entre el borde de la red y el handler. |
Pruebas sin tráfico real
Pedir a OSIGU que envíe un evento test.ping al endpoint vía la acción administrativa :test. El payload es sintético pero firmado con el secret real — confirma que la verificación end-to-end funciona.