Verificacion de firma
Cada entrega incluye la cabecera:
Ecuafact-Signature: t=1757341445,v1=6a7f...e21c
t: marca de tiempo Unix (segundos) del envio.v1: HMAC-SHA256 en hexadecimal minusculas.
Cadena firmada#
El valor v1 es el HMAC-SHA256 de la concatenacion, separada por un punto:
<timestamp> + "." + <cuerpo crudo>
Usa los bytes exactos del cuerpo recibido, tal como llego. Calcula el HMAC sobre UTF-8 y compara en tiempo constante.
Rechaza las entregas cuya marca de tiempo quede fuera de una ventana (por ejemplo, 5 minutos) para resistir repeticiones.
Verificacion#
# t y cuerpo recibidos; genera el HMAC y comparalo con v1
t="1757341445"
cuerpo='{"eventType":"document.authorized"}'
printf '%s' "$t.$cuerpo" | openssl dgst -sha256 -hmac "$ECUAFACT_WEBHOOK_SECRET"
using System.Security.Cryptography;
using System.Text;
static bool Verificar(string secreto, string cabecera, string cuerpoCrudo, TimeSpan tolerancia)
{
// cabecera: "t=1757341445,v1=6a7f..."
Dictionary<string, string> partes = cabecera
.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!partes.TryGetValue("t", out string? t) || !partes.TryGetValue("v1", out string? v1))
{
return false;
}
if (!long.TryParse(t, out long unix))
{
return false;
}
long ahora = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(ahora - unix) > (long)tolerancia.TotalSeconds)
{
return false;
}
string firmado = t + "." + cuerpoCrudo;
using HMACSHA256 hmac = new(Encoding.UTF8.GetBytes(secreto));
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(firmado));
string esperado = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(esperado),
Encoding.ASCII.GetBytes(v1.ToLowerInvariant()));
}
import hashlib, hmac, time
def verificar(secreto: str, cabecera: str, cuerpo_crudo: str, tolerancia: int = 300) -> bool:
partes = dict(p.split('=', 1) for p in cabecera.split(',') if '=' in p)
t, v1 = partes.get('t'), partes.get('v1')
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > tolerancia:
return False
firmado = f"{t}.{cuerpo_crudo}".encode('utf-8')
esperado = hmac.new(secreto.encode('utf-8'), firmado, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, v1)
const crypto = require('crypto');
function verificar(secreto, cabecera, cuerpoCrudo, toleranciaSegundos) {
const partes = Object.fromEntries(
cabecera.split(',').map((p) => p.split('='))
);
const t = partes.t;
const v1 = partes.v1;
if (!t || !v1) return false;
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - Number(t)) > toleranciaSegundos) return false;
const esperado = crypto
.createHmac('sha256', secreto)
.update(`${t}.${cuerpoCrudo}`, 'utf8')
.digest('hex');
const a = Buffer.from(esperado);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
function verificar(string $secreto, string $cabecera, string $cuerpoCrudo, int $tolerancia = 300): bool
{
$partes = [];
foreach (explode(',', $cabecera) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) { $partes[$kv[0]] = $kv[1]; }
}
$t = $partes['t'] ?? null;
$v1 = $partes['v1'] ?? null;
if ($t === null || $v1 === null) { return false; }
if (abs(time() - (int)$t) > $tolerancia) { return false; }
$esperado = hash_hmac('sha256', $t . '.' . $cuerpoCrudo, $secreto);
return hash_equals($esperado, strtolower($v1));
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
static boolean verificar(String secreto, String cabecera, String cuerpoCrudo, long tolerancia) throws Exception {
Map<String, String> partes = new HashMap<>();
for (String p : cabecera.split(",")) {
String[] kv = p.split("=", 2);
if (kv.length == 2) { partes.put(kv[0], kv[1]); }
}
String t = partes.get("t");
String v1 = partes.get("v1");
if (t == null || v1 == null) { return false; }
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > tolerancia) { return false; }
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secreto.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal((t + "." + cuerpoCrudo).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) { hex.append(String.format("%02x", b)); }
return java.security.MessageDigest.isEqual(
hex.toString().getBytes(StandardCharsets.US_ASCII),
v1.toLowerCase().getBytes(StandardCharsets.US_ASCII));
}
using Ecuafact.Sdk;
bool valido = WebhookSignature.Verify(secreto, cabecera, cuerpoCrudo, DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5));
from ecuafact import verify
valido = verify(secreto, cabecera, cuerpo_crudo, tolerance_seconds=300)
import { verify } from "@ecuafact/sdk";
const valido = verify(secreto, cabecera, cuerpoCrudo, new Date(), 300);
<?php
use Ecuafact\Sdk\WebhookSignature;
$valido = WebhookSignature::verify($secreto, $cabecera, $cuerpoCrudo, time(), 300);
import com.ecuafact.sdk.WebhookSignature;
import java.time.Instant;
boolean valido = WebhookSignature.verify(secreto, cabecera, cuerpoCrudo, Instant.now(), 300);
Vector de prueba#
Verifica tu implementacion con este vector. Calcula el HMAC-SHA256 de la cadena
firmada y comparalo con v1:
| Dato | Valor |
|---|---|
| Secreto | whsec_pruebas |
t |
1757341445 |
| Cuerpo crudo | {"eventType":"document.authorized","resourceId":"3f1c8c1e-6f2a-4b7e-9c3d-2a1b0c9d8e7f"} |
| Cadena firmada | 1757341445.{"eventType":"document.authorized","resourceId":"3f1c8c1e-6f2a-4b7e-9c3d-2a1b0c9d8e7f"} |
v1 esperado |
e3b03c6de58fd0565b83be8c2c83b0fb68dafe66edd06c568e57c5d48e992228 |
| Cabecera | Ecuafact-Signature: t=1757341445,v1=e3b03c6de58fd0565b83be8c2c83b0fb68dafe66edd06c568e57c5d48e992228 |
printf '%s' '1757341445.{"eventType":"document.authorized","resourceId":"3f1c8c1e-6f2a-4b7e-9c3d-2a1b0c9d8e7f"}' \
| openssl dgst -sha256 -hmac "whsec_pruebas"
# e3b03c6de58fd0565b83be8c2c83b0fb68dafe66edd06c568e57c5d48e992228
Usa los bytes exactos del cuerpo (sin reindentar ni reordenar) y respeta la ventana de tolerancia.
Rotacion de secreto#
Al rotar el secreto se emite uno nuevo. Pasos:
- Actualiza el consumidor con el secreto nuevo antes de retirar el anterior.
- Durante la ventana de rotacion, acepta el secreto nuevo y el anterior.
- Retirado el anterior, verifica solo con el vigente.
Verifica siempre contra el secreto vigente; si usas varios, prueba todos y acepta si alguno coincide (en tiempo constante).