"""Template — validador HMAC-SHA256 para webhooks de Orchestrate. Cuando WxO te manda un webhook (run lifecycle, completion, etc.), valida la firma para descartar requests no autorizados. Headers que WxO envía: X-Orchestrate-Timestamp (unix ts, ms) X-Orchestrate-Nonce (string aleatorio) X-Orchestrate-Signature (hex de HMAC-SHA256) La firma se calcula sobre: HMAC-SHA256(secret, f"{timestamp}.{nonce}.{body}") Reglas: - Tolerar skew de timestamp de 300s (HTTP 408 si stale) - Rechazar nonces repetidos (replay protection — guardar últimos 10k) - HTTP 401 si firma no matchea Origen: Dun `backend/app/orchestrate/webhook_validator.py`. """ from __future__ import annotations import hashlib import hmac import time from collections import deque from threading import Lock from typing import Optional class WebhookValidator: def __init__(self, secret: str, skew_seconds: int = 300, nonce_cache_size: int = 10_000): self._secret = secret.encode("utf-8") self._skew = skew_seconds self._nonces: deque[str] = deque(maxlen=nonce_cache_size) self._nonces_set: set[str] = set() self._lock = Lock() def validate(self, timestamp: str, nonce: str, signature: str, body: bytes) -> Optional[str]: """Returns None if valid, else an error code: 'stale' | 'replay' | 'badsig'.""" # ── 1. Timestamp freshness try: ts_ms = int(timestamp) except ValueError: return "badsig" now_ms = int(time.time() * 1000) if abs(now_ms - ts_ms) > self._skew * 1000: return "stale" # ── 2. Nonce replay with self._lock: if nonce in self._nonces_set: return "replay" # ── 3. Signature msg = f"{timestamp}.{nonce}.".encode("utf-8") + body expected = hmac.new(self._secret, msg, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return "badsig" # Valid — register nonce with self._lock: if len(self._nonces) >= self._nonces.maxlen: # deque is bounded; rotate set in sync expired = self._nonces[0] self._nonces_set.discard(expired) self._nonces.append(nonce) self._nonces_set.add(nonce) return None # Ejemplo de uso en FastAPI: # # from fastapi import APIRouter, Request, Header, HTTPException # from .webhook_validator import WebhookValidator # # router = APIRouter() # _validator = WebhookValidator(secret=os.environ["WEBHOOK_SECRET"]) # # @router.post("/webhooks/orchestrate") # async def orch_webhook( # request: Request, # x_orchestrate_timestamp: str = Header(...), # x_orchestrate_nonce: str = Header(...), # x_orchestrate_signature: str = Header(...), # ): # body = await request.body() # err = _validator.validate( # x_orchestrate_timestamp, # x_orchestrate_nonce, # x_orchestrate_signature, # body, # ) # if err == "stale": # raise HTTPException(status_code=408, detail="timestamp skew") # if err == "replay": # raise HTTPException(status_code=409, detail="nonce reused") # if err == "badsig": # raise HTTPException(status_code=401, detail="bad signature") # # ... handle payload