Verificar Assinatura
Como verificar a assinatura HMAC-SHA256 dos webhooks. Exemplos em curl, Node.js e Python com proteção anti-replay de ±5 minutos.
Formato da assinatura
O header X-MinhaCNH-Signature segue o formato Stripe-style:
t=<unix_timestamp>,v1=<hmac-sha256-hex>t— timestamp Unix (segundos) do momento da entrega.v1— HMAC-SHA256 do payload com o segredo da organização.
A mensagem assinada é: <timestamp>.<corpo-json-raw>.
Janela anti-replay de ±5 minutos
Rejeite entregas cujo t esteja a mais de 5 minutos do horário atual (passado ou futuro).
Isso protege contra ataques de replay de pacotes capturados.
Implementação
import crypto from 'node:crypto';
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
// Anti-replay: rejeitar se delta > 5 minutos
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Webhook timestamp out of tolerance window');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expected, 'hex')
);
}
// Uso (Express) — note express.raw para preservar o corpo cru:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-minhacnh-signature'];
if (!verifyWebhookSignature(req.body.toString(), sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// processar event.status, event.reportId, event.diagnosis ...
res.status(200).send('ok');
});Webhooks do ambiente de teste
Entregas de relatórios criados com uma chave cnh_test_ chegam na mesma URL, com
livemode: false no corpo e o header X-MinhaCNH-Livemode: false, mas não são assinadas com
o secret da organização. A assinatura usa um secret de teste derivado dele:
secret_de_teste = HMAC-SHA256(secret, "sandbox") // em hex, 64 caracteresO secret de teste aparece nas configurações da API junto com o de produção, sempre que você gera
um novo. Como ele sai do secret de produção por uma fórmula pública, quem tem o secret de
produção consegue calcular o de teste — e qualquer pessoa com uma chave cnh_test_ consegue
disparar eventos de teste, com o externalRef que quiser. Por isso a decisão sobre o ambiente vem
sempre do corpo assinado, nunca do header.
O header não é autenticado
X-MinhaCNH-Livemode é só uma indicação de roteamento: fica fora da assinatura e pode ser
forjado. Use o campo livemode do corpo, e só depois de verificar a assinatura.
Em produção: só o secret de produção
O servidor de produção verifica apenas com o secret de produção e, depois de verificar,
recusa qualquer evento com livemode: false no corpo:
if (!verifyWebhookSignature(req.body.toString(), req.headers['x-minhacnh-signature'], process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
if (event.livemode !== true) {
return res.status(400).send('Test event rejected');
}Eventos de teste falham na assinatura e nunca chegam ao JSON.parse; a checagem de livemode
é a segunda barreira, e não custa nada.
Em desenvolvimento ou homologação: os dois secrets
Só num ambiente que deve receber eventos de teste, aceite as duas assinaturas e decida pelo
livemode do corpo verificado — nunca pelo header:
// Apenas em dev/homologação. Nunca em produção.
const testSecret = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update('sandbox')
.digest('hex');
const signature = req.headers['x-minhacnh-signature'];
const signedLive = verifyWebhookSignature(req.body.toString(), signature, process.env.WEBHOOK_SECRET);
const signedTest = !signedLive && verifyWebhookSignature(req.body.toString(), signature, testSecret);
if (!signedLive && !signedTest) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Assinado com o secret de teste precisa dizer livemode: false no corpo, e vice-versa.
if (event.livemode !== signedLive) {
return res.status(400).send('Environment mismatch');
}Usar X-MinhaCNH-Delivery para idempotência no receptor
O header X-MinhaCNH-Delivery contém um id único por entrega. Armazene ids já processados
para evitar efeitos duplicados em caso de retry:
const deliveryId = req.headers['x-minhacnh-delivery'];
if (await isAlreadyProcessed(deliveryId)) {
return res.status(200).send('already processed');
}
await markAsProcessed(deliveryId);
// ... processar evento