Introducción
⚠️ Importante: Todos los callbacks enviados desde nuestros servidores (callbacks de contratación y notificaciones de cambios) utilizan el mismo sistema de firma HMAC-SHA256. Es crítico validar esta firma para garantizar que las peticiones provienen realmente de Imagina Energía y no de un atacante.
📍 Aplicación
Este sistema de firma se aplica a:
- ✅ Callbacks de resultado de contratación (cuando especificas callback_url)
- ✅ Notificaciones de cambios en contratos (cuando especificas url_notificaciones_cambios_contrato)
🔐 Cómo Funciona la Firma
Proceso de Firma (en nuestros servidores)
- Timestamp: Se genera un timestamp Unix (segundos desde epoch)
- Canonicalización: El payload JSON se serializa de forma determinista (orden alfabético de claves, sin espacios)
- Construcción del mensaje: Se concatena: {timestamp}.{url}.{payload}
- Firma HMAC-SHA256: Se firma el mensaje con tu CALLBACK_SEED_KEY
- Codificación: La firma se codifica en base64url (sin padding)
📋 Headers HTTP Recibidos
| Header | Descripción | Ejemplo |
|---|---|---|
| X-Signature | Firma HMAC-SHA256 en base64url con prefijo de versión | v1=AbCd123Xyz... |
| X-Signature-Timestamp | Timestamp Unix en segundos (usado en la firma) | 1732543800 |
| X-Signature-Algorithm | Algoritmo de firma utilizado | HS256 |
| Content-Type | Tipo de contenido del body | application/json |
💻 Implementación de Validación
Python (Flask/FastAPI):
import hmac
import hashlib
import base64
import json
import os
def validar_firma_callback(request_url, payload, signature_header, timestamp):
"""
Valida la firma HMAC-SHA256 de cualquier callback/webhook.
Funciona tanto para callbacks de contratación como notificaciones de cambios.
Args:
request_url: URL completa donde se recibió el callback
payload: Dict con el body JSON recibido
signature_header: Valor del header X-Signature (ej: "v1=AbCd123...")
timestamp: Valor del header X-Signature-Timestamp
Returns:
bool: True si la firma es válida, False en caso contrario
"""
# 1. Obtener la clave secreta compartida
seed = os.getenv('CALLBACK_SEED_KEY')
if not seed:
print("❌ ERROR: CALLBACK_SEED_KEY no configurada")
return False
# 2. Validar formato del header de firma
if not signature_header or not signature_header.startswith('v1='):
print("❌ ERROR: Header X-Signature inválido o ausente")
return False
received_signature = signature_header[3:]
# 3. Reconstruir el mensaje exactamente como se firmó
canonical_payload = json.dumps(
payload,
separators=(',', ':'),
sort_keys=True,
ensure_ascii=False
)
message = f"{timestamp}.{request_url}.{canonical_payload}"
message_bytes = message.encode('utf-8')
# 4. Calcular la firma esperada usando HMAC-SHA256
key = seed.encode('utf-8')
digest = hmac.new(key, message_bytes, hashlib.sha256).digest()
# 5. Codificar en base64url (sin padding)
expected_signature = base64.urlsafe_b64encode(digest).decode('ascii').rstrip('=')
# 6. Comparación segura contra timing attacks
is_valid = hmac.compare_digest(received_signature, expected_signature)
if is_valid:
print("✅ Firma válida - Callback auténtico")
else:
print("❌ Firma inválida - Posible ataque")
return is_valid
# Ejemplo de uso en Flask
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/contratos', methods=['POST'])
def webhook_handler():
payload = request.get_json()
signature = request.headers.get('X-Signature')
timestamp = request.headers.get('X-Signature-Timestamp')
url = request.url
if not validar_firma_callback(url, payload, signature, timestamp):
return jsonify({"error": "Firma inválida"}), 401
print(f"📥 Callback recibido y validado: {payload}")
return jsonify({"status": "ok"}), 200
Node.js (Express):
const crypto = require('crypto');
function validarFirmaCallback(requestUrl, payload, signatureHeader, timestamp) {
/**
* Valida la firma HMAC-SHA256 de cualquier callback/webhook.
*/
const seed = process.env.CALLBACK_SEED_KEY;
if (!seed) {
console.error('❌ ERROR: CALLBACK_SEED_KEY no configurada');
return false;
}
if (!signatureHeader || !signatureHeader.startsWith('v1=')) {
console.error('❌ ERROR: Header X-Signature inválido');
return false;
}
const receivedSignature = signatureHeader.substring(3);
const canonicalPayload = JSON.stringify(payload, Object.keys(payload).sort());
const message = `${timestamp}.${requestUrl}.${canonicalPayload}`;
const hmac = crypto.createHmac('sha256', seed);
hmac.update(message);
const expectedSignature = hmac.digest('base64url');
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
if (isValid) {
console.log('✅ Firma válida - Callback auténtico');
} else {
console.error('❌ Firma inválida - Posible ataque');
}
return isValid;
}
// Ejemplo de uso en Express
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/contratos', (req, res) => {
const payload = req.body;
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-signature-timestamp'];
const url = req.protocol + '://' + req.get('host') + req.originalUrl;
if (!validarFirmaCallback(url, payload, signature, timestamp)) {
return res.status(401).json({ error: 'Firma inválida' });
}
console.log('📥 Callback recibido y validado:', payload);
res.status(200).json({ status: 'ok' });
});
⚠️ Consideraciones de Seguridad
- 🔑 Protege tu CALLBACK_SEED_KEY: Nunca la expongas en código público, usa variables de entorno
- ⏱️ Valida el timestamp: Rechaza callbacks con timestamps muy antiguos (>5 min) para prevenir replay attacks
- 🔒 Usa HTTPS: Tu endpoint debe usar HTTPS en producción (obligatorio)
- 🚫 No omitas la validación: Siempre valida la firma antes de procesar el callback
- 📝 Logging: Registra intentos fallidos de validación para detectar ataques
- ⚡ Comparación segura: Usa funciones de comparación constante en tiempo para prevenir timing attacks
🆘 Obtener tu CALLBACK_SEED_KEY
Para obtener tu clave secreta única (CALLBACK_SEED_KEY), contacta con nuestro equipo de soporte técnico en api.agentes@imaginaenergia.com. Esta clave es única para tu organización y se utiliza para firmar todos tus callbacks y webhooks.
⚠️ Campos Adicionales en el Payload
Los callbacks incluyen campos adicionales que pueden confundir la validación de firmas. Es fundamental entender cuáles están en la firma y cuáles NO.
📋 Campos por Tipo de Callback
| Tipo de Callback | ✅ Campos EN la Firma | ❌ Campo FUERA de la Firma |
|---|---|---|
| Notificaciones de Cambios |
• tabla• tipo_evento• timestamp• contrato• cambios• _metadata ⚠️ Incluir para validar
|
• _callback_signature⚠️ Eliminar antes de validar |
| Creditcheck Asíncrono |
• request_id• referencia_externa• result(o error en caso de fallo)
|
• _callback_signature⚠️ Eliminar antes de validar |
| Callbacks de Contrata |
En éxito: • credit_result• contrato_result• firma_resultEn error: • request_id• referencia_externa• error
|
• _callback_signature⚠️ Eliminar antes de validar |
✅ Cómo Validar Correctamente
- Recibe el payload completo:
payload = request.get_json() - Extrae y elimina
_callback_signature:payload.pop('_callback_signature', {}) - Valida la firma con el payload restante (que SÍ incluye
_metadatau otros campos) - Después de validar, procesa los datos según necesites
❌ Ejemplo de Error Común
# ❌ INCORRECTO - No eliminar _callback_signature antes de validar
payload = request.get_json()
# Esto FALLARÁ porque _callback_signature no estaba cuando se firmó
if validar_firma_callback(url, payload, signature, timestamp):
# ...
✅ Ejemplo Correcto
# ✅ CORRECTO - Eliminar _callback_signature, mantener _metadata
payload = request.get_json()
# 1. Extraer y eliminar _callback_signature (NO estaba en la firma original)
callback_sig = payload.pop('_callback_signature', {})
# 2. El payload ahora tiene EXACTAMENTE lo que se firmó originalmente
# (incluye _metadata para notificaciones, o request_id/result para creditcheck)
if not validar_firma_callback(url, payload, signature_header, timestamp):
return {"error": "Firma inválida"}, 401
# 3. Ahora puedes procesar
notification_id = payload.get('_metadata', {}).get('notification_id') # Si es notificación
procesar_cambios(payload)
🔍 ¿Por qué esta distinción?
El servidor calcula la firma antes de añadir _callback_signature al payload.
Después añade ese campo para tu conveniencia (trazabilidad), pero NO formaba parte del mensaje original firmado.
Los otros campos (_metadata, request_id, etc.) SÍ estaban presentes cuando se calculó la firma,
por lo que DEBEN estar cuando valides.