Conferir a assinatura do webhook
A função recebe o segredo, os headers X-Etech-Timestamp e X-Etech-Signature e o corpo cru. Ela devolve true só quando o timestamp está na janela de 5 minutos e a assinatura confere. As regras estão em Webhook de venda.
Calcule a assinatura sobre os bytes que chegaram, antes de qualquer parse. Um JSON lido e serializado de novo muda espaços e a ordem dos campos, e a assinatura deixa de conferir.
- C#
- Node.js
- Python
- Java
using System.Security.Cryptography;
using System.Text;
static class AssinaturaWebhook
{
static readonly TimeSpan Janela = TimeSpan.FromMinutes(5);
public static bool Valida(string segredo, string? timestamp, string corpoCru, string? assinatura, DateTimeOffset agora)
{
if (timestamp is null || assinatura is null || !long.TryParse(timestamp, out var segundos))
return false;
var enviadoEm = DateTimeOffset.FromUnixTimeSeconds(segundos);
if ((agora - enviadoEm).Duration() > Janela)
return false;
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(segredo),
Encoding.UTF8.GetBytes(timestamp + "." + corpoCru));
var esperada = "v1=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(esperada),
Encoding.UTF8.GetBytes(assinatura));
}
}
No ASP.NET Core, leia o corpo cru antes do model binding: using var leitor = new StreamReader(Request.Body, Encoding.UTF8); var corpoCru = await leitor.ReadToEndAsync();.
import { createHmac, timingSafeEqual } from 'node:crypto';
const JANELA_SEGUNDOS = 5 * 60;
/**
* @param {string} segredo o segredo de assinatura, como texto
* @param {string} timestamp o header X-Etech-Timestamp
* @param {Buffer} corpoCru o corpo exatamente como chegou, antes do JSON.parse
* @param {string} assinatura o header X-Etech-Signature
*/
export function assinaturaValida(segredo, timestamp, corpoCru, assinatura, agora = Date.now()) {
if (!timestamp || !assinatura || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Math.floor(agora / 1000) - Number(timestamp)) > JANELA_SEGUNDOS) return false;
const hash = createHmac('sha256', Buffer.from(segredo, 'utf8'))
.update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), corpoCru]))
.digest('hex');
const esperada = Buffer.from(`v1=${hash}`, 'utf8');
const recebida = Buffer.from(assinatura, 'utf8');
return esperada.length === recebida.length && timingSafeEqual(esperada, recebida);
}
No Express, use express.raw({ type: 'application/json' }) na rota do webhook para receber o corpo como Buffer.
import hashlib
import hmac
import time
JANELA_SEGUNDOS = 5 * 60
def assinatura_valida(segredo: str, timestamp: str | None, corpo_cru: bytes,
assinatura: str | None, agora: float | None = None) -> bool:
"""corpo_cru: os bytes do corpo exatamente como chegaram, antes do json.loads."""
if not timestamp or not assinatura or not timestamp.isdigit():
return False
agora = time.time() if agora is None else agora
if abs(int(agora) - int(timestamp)) > JANELA_SEGUNDOS:
return False
mensagem = timestamp.encode("utf-8") + b"." + corpo_cru
esperada = "v1=" + hmac.new(segredo.encode("utf-8"), mensagem, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, assinatura)
No Flask, o corpo cru é request.get_data(); no FastAPI, await request.body().
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class AssinaturaWebhook {
private static final long JANELA_SEGUNDOS = 5 * 60;
/** corpoCru: os bytes do corpo exatamente como chegaram, antes do parse do JSON. */
public static boolean valida(String segredo, String timestamp, byte[] corpoCru,
String assinatura, long agoraSegundos) throws Exception {
if (timestamp == null || assinatura == null || !timestamp.matches("[0-9]+")) return false;
if (Math.abs(agoraSegundos - Long.parseLong(timestamp)) > JANELA_SEGUNDOS) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(segredo.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] hash = mac.doFinal(corpoCru);
StringBuilder esperada = new StringBuilder("v1=");
for (byte b : hash) esperada.append(String.format("%02x", b));
return MessageDigest.isEqual(
esperada.toString().getBytes(StandardCharsets.UTF_8),
assinatura.getBytes(StandardCharsets.UTF_8));
}
}
No Spring, receba o corpo como @RequestBody byte[] corpoCru e faça o parse depois de validar.
Vetor de teste
Use estes valores para conferir a sua implementação antes de testar com a ETech. Com o relógio em 1792681260 (60 s depois do timestamp), a função tem de devolver true. Com um espaço a mais no corpo, ou com o relógio 6 minutos adiante, tem de devolver false.
| Entrada | Valor |
|---|---|
| Segredo | dGVzdGUtZGUtYXNzaW5hdHVyYS1ldGVjaA |
X-Etech-Timestamp | 1792681200 |
X-Etech-Signature | v1=cd609fc3145c09b4edd66a62938898209c8abf10255d169fced6604d96bc0d74 |
Corpo, exatamente como abaixo, numa linha só e sem quebra de linha no fim:
{"id":"0f7c2d1e-4b9a-4c3e-8d21-6a5b7c8d9e01","type":"integration.ping","sequence":1,"occurredAt":"2026-10-22T15:00:00+00:00","organizationId":2,"data":{"message":"ping"}}
O segredo é usado como texto, sem decodificar o base64url.