Saltar a contenido

Verificar firma HMAC-SHA256

Cuando se registra un webhook con un secreto compartido, todas las notificaciones incluyen la cabecera X-Webhook-Signature con un HMAC-SHA256 del body. Verificar esta firma garantiza que la notificación proviene del sistema T-Canaria y no ha sido manipulada.

Cabeceras del webhook

POST https://mi-entidad.es/api/webhooks/tcanaria
Content-Type: application/json
X-Webhook-Event: evaluacion.abierta
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592ae...

Algoritmo de verificación

  1. Leer el body completo de la petición como string (sin parsear JSON)
  2. Calcular el HMAC-SHA256 del body usando el secreto compartido
  3. Convertir el resultado a hexadecimal en minusculas
  4. Comparar con el valor de la cabecera X-Webhook-Signature
  5. Si coinciden, la petición es auténtica
flowchart LR
    A[Body de la peticion] --> B["HMAC-SHA256(body, secreto)"]
    B --> C[Hex en minusculas]
    C --> D{Coincide con<br/>X-Webhook-Signature?}
    D -->|Sí| E[Petición auténtica]
    D -->|No| F[Rechazar petición]

Ejemplos de verificación

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "mi-secreto-compartido-seguro-12345"

@app.route("/api/webhooks/tcanaria", methods=["POST"])
def recibir_webhook():
    # 1. Obtener la firma de la cabecera
    firma_recibida = request.headers.get("X-Webhook-Signature", "")

    # 2. Calcular la firma del body
    body = request.get_data()  # bytes del body sin parsear
    firma_calculada = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        body,
        hashlib.sha256
    ).hexdigest()

    # 3. Comparar de forma segura (timing-safe)
    if not hmac.compare_digest(firma_calculada, firma_recibida):
        abort(401, "Firma invalida")

    # 4. Procesar el evento
    evento = request.headers.get("X-Webhook-Event")
    data = request.get_json()

    print(f"Evento recibido: {evento}")
    print(f"Datos: {data}")

    # Procesar segun el tipo de evento
    if evento == "resultados.provisionales":
        nota = data["data"]["notaFinal"]
        print(f"Nota provisional: {nota}")
    elif evento == "documento.disponible":
        doc_id = data["data"]["documentoId"]
        print(f"Nuevo documento disponible: {doc_id}")

    return "OK", 200
const crypto = require('crypto');
const express = require('express');
const app = express();

const WEBHOOK_SECRET = 'mi-secreto-compartido-seguro-12345';

// Importante: necesitamos el body como string sin parsear
app.use('/api/webhooks/tcanaria',
    express.raw({ type: 'application/json' }));

app.post('/api/webhooks/tcanaria', (req, res) => {
    // 1. Obtener la firma de la cabecera
    const firmaRecibida = req.headers['x-webhook-signature'] || '';

    // 2. Calcular la firma del body
    const firmaCalculada = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(req.body)  // body como Buffer
        .digest('hex');

    // 3. Comparar de forma segura (timing-safe)
    if (!crypto.timingSafeEqual(
        Buffer.from(firmaCalculada),
        Buffer.from(firmaRecibida)
    )) {
        return res.status(401).json({ error: 'Firma invalida' });
    }

    // 4. Procesar el evento
    const evento = req.headers['x-webhook-event'];
    const data = JSON.parse(req.body);

    console.log(`Evento: ${evento}`);
    console.log('Datos:', data);

    switch (evento) {
        case 'resultados.provisionales':
            console.log(`Nota provisional: ${data.data.notaFinal}`);
            break;
        case 'documento.disponible':
            console.log(`Nuevo documento: ${data.data.documentoId}`);
            break;
    }

    res.status(200).send('OK');
});

app.listen(3000, () => console.log('Webhook listener en puerto 3000'));
<?php
$WEBHOOK_SECRET = 'mi-secreto-compartido-seguro-12345';

// 1. Obtener la firma de la cabecera
$firmaRecibida = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

// 2. Leer el body sin parsear
$body = file_get_contents('php://input');

// 3. Calcular la firma
$firmaCalculada = hash_hmac('sha256', $body, $WEBHOOK_SECRET);

// 4. Comparar de forma segura (timing-safe)
if (!hash_equals($firmaCalculada, $firmaRecibida)) {
    http_response_code(401);
    echo json_encode(['error' => 'Firma invalida']);
    exit;
}

// 5. Procesar el evento
$evento = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$data = json_decode($body, true);

error_log("Evento recibido: $evento");

switch ($evento) {
    case 'resultados.provisionales':
        $nota = $data['data']['notaFinal'];
        error_log("Nota provisional: $nota");
        break;
    case 'documento.disponible':
        $docId = $data['data']['documentoId'];
        error_log("Nuevo documento: $docId");
        break;
}

http_response_code(200);
echo 'OK';
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/webhooks/tcanaria")]
public class WebhookController : ControllerBase
{
    private const string WebhookSecret = "mi-secreto-compartido-seguro-12345";

    [HttpPost]
    public async Task<IActionResult> RecibirWebhook()
    {
        // 1. Leer el body como string
        using var reader = new StreamReader(Request.Body);
        var body = await reader.ReadToEndAsync();

        // 2. Obtener la firma de la cabecera
        var firmaRecibida = Request.Headers["X-Webhook-Signature"]
            .FirstOrDefault() ?? "";

        // 3. Calcular la firma
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
        var firmaCalculada = Convert.ToHexString(hash).ToLowerInvariant();

        // 4. Comparar
        if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(firmaCalculada),
            Encoding.UTF8.GetBytes(firmaRecibida)))
        {
            return Unauthorized("Firma invalida");
        }

        // 5. Procesar el evento
        var evento = Request.Headers["X-Webhook-Event"].FirstOrDefault();
        var data = JsonConvert.DeserializeObject<dynamic>(body);

        Console.WriteLine($"Evento: {evento}");

        return Ok();
    }
}

Buenas prácticas de seguridad

Comparación timing-safe

Siempre use funciones de comparación timing-safe para evitar ataques de timing:

  • Python: hmac.compare_digest()
  • Node.js: crypto.timingSafeEqual()
  • PHP: hash_equals()
  • C#: CryptographicOperations.FixedTimeEquals()

Nunca compare strings directamente con == ya que es vulnerable a timing attacks.

Leer body sin parsear

Para calcular la firma correctamente, debe leer el body de la petición como string/bytes antes de parsearlo como JSON. Si el framework parsea el JSON automáticamente y luego lo re-serializa, los bytes pueden diferir y la firma no coincidirá.

Secreto seguro

Genere un secreto aleatorio de al menos 32 caracteres. Puede usar:

# Linux/macOS
openssl rand -hex 32

# Python
python3 -c "import secrets; print(secrets.token_hex(32))"