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
- Leer el body completo de la petición como string (sin parsear JSON)
- Calcular el HMAC-SHA256 del body usando el secreto compartido
- Convertir el resultado a hexadecimal en minusculas
- Comparar con el valor de la cabecera
X-Webhook-Signature - 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á.