# Seguridad de webhooks Fuente: /webhooks/webhooks-security/ Cada webhook que envía Verificamex Firma va **firmado**. Validar esa firma te asegura tres cosas: - **Lo envió Verificamex Firma**, y no alguien que conoce la URL de tu webhook. - **Nadie modificó el contenido** en el camino. - **No es un aviso viejo** que alguien capturó y volvió a enviar. No necesitas activar nada: la firma viene en todos los avisos. Validarla es opcional, pero te lo recomendamos en cualquier integración en producción, sobre todo si un aviso dispara acciones importantes en tu sistema (liberar un pago, activar una cuenta, entregar un producto). Aplica a todos los webhooks: los de [documentos](/webhooks/document-webhooks) y los de contenedores. *** ### 1. Obtén tu clave de webhooks 1. Inicia sesión en el panel de Verificamex Firma. 2. Entra a **Configuración → API desarrolladores**. 3. En la sección **Clave de webhooks**, presiona **Copiar**. La clave empieza con `whsec_` seguida de 64 caracteres. Se genera sola la primera vez; no hay que solicitarla. > **Trátala como una contraseña** > > Guárdala en una variable de entorno o en el gestor de secretos de tu servidor. Nunca la pongas en código que se ejecute en el navegador ni la subas a tu repositorio. #### ¿Con qué clave se firma cada aviso? Cada cuenta tiene su propia clave, y es distinta de tu API key. - **Documentos:** se firman con la clave de la **cuenta que creó el documento**, es decir, la dueña de la API key con la que hiciste la petición. Si trabajas en una carpeta compartida, usa tu propia clave, no la del dueño de la carpeta. - Si el documento no tiene registrado quién lo creó, se usa la clave de la cuenta dueña de la carpeta de trabajo. - **Contenedores:** se firman con la clave de la cuenta dueña del contenedor. *** ### 2. Qué trae cada aviso Además del cuerpo JSON, cada aviso trae estos encabezados: | Encabezado | Ejemplo | Qué es | | :- | :- | :- | | `X-Verificamex-Firma-Signature` | `t=1760306642,v1=672644ed…01fe1097` | `t` es el momento del envío en segundos Unix. `v1` es la firma: un HMAC-SHA256 en hexadecimal (64 caracteres en minúsculas). | | `X-Verificamex-Firma-Event-Id` | `ac110005-a022-1c20-81a0-22ba06f60011` | Identificador del aviso. Es el mismo en todos los reintentos de un aviso, así que te sirve para detectar duplicados. | | `Content-Type` | `application/json` | Formato del cuerpo. | *** ### 3. Valida la firma 1. **Lee el cuerpo tal cual llegó**, como texto o bytes, **antes** de convertirlo a JSON. 2. **Separa `t` y `v1`** del encabezado `X-Verificamex-Firma-Signature`. 3. **Arma el texto firmado:** el valor de `t`, un punto y el cuerpo. Por ejemplo: `1760306642.{"id":"…","signed":3,…}`. 4. **Calcula un HMAC-SHA256** de ese texto usando tu clave **completa**, incluido el `whsec_`, como llave. Exprésalo en hexadecimal. 5. **Compara tu resultado con `v1`** usando una comparación de tiempo constante (las funciones de los ejemplos ya lo hacen). 6. **Revisa la antigüedad:** si `t` tiene más de 5 minutos de diferencia con tu hora actual, descarta el aviso. 7. Si todo coincide, responde `2xx` y procesa el aviso. Si no, responde `401` y descártalo. > **Usa el cuerpo original, no uno reconstruido** > > Si tu framework ya convirtió el cuerpo a JSON y lo vuelves a convertir a texto, el resultado puede cambiar (espacios, orden de los campos, acentos) y la firma no va a coincidir. Firma siempre los bytes exactos que recibiste. > **Mantén la hora de tu servidor sincronizada** > > La revisión de antigüedad compara `t` con la hora de tu servidor. Si tu reloj está desfasado varios minutos, rechazarás avisos legítimos. Usa la sincronización automática de hora (NTP), que la mayoría de los servidores ya trae activada. *** ### Ejemplos Cada ejemplo trae una función de validación que puedes copiar tal cual, y su uso en un servidor. **Node.js** ```js const crypto = require('crypto'); const TOLERANCE_SECONDS = 300; // 5 minutos function verifyVerificamexFirmaSignature(rawBody, signatureHeader, secret) { if (!signatureHeader) return false; const parts = Object.fromEntries( signatureHeader.split(',').map((part) => part.split('=')) ); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || !parts.v1) return false; // Rechaza avisos viejos que alguien haya capturado y reenviado. if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) // el cuerpo tal cual llegó (Buffer o string) .digest(); const received = Buffer.from(parts.v1, 'hex'); return received.length === expected.length && crypto.timingSafeEqual(received, expected); } ``` Con Express, usa `express.raw` en la ruta del webhook para recibir el cuerpo sin convertir: ```js const express = require('express'); const WEBHOOK_SECRET = process.env.VERIFICAMEX_FIRMA_WEBHOOK_SECRET; // whsec_... const app = express(); app.post('/webhooks/verificamex-firma', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.get('X-Verificamex-Firma-Signature'); if (!verifyVerificamexFirmaSignature(req.body, signature, WEBHOOK_SECRET)) { return res.sendStatus(401); } const document = JSON.parse(req.body.toString('utf8')); const eventId = req.get('X-Verificamex-Firma-Event-Id'); res.sendStatus(200); // Procesa `document`. Usa `eventId` para ignorar avisos repetidos. }); ``` **Python** ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # 5 minutos def verify_verificamex_firma_signature(raw_body: bytes, signature_header: str | None, secret: str) -> bool: if not signature_header: return False parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part) try: timestamp = int(parts["t"]) received = parts["v1"] except (KeyError, ValueError): return False # Rechaza avisos viejos que alguien haya capturado y reenviado. if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received) ``` Con Flask, `request.get_data()` te da el cuerpo sin convertir: ```python import os from flask import Flask, abort, request WEBHOOK_SECRET = os.environ["VERIFICAMEX_FIRMA_WEBHOOK_SECRET"] # whsec_... app = Flask(__name__) @app.post("/webhooks/verificamex-firma") def verificamex_firma_webhook(): signature = request.headers.get("X-Verificamex-Firma-Signature") if not verify_verificamex_firma_signature(request.get_data(), signature, WEBHOOK_SECRET): abort(401) document = request.get_json() event_id = request.headers.get("X-Verificamex-Firma-Event-Id") # Procesa `document`. Usa `event_id` para ignorar avisos repetidos. return "", 200 ``` **PHP** ```php TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } ``` `php://input` te da el cuerpo sin convertir: ```php parts = new HashMap<>(); for (String part : signatureHeader.split(",")) { String[] keyValue = part.split("=", 2); if (keyValue.length == 2) { parts.put(keyValue[0], keyValue[1]); } } long timestamp; try { timestamp = Long.parseLong(parts.get("t")); } catch (NumberFormatException e) { return false; } String received = parts.get("v1"); // Rechaza avisos viejos que alguien haya capturado y reenviado. if (received == null || Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE_SECONDS) { return false; } try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); String expected = HexFormat.of().formatHex(mac.doFinal(rawBody)); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), received.getBytes(StandardCharsets.UTF_8) ); } catch (Exception e) { return false; } } } ``` Con Spring, recibe el cuerpo como `byte[]` para tenerlo sin convertir. Requiere Java 17 o superior por `HexFormat`. ```java @PostMapping("/webhooks/verificamex-firma") public ResponseEntity verificamexFirmaWebhook( @RequestBody byte[] rawBody, @RequestHeader(value = "X-Verificamex-Firma-Signature", required = false) String signature, @RequestHeader(value = "X-Verificamex-Firma-Event-Id", required = false) String eventId) { if (!VerificamexFirmaWebhookVerifier.verify(rawBody, signature, webhookSecret)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // Convierte rawBody a JSON con tu librería (por ejemplo, Jackson) y procésalo. // Usa eventId para ignorar avisos repetidos. return ResponseEntity.ok().build(); } ``` *** ### Prueba tu implementación Con estos datos, tu cálculo del paso 4 debe dar exactamente el valor de `v1`: | Dato | Valor | | :- | :- | | Clave | `whsec_test` | | `t` | `1760306642` | | Cuerpo | `{"id":"doc-1","signed":3}` | | `v1` esperado | `672644edb5a94127822b5390b7714887895895d01e320718872dbb4c01fe1097` | También puedes calcularlo desde la terminal: ```bash printf '%s' '1760306642.{"id":"doc-1","signed":3}' | openssl dgst -sha256 -hmac 'whsec_test' ``` > **Nota** > > Este `t` es de una fecha pasada, así que la revisión de antigüedad del paso 6 lo rechazará. Úsalo solo para comprobar el cálculo de la firma. *** ### Reintentos y avisos repetidos - **Cada reintento se firma de nuevo**, con un `t` actualizado. Un reintento legítimo nunca se rechaza por antigüedad. - **`X-Verificamex-Firma-Event-Id` se repite** en los reintentos de un mismo aviso. Guárdalo y, si ya lo procesaste, responde `200` sin volver a procesarlo. - Si respondes con un error, por ejemplo `401` por firma inválida, el aviso se reintenta hasta completar **3 intentos en total**. *** ### Regenerar la clave Si crees que tu clave se filtró, genera una nueva en **Configuración → API desarrolladores → Regenerar clave de webhooks**. > **La clave anterior deja de funcionar al instante** > > Desde ese momento, todos los avisos se firman con la clave nueva. Actualízala en tu servidor enseguida. Si tu sistema rechazó algún aviso mientras tanto, se reintentará en los siguientes minutos, ya firmado con la clave nueva, siempre que no haya agotado sus 3 intentos.