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:

  1. Actualiza el consumidor con el secreto nuevo antes de retirar el anterior.
  2. Durante la ventana de rotacion, acepta el secreto nuevo y el anterior.
  3. 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).

Siguientes pasos#

No se pudo completar la operacion. Recargar ✕