Verificação de assinatura
HMAC-SHA256 sobre timestamp + body bruto, comparação em tempo constante, janela de replay de 300 segundos. Código funcional em curl, Node e Python.
Toda entrega de webhook da BlendFi é assinada com HMAC-SHA256. Verificar essa assinatura é obrigatório: um receptor não verificado expõe uma superfície de execução remota de código pela rede pública. Esta página é a referência canônica: os headers que a BlendFi envia, o algoritmo, snippets funcionais em três linguagens e os quatro erros mais comuns em implementações.
Os headers
Toda entrega carrega:
X-Blendfi-Event-Id: evt_01J…
X-Blendfi-Event-Type: conversion.completed
X-Blendfi-Timestamp: 1714500000
X-Blendfi-Signature: t=1714500000,v1=<hex-of-hmac-sha256>
Content-Type: application/json| Header | Formato | Propósito |
|---|---|---|
X-Blendfi-Event-Id | evt_<id> | Identificador estável para este evento. Use como sua chave de controle de duplicatas. |
X-Blendfi-Event-Type | ex.: conversion.completed | Header de conveniência para rotear antes de interpretar o body. |
X-Blendfi-Timestamp | Unix em segundos | Quando a BlendFi montou a entrega. Espelha o valor t= de dentro da assinatura e é usado para aplicação da janela de replay. |
X-Blendfi-Signature | t=<unix_segundos>,v1=<hex> | O timestamp de assinatura (t=) mais o HMAC-SHA256 de {t}.{raw_body} com chave igual ao segredo do endpoint, codificado em hexadecimal (v1=). |
O timestamp assinado é carregado dentro do header X-Blendfi-Signature como o componente t=, e é isso que conecta a proteção de replay à assinatura: você não consegue verificar a assinatura sem também vinculá-la ao timestamp.
O algoritmo
Os cinco passos que todo verificador executa, exatamente nesta ordem:
- Leia os bytes brutos do body. Antes de qualquer interpretação do JSON, antes de qualquer re-serialização do middleware. Os bytes exatos que a BlendFi enviou.
- Separe o
X-Blendfi-Signaturenos componentest=(timestamp) ev1=(assinatura em hexadecimal) e então reconstrua o payload assinado:{t}.{raw_body}. - Aplique HMAC-SHA256 nessa string com o segredo em texto plano do endpoint. Pegue o resultado em hexadecimal.
- Compare em tempo constante o resultado com o valor
v1=. Não use igualdade de string, ataques de timing recuperam segredos. - Rejeite se
agora - t > 300 segundos: proteção da janela de replay.
Tudo o mais (interpretar o body, controle de duplicatas, despacho) acontece depois que isso passar.
Código funcional
# Smoke-test contra um payload e segredo conhecidos.
# Útil para debug: produza a assinatura você mesmo e compare com o header.
TIMESTAMP=1714500000
BODY='{"id":"evt_01J","type":"conversion.completed","data":{}}'
SECRET='whsec_yoursecret'
# Reconstrói o payload assinado
SIGNED="${TIMESTAMP}.${BODY}"
# HMAC-SHA256, hexadecimal
echo -n "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}'
# → bate com o valor v1= de X-Blendfi-Signatureimport crypto from "node:crypto";
import express from "express";
const app = express();
// Crítico: capture o body bruto, não um parseado nem re-serializado.
app.post(
"/blendfi-webhooks",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const id = req.header("x-blendfi-event-id");
const signatureHeader = req.header("x-blendfi-signature");
if (!id || !signatureHeader) {
return res.status(401).send("missing webhook headers");
}
// Separa "t=<unix>,v1=<hex>" nos seus componentes.
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) {
return res.status(401).send("malformed signature header");
}
// Janela de replay
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skew > 300) {
return res.status(401).send("timestamp outside 300s window");
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", process.env.BLENDFI_WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
const match =
a.length === b.length && crypto.timingSafeEqual(a, b);
if (!match) {
return res.status(401).send("signature mismatch");
}
// Dedup em id (Redis com TTL ≥ janela de retentativa), pseudo:
// if (await redis.exists(`webhook:${id}`)) return res.sendStatus(200);
// await redis.set(`webhook:${id}`, "1", "EX", 600);
// Ack rápido; faça o trabalho real de forma assíncrona.
res.sendStatus(200);
queue.enqueue({ id, body: JSON.parse(rawBody) });
},
);import hmac, hashlib, time, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["BLENDFI_WEBHOOK_SECRET"].encode()
@app.post("/blendfi-webhooks")
def handle():
raw = request.get_data() # bytes brutos; NÃO use request.json
id_ = request.headers.get("X-Blendfi-Event-Id")
sig_hdr = request.headers.get("X-Blendfi-Signature", "")
if not id_ or not sig_hdr:
return "missing webhook headers", 401
# Separa "t=<unix>,v1=<hex>" nos seus componentes.
parts = dict(p.split("=", 1) for p in sig_hdr.split(",") if "=" in p)
ts = parts.get("t")
provided = parts.get("v1")
if not ts or not provided:
return "malformed signature header", 401
# Janela de replay
if abs(time.time() - int(ts)) > 300:
return "timestamp outside 300s window", 401
signed_payload = f"{ts}.".encode() + raw
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(provided, expected):
return "signature mismatch", 401
# Dedup em id (pseudo Redis):
# if redis.exists(f"webhook:{id_}"): return "", 200
# redis.setex(f"webhook:{id_}", 600, "1")
# Ack rápido; enfileire o trabalho real.
queue.put({"id": id_, "body": request.json})
return "", 200Quatro erros comuns na implementação
A grande maioria dos chamados de suporte sobre verificação de assinatura corresponde a um destes casos.
1. Body bruto re-serializado por middleware
A falha mais comum. express.json() no Express, interpretação automática do JSON no FastAPI, todos interpretam o body antes do seu handler ver e depois re-serializam para o código que vem depois. JSON re-serializado difere do original (whitespace, ordem das chaves, quebras de linha finais) → HMAC não bate.
Correção: capture o body bruto antes de qualquer middleware tocar. No Express, express.raw({ type: "application/json" }) para a rota de webhook. No FastAPI, await request.body().
2. Comparação string-equal em vez de tempo constante
== ou === em strings sai cedo assim que um byte difere. Um atacante medindo o tempo de resposta consegue recuperar a assinatura byte por byte. Sempre use crypto.timingSafeEqual (Node), hmac.compare_digest (Python) ou o equivalente da sua linguagem.
3. Clock skew excede 300 segundos
A janela de replay pega payloads antigos, mas também pega o seu próprio servidor se o relógio dele estiver com drift. Sintomas: falhas de assinatura agrupadas em torno de um único host; falhas intermitentes que "se corrigem sozinhas" minutos depois. Correção: sincronize NTP. A maioria dos provedores de cloud expõe um time-sync service local que faz isso automaticamente.
4. Segredo errado (test vs live)
Cada ambiente tem o próprio segredo. Uma chave sk_live_… cria endpoints de webhook com segredos de ambiente live; sk_test_… cria segredos de ambiente sandbox. Sintoma: toda assinatura falha depois de um deploy.
Dica de diagnóstico
Quando as assinaturas falham em CI mas não localmente (ou vice-versa), a primeira coisa a checar é qual segredo o verificador está lendo. Logue os primeiros 8 caracteres do segredo (nunca o segredo inteiro) e compare entre ambientes.
Leia em seguida
- Retentativas e replay, o que acontece quando a verificação falha (a BlendFi retenta)
- Gerenciamento de endpoints, como rotacionar o segredo sem perder entregas
- Testes no sandbox, acione uma entrega contra um listener local e verifique de ponta a ponta
