# Report 0005 — unibus: hardening de seguridad (issue 0004, fases 0004a–0004f)

- **Fecha:** 07/06/2026
- **Autor:** agente (Claude Opus 4.8)
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `membership`, `client`, `embeddednats`, `cmd/membershipd`; `deploy/tls`.
- **Estado:** done — entrega 0004a–0004f en `master` del sub-repo. Despliegue (0001f/0003f) lo ejecuta el humano.
- **Origen:** cierra los hallazgos de la auditoría red-team del report 0004 (`projects/message_bus/reports/0004-2026-06-07-unibus-security-audit.md`).

## Resumen

La auditoría 0004 concluyó que la **autenticación** del bus es sólida pero faltaban **autorización, disponibilidad y confidencialidad de metadata** — exactamente lo que un bus *público* necesita. Veredicto de la auditoría: **NO exponer público hoy** (1 crítico + 4 altos bloqueantes).

Este issue cierra esos hallazgos. Cada fase es una rama TBD propia, mergeada a `master` con `merge --no-ff` tras tests verdes, y cada una porta el test adversarial del auditor (`TestAudit_*`) como regresión que ahora arroja el resultado **seguro**.

Historia (vista `--first-parent`):

```
d483c90 Merge issue/0004f-medium-fixes  (H6/H7/H12)
957b728 Merge issue/0004e-control-tls   (H5)
0d56c3c Merge issue/0004d-dataplane-acl (H4)
47ff74d Merge issue/0004c-membership-authz (H3)
d742f91 Merge issue/0004b-failopen-guard (H2)
01e2ee1 Merge issue/0004a-dos-limit     (H1)
```

Verificación global (sin caché):

```
$ CGO_ENABLED=0 go build ./...   # OK
$ CGO_ENABLED=0 go vet ./...     # limpio
$ CGO_ENABLED=0 go test -count=1 ./...
  ok  cmd/membershipd  0.003s
  ok  pkg/busauth      0.007s
  ok  pkg/client       5.353s
  ok  pkg/frame        0.002s
  ok  pkg/membership   0.862s
```

---

## Fase 0004a — H1 (Crítico): límite de cuerpo + anti-DoS pre-auth ✅

**Hallazgo cerrado.** El middleware `Server.ServeHTTP` hacía `io.ReadAll(r.Body)` sin límite y antes de `authenticate()`; `handlePutBlob` repetía el `io.ReadAll`. Un atacante sin credenciales forzaba buffering ilimitado en RAM (400 MB → 898 MB RSS según el auditor).

**Fix.**
- `http.MaxBytesReader` en el middleware **antes** del `io.ReadAll`, con ceiling por ruta: 1 MiB para JSON de control, 16 MiB para `/blobs`. Un `Content-Length` declarado por encima del ceiling se rechaza **sin bufferizar un solo byte**; un sender que miente (chunked) trip­ea `MaxBytesReader` al llegar al ceiling.
- `handlePutBlob` mapea el corte a 413 en todos los modos de auth.
- Rate-limit por IP (token-bucket `golang.org/x/time/rate`, ya en el module graph; mantenido en-paquete como glue de transporte, igual que el `nonceCache` en 0003) que descarta floods antes de auth y de leer el body.
- `http.Server.MaxHeaderBytes` + `ReadHeaderTimeout`.

**Evidencia (regresión, RSS acotado):**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_DoSBodyLimitNoAuth|TestBlobLimit|TestControlBodyLimit|TestRateLimitPerIP' -v
  --- PASS: TestAudit_DoSBodyLimitNoAuth   (cuerpo 400 MiB sin firma -> 413; delta RSS < 96 MiB vs 400 MB+ del ataque)
  --- PASS: TestBlobLimitGoldenAndBoundary (blob normal -> 200; exactamente en el ceiling -> 200; 1 byte sobre -> 413)
  --- PASS: TestControlBodyLimit           (cuerpo control > 1 MiB -> 413)
  --- PASS: TestRateLimitPerIP             (flood de una IP -> 429; IPs distintas no se estrangulan)
```

**Gaps.** El blob se sigue bufferizando en RAM hasta 16 MiB (la firma cubre `sha256(body)`, hay que leer todo). El streaming a disco que sugería el auditor queda fuera de alcance (encaja con la cuota/GC de blobs del issue 0002).

---

## Fase 0004b — H2 (Alto): cerrar el fail-open de configuración ✅

**Hallazgo cerrado.** Default `--bus-auth off`; el nkey solo se activa en `enforce`; TLS era flag independiente. `--bind 0.0.0.0 --tls-cert …` sin `enforce` dejaba el bus abierto con apariencia de seguro.

**Fix.** `validateBootConfig` (función pura, llamada tras el flag parse) hace `log.Fatal` ante dos formas inseguras: bind no-loopback sin `enforce`, y `--tls-cert/--tls-key` sin `enforce`. Un arranque inseguro es imposible (el proceso sale).

**Evidencia:**

```
$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestAudit_FailOpenTLSWithoutAuth|TestBootConfigPolicy' -v
  --- PASS: TestAudit_FailOpenTLSWithoutAuth  (TLS sin enforce -> rechazado; el cliente no tiene a qué conectarse)
  --- PASS: TestBootConfigPolicy              (12 casos: public+enforce OK, loopback dev OK, public/LAN/empty sin enforce y TLS-sin-enforce rechazados)

$ ./membershipd --bind 0.0.0.0 --bus-auth off
  refusing to start: --bind "0.0.0.0" is not loopback but --bus-auth is "off"; a public bind requires --bus-auth enforce (or bind 127.0.0.1 for local dev)
```

**Gaps.** El guard clasifica como "público" cualquier hostname que no resuelva a un literal loopback (conservador). mTLS (client certs) como segunda barrera no se implementó (no estaba en alcance).

---

## Fase 0004c — H3 (Alto): autorización por pertenencia en el control plane ✅

**Hallazgo cerrado.** "Autorizado" significaba "registrado", no "miembro". Los GET de room exponían subject, lista de miembros (con `sign_pub`+`kex_pub`), el directorio de rooms de cualquiera y la `sealed_key` ajena a cualquier registrado.

**Fix.** El middleware propaga el endpoint del firmante autenticado al handler vía `context`. Los handlers de room exigen pertenencia: `GET /rooms/{id}` y `/rooms/{id}/members` requieren ser miembro; `/rooms/{id}/key` sirve la clave sellada **solo** a su propio endpoint (y solo a un miembro); `/members/{endpoint}/rooms` se restringe al propio endpoint del firmante. La autorización se salta solo cuando no hay firmante autenticado (AuthOff dev / pass-through soft), preservando el comportamiento legacy.

**Evidencia:**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_HorizontalMetadataLeak|TestAuth' -v
  --- PASS: TestAudit_HorizontalMetadataLeak  (bob no-miembro -> 403 en room/members/directorio/clave; alice owner y carol miembro -> 200; carol solo su propia clave)
  --- PASS: TestAuthGoldenAccepted / Replay / ClockSkew / TamperedBody / MissingHeaders / HealthExempt / Soft / Off  (suite de auth intacta)
```

El flujo de membresía legítimo sigue verde (`TestSecureBusEndToEnd`: A invita B, B accede a su room/clave bajo enforce).

**Gaps.** Ninguno relevante para el hallazgo. La autorización se evalúa por request contra SQLite (fail-closed ante error de query).

---

## Fase 0004d — H4 (Alto): control de acceso en el data plane NATS ✅ (mínimo defensivo + documentado)

**Hallazgo.** El authenticator nkey solo decide "registrado sí/no"; no hay permisos por subject. Cualquier registrado se suscribe/publica en cualquier subject; las rooms `ModeNATS` (cleartext) quedan expuestas.

**Estrategia elegida y su límite** (documentada en `apps/unibus/dev/0004d-dataplane-acl.md`). La ACL por subject completa derivada de pertenencia **no cabe** aquí: NATS evalúa los permisos una vez al conectar y no los re-evalúa, pero los clientes de unibus hacen *connect → create/join → publish* en la **misma** conexión (`TestSecureBusEndToEnd`). Permisos estáticos prohibirían al propio owner publicar en la room que acaba de crear; el modelo de reconexión dinámica pertenece al rediseño de sesión del issue 0003. Por eso se implementa el **mínimo defensivo** que el issue autoriza:

**Fix.** `Server.RequireEncryptedRooms` (que `membershipd` activa en cualquier bind no-loopback) rechaza crear rooms cleartext. En despliegue público **toda room es E2E**, así que el contenido de los mensajes permanece confidencial aunque el transporte no aísle subjects: un sniffer recibe solo ciphertext AEAD sin clave.

**Evidencia:**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestRequireEncryptedRoomsRejectsCleartext -v
  --- PASS  (cleartext create -> 403; encrypted -> 201; flag off -> cleartext permitido de nuevo)

$ CGO_ENABLED=0 go test ./pkg/client/ -run TestAudit_NoSubjectACL -v
  --- PASS  (postura pública: ModeNATS rechazada; bob miembro descifra "internal: salary numbers";
            eve se suscribe RAW al subject del data plane y recibe SOLO ciphertext — nonce AEAD no vacío,
            sin el plaintext — cerrando el "eve lee internal: salary numbers" del auditor)
```

**Gaps (residual, por diseño, tracked para 0003).** Un registrado que ya conoce un subject puede observar que está activo, los tamaños de ciphertext y la cadencia (metadata de tráfico), y puede *publicar* bytes arbitrarios (que fallan AEAD/firma y se descartan: spam, no break de confidencialidad/integridad). La ACL por subject real requiere el modelo de reconexión dinámica de 0003.

---

## Fase 0004e — H5 (Alto, público): TLS en el control plane ✅

**Hallazgo cerrado.** El HTTP `:8470` iba firmado pero **sin TLS** → toda la metadata (subjects, endpoints, pubkeys, sealed keys, hashes, grafo social) legible por un MITM.

**Fix.** `membershipd` sirve el control plane sobre TLS (`ListenAndServeTLS`, MinVersion 1.2) con el mismo cert firmado por la CA propia que el data plane, cuando `--tls-cert` está presente (el guard 0004b ya exige `enforce` con esos flags). El cliente recibe `Options.CtrlTLS` separado para pinear la CA en el `http.Client`, independiente del TLS de NATS. `Connect` setea ambos planos desde la única CA y **rechaza** un control-plane `http://` cuando se le pasa una CA (la firma da integridad, no confidencialidad).

**Evidencia (regresión + runtime):**

```
$ CGO_ENABLED=0 go test ./pkg/client/ -run 'TestConnectRequiresHTTPSWithCA|TestControlPlaneOverTLS' -v
  --- PASS: TestConnectRequiresHTTPSWithCA  (CA + http:// -> error que apunta a https)
  --- PASS: TestControlPlaneOverTLS         (con la CA pineada -> request OK; sin la CA -> handshake falla)

$ ./membershipd --bind 127.0.0.1 --bus-auth enforce --tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key ...
  [membershipd] HTTPS control-plane API: https://127.0.0.1:18473
  [membershipd] control-plane TLS: ON (deploy/tls/server.crt)
$ curl --cacert deploy/tls/ca.crt -o /dev/null -w '%{http_code}' https://127.0.0.1:18473/healthz   # 200
$ curl http://127.0.0.1:18473/healthz                                                              # 400 + log "client sent an HTTP request to an HTTPS server"
```

**Gaps.** `Connect` mantiene su firma; los binarios `worker`/`chat` (flag `--ca`) y `mobile.NewSession` deben pasar una URL de control-plane `https://` cuando pasan CA — anotado para el deploy. No se reescribió `playground/` ni `mobile/` (fuera de alcance; el binding mobile no cambió de firma).

---

## Fase 0004f — medios: owner binding + nonce-cache + error leak (H6/H7/H12) ✅

**H6 (owner spoof).** `handleCreateRoom` liga ahora el owner declarado al firmante autenticado: el endpoint id **y** la clave de firma deben ser los del firmante. Un registrado ya no crea rooms a nombre de otra identidad.

**H7 (nonce-cache poison pre-auth).** `IsAuthorized` corre **antes** de tocar el `nonceCache`, así que una identidad no registrada (las claves Ed25519 son gratis) ya no siembra nonces. El cache se reescribió con poda O(expired) — bajo TTL constante el orden de inserción es el de expiración, así que la cola FIFO poda solo lo caducado, en vez del scan O(n) sobre todo el mapa bajo el mutex — más un cap de tamaño con evicción del más viejo. Es el prerequisito del nonce-store replicado del issue 0003.

**H12 (error leak).** Los errores internos de store/blob se loguean y se sustituyen por un mensaje genérico al cliente vía `writeServerErr`; ya no se filtran fragmentos SQL ni rutas. Los 4xx con mensaje crafteado (owner-sig, validación) se conservan.

**Evidencia:**

```
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAudit_OwnerSpoof|TestAudit_NonceCachePoisonPreAuth|TestNonceCache' -v
  --- PASS: TestAudit_OwnerSpoof              (owner endpoint o clave ajenos -> 403; self-owned -> 201)
  --- PASS: TestAudit_NonceCachePoisonPreAuth (no-registrado repite nonce -> sigue "not authorized", nunca "replayed" => no se cacheó;
                                               replay de identidad AUTORIZADA -> sigue rechazado "replayed nonce")
  --- PASS: TestNonceCacheRememberPrune        (acepta nuevo, rechaza replay, re-acepta tras TTL)
  --- PASS: TestNonceCacheCapBounded           (500 inserts, TTL largo -> cache acotado al cap; order/seen sin drift)
```

**Gaps.** Bajo presión de cap, evictar el nonce más viejo (TTL casi cumplido) podría, en una ventana muy estrecha, permitir el replay de ese nonce concreto; mitigado porque solo se cachea tráfico autorizado (post-H7) y el rate-limit (0004a) estrangula. La firma de owner del payload (invite/rekey) sigue sin nonce/ts propios (H11): cubierta en la práctica por el envelope `enforce`; reforzar si se relaja `enforce`.

---

## Cobertura de tests (DoD)

Cada fase aporta **golden + ≥2 edge + ≥1 error path** con evidencia ejecutable, y porta el `TestAudit_*` correspondiente:

| Hallazgo | Test adversarial portado | Paquete |
|---|---|---|
| H1 | `TestAudit_DoSBodyLimitNoAuth` | `pkg/membership` |
| H2 | `TestAudit_FailOpenTLSWithoutAuth` | `cmd/membershipd` |
| H3 | `TestAudit_HorizontalMetadataLeak` | `pkg/membership` |
| H4 | `TestAudit_NoSubjectACL` | `pkg/client` |
| H6 | `TestAudit_OwnerSpoof` | `pkg/membership` |
| H7 | `TestAudit_NonceCachePoisonPreAuth` | `pkg/membership` |

---

## Fuera de alcance (encolado en otros issues)

- **H9** (cuota/GC de blobs) → issue 0002 (media v2).
- **H10** (AEAD nonce 12B → XChaCha o rekey por volumen) → futuro, issue propio si se necesitan rooms de muy alto volumen.
- **H11** (firma de owner sin nonce/ts) → cubierto por el envelope `enforce`; documentado.
- **H8** (custodia de la CA: generar en om, `ca.key` fuera del PC) → operacional del deploy.
- **ACL por subject completa en el data plane** (residual de H4) → issue 0003 (requiere reconexión dinámica del cliente / permisos replicados).
- **govulncheck** sobre nats-server/nats.go/modernc → paso de CI aparte.

---

## Veredicto re-evaluado

La auditoría 0004 dijo **"NO exponer público"** por 1 crítico + 4 altos. Tras este hardening:

- **H1 (DoS)** cerrado: cuerpos acotados + rate-limit; RSS no se dispara (regresión con medición real).
- **H2 (fail-open)** cerrado: arranque público inseguro imposible.
- **H3 (autz horizontal)** cerrado: metadata de room solo para miembros; clave sellada solo para su dueño.
- **H4 (data plane)** mitigado al mínimo defensivo: contenido siempre E2E en público; ACL por subject documentada y diferida a 0003.
- **H5 (MITM control plane)** cerrado: control plane sobre TLS con CA propia; cliente exige https.
- Medios **H6/H7/H12** cerrados.

**Veredicto: de "NO" a "SÍ, con condiciones operacionales".** El bus es seguro para exposición pública **si** se cumplen, en el deploy:

1. **`--bus-auth enforce` + `--tls-cert/--tls-key`** (el guard ya lo fuerza en bind público; con bind público las rooms quedan E2E-only automáticamente).
2. **`Restart=always`** en el unit systemd (un SIGTERM limpio es exit 0; `on-failure` no reiniciaría — gotcha conocido del ecosistema).
3. **CA generada y custodiada fuera del host de aplicación** (`ca.key` offline / en `pass`); el `ca.crt` commiteado se trata como CA de dev (H8, operacional).
4. **Clientes recompilados** contra este `pkg/client` y conectando con `client.Connect(...ca.crt)` y URL de control-plane `https://`.

Condición residual aceptada: en el data plane, un peer registrado puede observar metadata de tráfico (no contenido) y hacer spam descartado; la ACL por subject real llega con el issue 0003. Para un despliegue **solo-WireGuard** ese residual es irrelevante (la red ya restringe).

---

## Gaps / pendientes honestos de este trabajo

- **No se midió el DoS bajo concurrencia real** (N conexiones de varios GB): la regresión demuestra el cap de RAM con requests in-process (medición `/proc/self/status`), no el OOM bajo carga concurrente.
- **El blob se bufferiza hasta 16 MiB en RAM** (necesario por la firma sobre `sha256(body)`); el streaming a disco queda para 0002.
- **No se ejecutó `govulncheck`** sobre las dependencias NATS/sqlite (paso de CI aparte).
- **`playground/`, `mobile/`, unibots** no migrados: un cliente que conecte en claro/sin nkey reabre superficie; deben recompilarse contra este `pkg/client` y pasar `https://` + `ca.crt`.
- La estrategia de **0004d es deliberadamente el mínimo defensivo**; la confidencialidad de contenido está garantizada, la de metadata de tráfico no — explícito y tracked para 0003.
