# Webhooks firmados — Guía de verificación para el SOC (firma v2, anti-replay)

IA Gobernanza entrega eventos (`killswitch.activated`, `killswitch.deactivated`, `policy.violation`, …) a las URLs
suscritas por el tenant. Cada entrega está firmada con **HMAC-SHA256** y protegida contra **reenvío (replay)**.

## Cabeceras de cada entrega

| Header | Descripción |
|---|---|
| `X-AIGuard-Event` | Tipo de evento (`killswitch.activated`, …) |
| `X-AIGuard-Delivery` | ID único de la entrega (UUID). Igual al nonce. |
| `X-AIGuard-Nonce` | Nonce único por entrega (UUID). **Rechaza si ya lo viste.** |
| `X-AIGuard-Timestamp` | Unix time (segundos) en que se firmó **este intento**. **Rechaza si \|ahora − ts\| > 300 s.** |
| `X-AIGuard-Signature-Version` | `v2` |
| `X-Hub-Signature-256` | `sha256=<hex>` = HMAC-SHA256(secret, `"{timestamp}.{nonce}.{body}"`) |

El cuerpo es JSON canónico (claves ordenadas alfabéticamente). Verifica sobre los **bytes crudos** recibidos, no sobre un
objeto re-serializado.

## Algoritmo de verificación (obligatorio)

1. Leer el cuerpo crudo `body` (bytes) y las cabeceras.
2. `ts = int(X-AIGuard-Timestamp)`; si `abs(now - ts) > 300` → **401 stale_timestamp**.
3. `expected = "sha256=" + hex(HMAC_SHA256(secret, f"{ts}.{nonce}.{body}"))`.
4. Comparar `expected` con `X-Hub-Signature-256` en **tiempo constante** → si difiere **401 bad_signature**.
5. Guardar `nonce` en un almacén con TTL ≥ 15 min (Redis `SET NX EX 900`). Si ya existía → **401 replay_detected**.
6. Responder `2xx` en < 5 s. Cualquier otro código dispara reintentos con backoff (2, 4, 8, 16, 32 s) y **nueva firma**
   con timestamp fresco (mismo nonce = mismo `X-AIGuard-Delivery`, por lo que un receptor idempotente puede deduplicar
   por `delivery id` **después** de validar la firma).

## Python (FastAPI)

```python
import hmac, hashlib, time
from fastapi import FastAPI, Request, HTTPException
import redis

app = FastAPI()
r = redis.Redis()
SECRET = b"<secret_token de la suscripción>"

@app.post("/soc/aiguard")
async def receive(req: Request):
    raw = await req.body()
    ts = req.headers.get("x-aiguard-timestamp"); nonce = req.headers.get("x-aiguard-nonce")
    sig = req.headers.get("x-hub-signature-256", "")
    if not ts or not nonce: raise HTTPException(401, "missing_timestamp_or_nonce")
    if abs(time.time() - int(ts)) > 300: raise HTTPException(401, "stale_timestamp")
    expected = "sha256=" + hmac.new(SECRET, f"{ts}.{nonce}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig): raise HTTPException(401, "bad_signature")
    if not r.set(f"aiguard:nonce:{nonce}", 1, nx=True, ex=900): raise HTTPException(401, "replay_detected")
    event = await req.json()
    # ... enrutar a SIEM / PagerDuty / Slack
    return {"received": True}
```

## Node.js (Express)

```js
import crypto from 'node:crypto'
app.post('/soc/aiguard', express.raw({ type: '*/*' }), async (req, res) => {
  const ts = req.get('x-aiguard-timestamp'), nonce = req.get('x-aiguard-nonce'), sig = req.get('x-hub-signature-256') || ''
  if (!ts || !nonce) return res.status(401).json({ reason: 'missing_timestamp_or_nonce' })
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).json({ reason: 'stale_timestamp' })
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${ts}.${nonce}.`).update(req.body).digest('hex')
  const a = Buffer.from(expected), b = Buffer.from(sig)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).json({ reason: 'bad_signature' })
  if (!(await redis.set(`aiguard:nonce:${nonce}`, 1, 'NX', 'EX', 900))) return res.status(401).json({ reason: 'replay_detected' })
  res.json({ received: true })
})
```

## Rotación del secreto

`PATCH /api/webhooks/:id` con `{ "rotate_secret": true }` (rol admin) devuelve el nuevo secreto **una sola vez**.
Durante la ventana de migración puedes validar contra el secreto anterior y el nuevo.

## Eventos

| Evento | Cuándo | Severidad |
|---|---|---|
| `killswitch.activated` | Disparo automático (voto por capas) o manual (operator+) | `critical` / `warning` |
| `killswitch.deactivated` | Reactivación por **admin** con motivo obligatorio | `info` |
| `policy.violation` | Violación de regla, `LOSS_OF_GOVERNANCE_VISIBILITY` (Dead Man's Switch) o `PII_PLAINTEXT_INGRESS` (Zero-Knowledge fail-closed) | `high` / `critical` |
