Verificar Signatures HMAC
Todo webhook do DVS é assinado com HMAC-SHA256 usando um secret fornecido no onboarding. Sempre verifique a signature antes de processar — caso esse passo seja ignorado, um atacante que conheça a URL do endpoint pode enviar eventos falsos.
O algoritmo
O DVS calcula:
payload_to_sign = timestamp + "." + raw_body
signature = hmac_sha256_hex(secret, payload_to_sign)
E envia dois headers:
X-DVS-Signature: t=1748884800,v1=a8d3e8b1c2...
X-DVS-Signature-Timestamp: 1748884800
O que deve ser feito:
- Extrair
timestampdeX-DVS-Signature-Timestamp. - Construir o payload esperado:
timestamp + "." + raw_request_body. - Calcular
hmac_sha256_hex(seu_secret, payload). - Comparar com a parte
v1=...deX-DVS-Signatureusando comparação de tempo constante. - Rejeitar se
|agora - timestamp| > 300 segundos(anti-replay).
Regras críticas
Use o raw request body, não o JSON parseado. A signature cobre exatamente os bytes que o DVS enviou. Se houver parse do JSON e re-serialização, espaços em branco e ordem das chaves mudam e a signature não confere. Configure o framework para fornecer raw bytes (express.raw(), FastAPI await request.body(), etc.).
Use comparação de tempo constante. Utilize hmac.compare_digest (Python), crypto.timingSafeEqual (Node), hash_equals (PHP), MessageDigest.isEqual (Java). Uma comparação ingênua com == é vulnerável a timing attacks.
Sincronize o clock do servidor com NTP. A janela anti-replay de ±5 minutos pressupõe tempo preciso. Clocks com drift rejeitarão eventos legítimos.
Implementações
- Python (FastAPI)
- Node.js (Express)
- PHP (Slim ou vanilla)
- Java (Spring Boot)
- curl (debug)
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) Janela anti-replay
try:
ts = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > SIGNATURE_TOLERANCE_SECONDS:
return False
# 2) Parse do 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) Calcula a signature esperada
payload = f"{timestamp_header}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
# 4) Comparação de tempo 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: monte como raw para acessar os bytes exatos para o 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 = ""; // carregue de 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"]);
}
// No route handler:
$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 conferir se a signature calculada bate com a enviada pelo DVS:
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 "Compare against the v1=... part of the X-DVS-Signature header DVS sent."
Troubleshooting
| Sintoma | Causa provável |
|---|---|
| Receiver retorna 401 para todo webhook | Secret incorreto, ou cálculo do HMAC sobre o JSON parseado em vez do raw body. |
| Alguns webhooks passam, outros falham (após rotação) | Esqueceu de suportar ambos os secrets durante a janela de 24h. Consulte Rotacionando Webhook Secrets. |
| Todos os webhooks rejeitados com "timestamp too old" | Clock do servidor com drift. Instale NTP / chrony / systemd-timesyncd. |
| Funciona localmente com ngrok, falha em prod | Secret diferente no ambiente de prod, ou proxy alterando o body (alguns load balancers/CDNs modificam JSON). Evite qualquer transformação entre a borda da rede e o handler. |
Teste sem tráfego real
Solicite à OSIGU o envio de um evento test.ping ao endpoint via a ação admin :test. O payload é sintético, mas assinado com o secret real — confirma que a verificação ponta a ponta funciona.