Verificação de assinaturas de Webhook
Aprenda a verificar a autenticidade de webhooks enviados pela Liqi usando HMAC-SHA256. A verificação garante que o payload não foi adulterado e que realmente foi enviado pela Liqi.
Como funciona a assinatura
Cada webhook enviado pela Liqi e assinado com HMAC-SHA256 usando o webhook secret que você recebeu ao registrar o endpoint. A assinatura cobre o webhook ID, timestamp e o corpo da requisição, garantindo integridade completa.
Liqi Server Seu Servidor
| |
| 1. Compoe o payload: |
| "{webhook_id}.{timestamp}.{body}" |
| |
| 2. Calcula HMAC-SHA256 com webhook_secret |
| |
| 3. Envia POST com headers: |
| X-Webhook-Signature: {hmac_hex} |
| X-Webhook-Id: {webhook_id} |
| X-Webhook-Timestamp: {unix_timestamp} |
| ──────────────────────────────────────────────> |
| |
| 4. Extrai headers |
| 5. Reconstroi payload |
| 6. Calcula HMAC |
| 7. Compara (timing-safe)|
| 8. Valida timestamp |
| |
| 9. Responde 200 OK (ou 401 se invalido) |
| <────────────────────────────────────────────── |
| |Headers de verificação
| Header | Formato | Descrição |
|---|---|---|
| X-Webhook-Signature | Hex string (64 chars) | Assinatura HMAC-SHA256 em hexadecimal |
| X-Webhook-Id | evt_xxxxxxxxxxxxx | Identificador único do evento |
| X-Webhook-Timestamp | Unix timestamp (seg) | Momento do envio (para proteção contra replay) |
1Extrair headers
Extraia os três headers obrigatórios da requisição. Se qualquer um deles estiver ausente, rejeite a requisição imediatamente.
const signature = req.headers["x-webhook-signature"] as string;
const webhookId = req.headers["x-webhook-id"] as string;
const timestamp = req.headers["x-webhook-timestamp"] as string;
if (!signature || !webhookId || !timestamp) {
return res.status(401).json({ error: "Missing webhook headers" });
}signature = request.headers.get("X-Webhook-Signature")
webhook_id = request.headers.get("X-Webhook-Id")
timestamp = request.headers.get("X-Webhook-Timestamp")
if not all([signature, webhook_id, timestamp]):
return jsonify({"error": "Missing webhook headers"}), 401signature := r.Header.Get("X-Webhook-Signature")
webhookId := r.Header.Get("X-Webhook-Id")
timestamp := r.Header.Get("X-Webhook-Timestamp")
if signature == "" || webhookId == "" || timestamp == "" {
http.Error(w, "Missing webhook headers", http.StatusUnauthorized)
return
}2Construir o payload
O payload assinado é uma concatenação do webhook ID, timestamp e corpo da requisição, separados por ponto:
{webhook_id}.{timestamp}.{raw_body}
Exemplo:
evt_abc123def456.1708534200.{"type":"payment.completed","id":"evt_abc123def456","data":{...}}Atenção
3Calcular o HMAC
Calcule o HMAC-SHA256 do payload usando seu webhook secret como chave:
import crypto from "crypto";
const WEBHOOK_SECRET = process.env.LIQI_WEBHOOK_SECRET!;
const payload = `${webhookId}.${timestamp}.${rawBody.toString()}`;
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");import hmac
import hashlib
import os
WEBHOOK_SECRET = os.environ["LIQI_WEBHOOK_SECRET"]
payload = f"{webhook_id}.{timestamp}.{raw_body.decode()}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
)
webhookSecret := os.Getenv("LIQI_WEBHOOK_SECRET")
payload := webhookId + "." + timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(payload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))4Comparação timing-safe
Use comparação de tempo constante para evitar ataques de timing. Nunca use === ou == para comparar assinaturas.
// CORRETO: Comparação timing-safe const isValid = crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); // ERRADO: Vulnerável a timing attacks // const isValid = signature === expectedSignature;
# CORRETO: Comparação timing-safe is_valid = hmac.compare_digest(signature, expected_signature) # ERRADO: Vulnerável a timing attacks # is_valid = signature == expected_signature
// CORRETO: Comparação timing-safe isValid := hmac.Equal([]byte(signature), []byte(expectedSignature)) // ERRADO: Vulnerável a timing attacks // isValid := signature == expectedSignature
Por que timing-safe?
===) retornam mais rápido quando os primeiros bytes não coincidem. Um atacante pode usar essa diferença de tempo para descobrir a assinatura correta byte a byte. Comparações timing-safe sempre levam o mesmo tempo.5Validar timestamp
Rejeite webhooks com timestamp mais velho que 5 minutos para proteger contra ataques de replay:
const MAX_AGE_SECONDS = 300; // 5 minutos
const now = Math.floor(Date.now() / 1000);
const webhookTimestamp = parseInt(timestamp, 10);
if (Math.abs(now - webhookTimestamp) > MAX_AGE_SECONDS) {
return res.status(401).json({ error: "Webhook timestamp too old" });
}import time
MAX_AGE_SECONDS = 300 # 5 minutos
now = int(time.time())
webhook_timestamp = int(timestamp)
if abs(now - webhook_timestamp) > MAX_AGE_SECONDS:
return jsonify({"error": "Webhook timestamp too old"}), 401import (
"math"
"strconv"
"time"
)
const maxAgeSeconds = 300 // 5 minutos
webhookTimestamp, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
http.Error(w, "Invalid timestamp", http.StatusUnauthorized)
return
}
now := time.Now().Unix()
if math.Abs(float64(now-webhookTimestamp)) > maxAgeSeconds {
http.Error(w, "Webhook timestamp too old", http.StatusUnauthorized)
return
}Implementação completa
Aqui está a implementação completa de verificação em cada linguagem:
import crypto from "crypto";
const WEBHOOK_SECRET = process.env.LIQI_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300;
export function verifyWebhook(
rawBody: Buffer,
headers: Record<string, string>
): { valid: boolean; error?: string } {
const signature = headers["x-webhook-signature"];
const webhookId = headers["x-webhook-id"];
const timestamp = headers["x-webhook-timestamp"];
// 1. Verificar headers obrigatórios
if (!signature || !webhookId || !timestamp) {
return { valid: false, error: "Missing required headers" };
}
// 2. Validar timestamp (proteção contra replay)
const now = Math.floor(Date.now() / 1000);
const webhookTimestamp = parseInt(timestamp, 10);
if (isNaN(webhookTimestamp)) {
return { valid: false, error: "Invalid timestamp format" };
}
if (Math.abs(now - webhookTimestamp) > MAX_AGE_SECONDS) {
return { valid: false, error: "Timestamp too old or too far in future" };
}
// 3. Construir payload e calcular HMAC
const payload = `${webhookId}.${timestamp}.${rawBody.toString()}`;
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");
// 4. Comparação timing-safe
try {
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
if (!isValid) {
return { valid: false, error: "Invalid signature" };
}
} catch {
return { valid: false, error: "Signature length mismatch" };
}
return { valid: true };
}import hmac
import hashlib
import time
import os
WEBHOOK_SECRET = os.environ["LIQI_WEBHOOK_SECRET"]
MAX_AGE_SECONDS = 300
def verify_webhook(
raw_body: bytes,
headers: dict
) -> tuple[bool, str | None]:
"""Verifica a assinatura do webhook. Retorna (valid, error)."""
signature = headers.get("X-Webhook-Signature")
webhook_id = headers.get("X-Webhook-Id")
timestamp = headers.get("X-Webhook-Timestamp")
# 1. Verificar headers obrigatórios
if not all([signature, webhook_id, timestamp]):
return False, "Missing required headers"
# 2. Validar timestamp (proteção contra replay)
try:
webhook_timestamp = int(timestamp)
except ValueError:
return False, "Invalid timestamp format"
now = int(time.time())
if abs(now - webhook_timestamp) > MAX_AGE_SECONDS:
return False, "Timestamp too old or too far in future"
# 3. Construir payload e calcular HMAC
payload = f"{webhook_id}.{timestamp}.{raw_body.decode()}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
# 4. Comparação timing-safe
if not hmac.compare_digest(signature, expected_signature):
return False, "Invalid signature"
return True, Nonepackage webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"strconv"
"time"
)
var (
webhookSecret = os.Getenv("LIQI_WEBHOOK_SECRET")
maxAgeSeconds = 300.0
)
func VerifyWebhook(r *http.Request, rawBody []byte) error {
signature := r.Header.Get("X-Webhook-Signature")
webhookId := r.Header.Get("X-Webhook-Id")
timestamp := r.Header.Get("X-Webhook-Timestamp")
// 1. Verificar headers obrigatórios
if signature == "" || webhookId == "" || timestamp == "" {
return fmt.Errorf("missing required headers")
}
// 2. Validar timestamp
webhookTimestamp, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp format")
}
now := time.Now().Unix()
if math.Abs(float64(now-webhookTimestamp)) > maxAgeSeconds {
return fmt.Errorf("timestamp too old")
}
// 3. Construir payload e calcular HMAC
payload := webhookId + "." + timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(payload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// 4. Comparação timing-safe
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return fmt.Errorf("invalid signature")
}
return nil
}Boas práticas de segurança
- Sempre use comparação timing-safe —
crypto.timingSafeEqual(Node.js),hmac.compare_digest(Python),hmac.Equal(Go). - Valide o timestamp — Rejeite webhooks com mais de 5 minutos de diferença para proteger contra replay attacks.
- Use raw body — Nunca re-serialize o JSON parseado. Middleware como
express.json()consume o body. Useexpress.raw()para a rota de webhook. - Armazene o secret com segurança — Use variável de ambiente ou cofre de segredos. Nunca inclua no código fonte.
- Implemente idempotência — Armazene o
X-Webhook-Idem banco de dados e verifique antes de processar. Webhooks podem ser entregues mais de uma vez. - Monitore rejeições — Registre tentativas com assinatura inválida em log. Muitas tentativas podem indicar um ataque.
Testando com payloads de exemplo
Use o script abaixo para gerar uma assinatura e testar seu endpoint localmente:
import crypto from "crypto";
const SECRET = "whsec_test_secret_for_development";
const WEBHOOK_ID = "evt_test_123";
const TIMESTAMP = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
type: "payment.completed",
id: WEBHOOK_ID,
createdAt: new Date().toISOString(),
data: {
trancheTicker: "ROB1SR06",
installmentNumber: 5,
totalValue: "3095.00",
status: "PAID"
}
});
const payload = `${WEBHOOK_ID}.${TIMESTAMP}.${body}`;
const signature = crypto
.createHmac("sha256", SECRET)
.update(payload)
.digest("hex");
console.log("Headers para teste:");
console.log(` X-Webhook-Signature: ${signature}`);
console.log(` X-Webhook-Id: ${WEBHOOK_ID}`);
console.log(` X-Webhook-Timestamp: ${TIMESTAMP}`);
console.log(`\nBody:\n${body}`);# Defina as variáveis (saída do script acima)
SIGNATURE="a1b2c3d4e5f6..."
WEBHOOK_ID="evt_test_123"
TIMESTAMP="1708534200"
BODY='{"type":"payment.completed","id":"evt_test_123","data":{"trancheTicker":"ROB1SR06","installmentNumber":5,"totalValue":"3095.00","status":"PAID"}}'
curl -X POST "http://localhost:3001/webhooks/liqi" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-H "X-Webhook-Id: $WEBHOOK_ID" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-d "$BODY"Dica
whsec_test_secret_for_development. Nunca use sua chave de produção em testes locais.