StackPractices
intermediate Por Mathias Paulenko

Firma de Requests con HMAC

Asegura requests de APIs con firmas HMAC-SHA256 para garantizar integridad y autenticidad.

Temas: security

Visión General

HMAC (Hash-based Message Authentication Code) es el estándar de la industria para firmar requests de APIs. Al combinar un secreto compartido con el payload del request y un hash criptográfico, tanto emisor como receptor pueden verificar la integridad y autenticidad del mensaje sin transmitir el secreto por la red.

Cuándo Usar

Usa este recurso cuando:

  • Autenticas llamadas API de servicio a servicio
  • Aseguras que payloads de webhooks no han sido alterados
  • Implementas autenticación de API keys sin la complejidad de OAuth
  • Verificas integridad de requests a través de redes no confiables

Solución

Firma HMAC-SHA256 (Node.js)

const crypto = require('crypto');

function signRequest(method, path, body, timestamp, secret) {
  const payload = method.toUpperCase() + path + timestamp + JSON.stringify(body);
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

function verifyRequest(method, path, body, timestamp, signature, secret) {
  const expected = signRequest(method, path, body, timestamp, secret);
  // Comparación en tiempo constante
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Ejemplo Cliente-Servidor (Python)

import hmac
import hashlib
import time

def sign_request(method: str, path: str, body: bytes, secret: str) -> str:
    timestamp = str(int(time.time()))
    message = f"{method.upper()}{path}{timestamp}{body.decode()}"
    signature = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()
    return signature, timestamp

# Cliente
signature, ts = sign_request("POST", "/api/orders", b'{"id":1}', "my-secret")
headers = {"X-Signature": signature, "X-Timestamp": ts}

# Servidor
def verify(signature: str, timestamp: str, method, path, body, secret):
    # Rechazar requests antiguos (protección contra replay)
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False
    expected, _ = sign_request(method, path, body, secret)
    return hmac.compare_digest(signature, expected)

Explicación

La seguridad de HMAC se basa en tres propiedades:

  1. Clave secreta: Nunca transmitida; compartida out-of-band durante el onboarding
  2. Cobertura del mensaje: La firma debe cubrir method, path, timestamp y body
  3. Protección contra replay: Ventanas de tiempo previenen que atacantes reutilicen requests antiguos

¿Por qué no SHA-256 plano? SHA-256 sin HMAC es vulnerable a ataques de extensión de longitud. HMAC usa dos pasadas anidadas de hashing que previenen esto.

Variantes

AlgoritmoHashFortalezaNotas
HMAC-SHA256SHA-256128-bitRecomendado por defecto
HMAC-SHA384SHA-384192-bitMayor margen de seguridad
HMAC-SHA512SHA-512256-bitMás lento; usar en contextos de alta seguridad
HMAC-Blake3Blake3256-bitRápido; alternativa moderna

Lo que funciona

  • Incluye timestamp: Rechaza requests más antiguos que 5 minutos para prevenir ataques de replay
  • Firma todo el request: Method + path + timestamp + body (headers ordenados si se incluyen)
  • Usa comparación en tiempo constante: timingSafeEqual previene ataques de timing
  • Rota secretos regularmente: Usa versionado de claves (v1, v2) en el header de firma
  • Nunca loguees el secreto: Loguea firmas y claves, nunca el secreto raw

Errores Comunes

  1. Firmar solo el body: Un atacante puede replayar un body válido con un endpoint diferente
  2. Faltar protección contra replay: Sin timestamps, requests interceptados son válidos para siempre
  3. Usar MD5 o SHA-1: Criptográficamente rotos; usar mínimo SHA-256
  4. Comparación de strings en lugar de timingSafeEqual: Vulnerable a ataques de timing
  5. Almacenar secretos en variables de entorno sin encriptar: Usa un secret manager

Soluciones Avanzadas

Firma HMAC-SHA256 en Java con rotación de claves

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class HmacSigner {

    private final Map<String, String> secrets = new HashMap<>();

    public HmacSigner() {
        secrets.put("v1", "old-secret-key");
        secrets.put("v2", "current-secret-key");
    }

    public String sign(String method, String path, String body, String timestamp, String keyVersion)
            throws Exception {
        String payload = method.toUpperCase() + path + timestamp + body;
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec keySpec = new SecretKeySpec(
            secrets.get(keyVersion).getBytes(StandardCharsets.UTF_8),
            "HmacSHA256"
        );
        mac.init(keySpec);
        byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
        return keyVersion + ":" + Base64.getEncoder().encodeToString(hash);
    }

    public boolean verify(String method, String path, String body, String timestamp, String signature)
            throws Exception {
        // Parsear versión de clave desde la firma: "v2:base64hash"
        String[] parts = signature.split(":", 2);
        if (parts.length != 2) return false;

        String keyVersion = parts[0];
        String receivedHash = parts[1];

        if (!secrets.containsKey(keyVersion)) return false;

        String expected = sign(method, path, body, timestamp, keyVersion);
        String expectedHash = expected.split(":", 2)[1];

        // Comparación en tiempo constante
        return MessageDigest.isEqual(
            receivedHash.getBytes(StandardCharsets.UTF_8),
            expectedHash.getBytes(StandardCharsets.UTF_8)
        );
    }
}

// Uso
HmacSigner signer = new HmacSigner();
String sig = signer.sign("POST", "/api/orders", "{\"id\":1}", "1690000000", "v2");
// Headers: X-Signature: v2:base64hash, X-Timestamp: 1690000000

Verificación de firma de webhook con raw body

Muchas APIs (Stripe, GitHub, Slack) firman webhooks con HMAC. Debes verificar usando el raw body del request antes de cualquier parseo JSON:

const crypto = require('crypto');

/**
 * Verifica una firma de webhook estilo Stripe.
 * Stripe usa: t=<timestamp>,v1=<firma>
 */
function verifyWebhook(rawBody, signatureHeader, secret) {
    const parts = signatureHeader.split(',');
    const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1];
    const signatures = parts
        .filter(p => p.startsWith('v1='))
        .map(p => p.split('=')[1]);

    if (!timestamp || signatures.length === 0) return false;

    // Rechazar timestamps antiguos (ventana de 5 minutos)
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
    if (age > 300 || age < -300) return false;

    // Calcular firma esperada: HMAC-SHA256(timestamp + rawBody)
    const payload = `${timestamp}.${rawBody}`;
    const expected = crypto
        .createHmac('sha256', secret)
        .update(payload, 'utf8')
        .digest('hex');

    // Verificar contra todas las firmas proporcionadas (Stripe puede enviar múltiples)
    return signatures.some(sig =>
        crypto.timingSafeEqual(
            Buffer.from(sig, 'hex'),
            Buffer.from(expected, 'hex')
        )
    );
}

// Middleware Express: debe usar raw body
const express = require('express');
const app = express();

// IMPORTANTE: usar raw body para verificación de firma
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks/stripe', (req, res) => {
    const rawBody = req.body.toString('utf8');
    const sig = req.headers['stripe-signature'];

    if (!verifyWebhook(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET)) {
        return res.status(400).send('Firma inválida');
    }

    const event = JSON.parse(rawBody);
    console.log('Webhook verificado:', event.type);
    res.status(200).send('OK');
});

Prevención de replay con nonce

Los timestamps solos permiten una ventana de replay de 5 minutos. Añade un cache de nonce para rechazar requests duplicados dentro de esa ventana:

import hmac
import hashlib
import time
from collections import OrderedDict
from typing import Optional


class NonceCache:
    """Cache LRU para rastrear nonces usados dentro de la ventana de timestamp."""

    def __init__(self, max_size: int = 10000, ttl_seconds: int = 300):
        self._cache: OrderedDict[str, float] = OrderedDict()
        self._max_size = max_size
        self._ttl = ttl_seconds

    def check_and_add(self, nonce: str) -> bool:
        """Retorna True si el nonce es nuevo (aceptable), False si es duplicado."""
        now = time.time()
        self._evict_expired(now)

        if nonce in self._cache:
            return False  # Nonce duplicado

        self._cache[nonce] = now
        if len(self._cache) > self._max_size:
            self._cache.popitem(last=False)
        return True

    def _evict_expired(self, now: float):
        expired = [
            k for k, ts in self._cache.items()
            if now - ts > self._ttl
        ]
        for k in expired:
            self._cache.pop(k, None)


class HmacVerifier:
    """Verificador HMAC con prevención de replay basada en nonce."""

    def __init__(self, secret: str, max_skew_seconds: int = 300):
        self.secret = secret.encode()
        self.max_skew = max_skew_seconds
        self.nonces = NonceCache(max_size=10000, ttl_seconds=max_skew_seconds)

    def verify(
        self,
        method: str,
        path: str,
        body: str,
        timestamp: str,
        nonce: str,
        signature: str,
    ) -> bool:
        # Verificar ventana de timestamp
        try:
            ts = int(timestamp)
        except ValueError:
            return False

        if abs(int(time.time()) - ts) > self.max_skew:
            return False

        # Verificar unicidad del nonce
        if not self.nonces.check_and_add(nonce):
            return False

        # Verificar firma
        message = f"{method.upper()}{path}{timestamp}{nonce}{body}"
        expected = hmac.new(
            self.secret,
            message.encode(),
            hashlib.sha256
        ).hexdigest()

        return hmac.compare_digest(signature, expected)


# Uso en el servidor
verifier = HmacVerifier("my-secret-key")

# El request llega con headers:
# X-Timestamp: 1690000000
# X-Nonce: a1b2c3d4-e5f6-7890-abcd-ef1234567890
# X-Signature: hexhash

is_valid = verifier.verify(
    method="POST",
    path="/api/orders",
    body='{"id":1,"item":"widget"}',
    timestamp="1690000000",
    nonce="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    signature="abc123...",
)

Estrategia de rotación de claves

import hmac
import hashlib
import time
from typing import Optional


class KeyRotator:
    """Gestiona rotación de claves HMAC con período de superposición para zero downtime."""

    def __init__(self):
        self.keys: dict[str, dict] = {}
        self.active_version: Optional[str] = None

    def add_key(self, version: str, secret: str, activate: bool = True):
        self.keys[version] = {
            "secret": secret,
            "created": time.time(),
        }
        if activate:
            self.active_version = version

    def deactivate_key(self, version: str):
        self.keys.pop(version, None)
        if self.active_version == version:
            # Activar la clave más reciente restante
            if self.keys:
                self.active_version = max(self.keys.keys())
            else:
                self.active_version = None

    def get_signing_key(self) -> tuple[str, str]:
        """Retorna (versión, secreto) para firmar nuevos requests."""
        if not self.active_version:
            raise RuntimeError("No hay clave de firma activa")
        return self.active_version, self.keys[self.active_version]["secret"]

    def get_verification_keys(self) -> list[tuple[str, str]]:
        """Retorna todas las claves válidas para verificar requests entrantes."""
        return [(v, info["secret"]) for v, info in self.keys.items()]

    def sign(self, method: str, path: str, body: str, timestamp: str) -> str:
        version, secret = self.get_signing_key()
        message = f"{method.upper()}{path}{timestamp}{body}"
        sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
        return f"{version}:{sig}"

    def verify(self, method: str, path: str, body: str, timestamp: str, signature: str) -> bool:
        parts = signature.split(":", 1)
        if len(parts) != 2:
            return False

        version, received_sig = parts
        for v, secret in self.get_verification_keys():
            if v != version:
                continue
            message = f"{method.upper()}{path}{timestamp}{body}"
            expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
            if hmac.compare_digest(received_sig, expected):
                return True
        return False


# Flujo de rotación:
# 1. Añadir nueva clave (v2) — tanto v1 como v2 son válidas para verificación
rotator = KeyRotator()
rotator.add_key("v1", "old-secret", activate=False)
rotator.add_key("v2", "new-secret", activate=True)

# 2. Desplegar: nuevos requests firmados con v2, requests v1 antiguos aún verifican
# 3. Después de que todos los requests antiguos expiren (ventana de timestamp), desactivar v1
# rotator.deactivate_key("v1")

Preguntas frecuentes

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.