From bcd02716d56bf48a68063075df6dbffa54b8d857 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 7 Jun 2026 14:04:33 +0200 Subject: [PATCH] =?UTF-8?q?docs(issues):=20encolar=200002=20(media=20v2),?= =?UTF-8?q?=200003=20(descentralizaci=C3=B3n=20HA),=200004=20(hardening=20?= =?UTF-8?q?seguridad)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs de los tres issues siguientes del bus, derivados de esta sesión: - 0002 media v2: chunking, mimetype, GC del object store, exponer en clientes. - 0003 descentralización/HA: cluster NATS magnus+homer (R1→R3), control plane SQLite→JetStream KV, quorum, failover. Tercer nodo = homer (141.94.69.66). - 0004 hardening: cierra los hallazgos de la auditoría red-team (report 0004): DoS pre-auth, fail-open, autorización por pertenencia, ACL NATS, TLS control plane. --- dev/issues/0002-media-v2.md | 146 ++++++++++++++++++ dev/issues/0003-decentralization-ha.md | 195 +++++++++++++++++++++++++ dev/issues/0004-security-hardening.md | 144 ++++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 dev/issues/0002-media-v2.md create mode 100644 dev/issues/0003-decentralization-ha.md create mode 100644 dev/issues/0004-security-hardening.md diff --git a/dev/issues/0002-media-v2.md b/dev/issues/0002-media-v2.md new file mode 100644 index 0000000..509e85e --- /dev/null +++ b/dev/issues/0002-media-v2.md @@ -0,0 +1,146 @@ +--- +issue: 0002 +title: Media v2 — archivos grandes (chunking), metadata, GC del object store, exponer en clientes +status: spec +created: 2026-06-07 +domain: media +scope: unibus (pkg/blobstore, pkg/frame, pkg/client, pkg/membership) + clientes (mobile binding, gateway web, unibots) +depends_on: 0001 (la auth firmada del control plane debe cubrir /blobs antes de exponer media) +--- + +# Objetivo + +El envío de archivos (imágenes, audio, vídeo) ya funciona en v1, pero con límites +que lo hacen inviable para vídeo grande y poco usable para los clientes. Este issue +lleva la media a un estado de producción: archivos grandes por chunks, metadata de +tipo/nombre, recolección de basura del object store, y exposición en los frontends. + +# Contexto — cómo funciona media v1 (hoy) + +`PublishMedia(roomID, data []byte)` cifra el archivo **entero** con la clave de la +room (`SealAEAD`), lo sube **entero** al object store (`pkg/blobstore`, +content-addressed por hash) vía el control plane (`POST /blobs`), y publica por el +bus solo una referencia `frame.BlobRef{Hash, Nonce, Size}`. `FetchMedia` baja el +ciphertext por hash (`GET /blobs/{hash}`) y lo descifra. El binario nunca viaja por +NATS; el bus solo lleva la referencia. El object store guarda solo ciphertext (E2E +real). Es correcto y simple, pero: + +| Limitación v1 | Consecuencia | +|---|---| +| Todo el archivo en RAM (cifra y sube de una vez) | imágenes/audio OK; vídeo grande (cientos MB/GB) revienta memoria | +| `BlobRef` solo lleva hash+nonce+size | el receptor no sabe mimetype/filename; no puede renderizar bien | +| Sin resumable | si falla la subida de un archivo grande, reempezar de cero | +| Object store sin GC | blobs content-addressed crecen indefinidamente, sin refcount ni TTL | +| `mobile/` solo expone `Publish` (texto) | no se puede enviar una foto desde el móvil | +| Gateway web sin endpoints de media | la SPA no sube/baja archivos | + +Fuera de alcance de este issue (sería otro): **streaming en vivo** (videollamada, +audio en tiempo real) — eso no es modelo blob, requiere WebRTC señalizado por el bus. + +# Diseño + +## Pieza 1 — Chunking de archivos grandes + +Partir el archivo en chunks de tamaño fijo (propuesta: 4 MB), cifrar **cada chunk** +de forma independiente con la clave de la room (nonce por chunk), y subir cada chunk +como un blob propio (content-addressed). La referencia pasa de un solo blob a un +manifiesto de chunks. + +- `frame.BlobRef` evoluciona (de forma compatible) a soportar lista de chunks: + ``` + BlobRef{ + Hash string // hash del manifiesto (o del blob único si no hay chunks) + Nonce []byte // nonce del manifiesto / del blob único + Size int64 // tamaño total en claro + Chunks []ChunkRef // vacío en archivos pequeños (camino v1 intacto) + } + ChunkRef{ Hash string; Nonce []byte; Size int64 } // por chunk cifrado + ``` +- `PublishMediaStream(roomID string, r io.Reader, meta MediaMeta) (BlobRef, error)`: + lee del `io.Reader` en chunks (no carga el archivo entero en RAM), cifra y sube + cada chunk, y construye el manifiesto. El `PublishMedia([]byte)` v1 se mantiene + como atajo para archivos pequeños (sin chunks). +- `FetchMediaStream(roomID, BlobRef) (io.ReadCloser, error)`: baja y descifra chunks + bajo demanda, exponiendo un `io.Reader` (descarga progresiva, no todo en RAM). +- Subida/descarga de chunks en paralelo acotado (p. ej. 4 a la vez) para throughput. + +## Pieza 2 — Metadata (mimetype + filename) + +Añadir a `BlobRef` (o a un sidecar cifrado) los campos `Mime string` y `Name +string`, de modo que el receptor sepa renderizar (imagen inline, reproductor de +audio/vídeo, icono de descarga). Como `Name`/`Mime` pueden ser sensibles, viajan +**dentro del campo cifrado** del frame, no en claro. Detección de mimetype por +sniffing del primer chunk + extensión. + +## Pieza 3 — Garbage collection del object store + +Hoy los blobs no se borran nunca. Introducir refcount o barrido: + +- **Refcount por referencia**: una tabla `blob_refs(hash, room_id, msg_id)` en el + control plane; al expirar un mensaje de una room efímera o al purgar historial de + una room persistente, decrementar y borrar el blob cuando llega a cero. +- **Alternativa TTL**: blobs de rooms efímeras con TTL; blobs de rooms persistentes + viven mientras viva el mensaje en JetStream. +- Comando `membershipd blobs gc [--dry-run]` para barrido manual + métrica de + espacio. Debe ser idempotente y seguro (nunca borrar un blob aún referenciado). + +## Pieza 4 — Exponer media en los clientes + +- **Binding móvil** (`mobile/unibus.go`): `SendFile(roomID, path, mime)` y + `FetchFile(roomID, frameJSON) -> path` (escribe a un archivo local del sandbox de + la app y devuelve la ruta; no pasa []byte grandes por el puente gomobile). +- **Gateway web** (`playground/server.go`): `POST /api/media` (multipart, streaming + al store) y `GET /api/media/{room}/{hash}` (descarga descifrada con los headers + `Content-Type`/`Content-Disposition` derivados de la metadata). +- **unibots**: una tool `send_file` para que un bot pueda adjuntar archivos. + +# Decisiones técnicas + +| Decisión | Elegido | Alternativa | Razón | +|---|---|---|---| +| Tamaño de chunk | 4 MB | 1 MB / 16 MB | equilibrio RAM vs overhead de manifiesto | +| Cifrado por chunk | nonce independiente por chunk, misma clave de room | re-cifrar todo | permite descarga/borrado parcial y paralelismo | +| Metadata sensible | dentro del frame cifrado | en claro en BlobRef | filename/mime pueden filtrar info | +| GC | refcount en control plane | solo TTL | preciso, no borra lo aún referenciado | +| Compatibilidad v1 | `Chunks` vacío = camino v1 | romper formato | no romper media ya enviada | + +# Fases (TBD, ramas `issue/0002x-*`) + +1. **0002a — BlobRef con chunks (compatible)** — extender el tipo + tests de + marshalling con `Chunks` vacío (v1) y con chunks (v2). Sin cambiar clientes aún. +2. **0002b — PublishMediaStream / FetchMediaStream** — API de streaming en + `pkg/client` sobre `io.Reader`/`io.ReadCloser`, cifrado por chunk, subida/descarga + paralela acotada. Tests con un archivo > tamaño de chunk. +3. **0002c — metadata mime+name** (en el campo cifrado) + sniffing. +4. **0002d — GC del object store** — refcount + `membershipd blobs gc` + tests de + "no borrar referenciado / borrar huérfano". +5. **0002e — exponer en clientes** — binding móvil (`SendFile`/`FetchFile`), gateway + web (`/api/media`), tool `send_file` en unibots. + +# Definition of Done (evidencia ejecutable) + +- **Golden:** enviar y recibir una imagen pequeña (camino v1, sin chunks) sigue + funcionando; enviar y recibir un archivo de 50 MB por chunks sin cargar 50 MB en + RAM (medir RSS durante la operación). +- **Edge:** archivo cuyo tamaño es múltiplo exacto del chunk; archivo de 1 byte; + archivo justo por debajo y por encima del umbral de chunking. +- **Error path:** chunk corrupto/no descifrable → error claro, no panic; `blobs gc` + con un blob aún referenciado → NO lo borra (assert). +- `CGO_ENABLED=0 go test ./...` verde. + +# Riesgos y mitigaciones + +| Riesgo | Mitigación | +|---|---| +| Romper media v1 ya enviada | `Chunks` vacío preserva el camino v1; tests de compatibilidad | +| GC borra un blob aún referenciado | refcount + barrido conservador + `--dry-run` por defecto en CI | +| Puente gomobile con []byte grandes | el binding trabaja con rutas de archivo, no buffers en memoria | +| Paralelismo de chunks satura el control plane | límite de concurrencia (4) + el endurecimiento de auth del issue 0001 | + +# Relación con otros issues + +- **0001 (seguridad)** — prerequisito: la auth firmada del control plane debe cubrir + `POST/GET /blobs` antes de exponer media públicamente; si no, cualquiera llena el + store o descarga ciphertext ajeno. +- **Streaming en vivo** (futuro, no este issue) — videollamada/audio en tiempo real = + WebRTC con el bus como canal de señalización; modelo distinto al blob. diff --git a/dev/issues/0003-decentralization-ha.md b/dev/issues/0003-decentralization-ha.md new file mode 100644 index 0000000..222e72c --- /dev/null +++ b/dev/issues/0003-decentralization-ha.md @@ -0,0 +1,195 @@ +--- +issue: 0003 +title: Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF +status: spec +created: 2026-06-07 +domain: infra +scope: unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo +depends_on: 0001 (la auth de cluster y de clientes va junto con el endurecimiento) +--- + +# Objetivo + +Que la caída de un servidor **no deje el bus sin servicio**. Hoy unibus es un único +`membershipd` (con NATS embebido + SQLite local): si ese host muere, no hay bus. +Este issue lleva unibus a un modelo **descentralizado / alta disponibilidad** usando +las capacidades nativas de NATS: cluster multi-nodo, JetStream replicado (RAFT), y +el estado del control plane fuera de la SQLite local. **No es federación** +(multi-operador con dominios distintos); es eliminar el punto único de fallo dentro +de un único dominio administrativo controlado por nosotros. + +# Requisito clave de quorum (decisión de infraestructura) + +JetStream replica con RAFT, que necesita **mayoría (quorum)** para confirmar +escrituras. Las consecuencias son duras y hay que asumirlas desde el diseño: + +| Nodos | Réplica | Tolera caída de | Nota | +|---|---|---|---| +| 1 | R1 | 0 | situación actual (SPOF) | +| 2 | R2 | **0** | si cae uno se pierde quorum: las escrituras se bloquean. NO sirve para HA | +| **3** | **R3** | **1** | mínimo real para "si un server cae, seguimos" | +| 5 | R5 | 2 | mayor tolerancia | + +**Por tanto el objetivo del usuario ("si mi server falla, no nos quedamos sin +servicio") exige 3 nodos JetStream.** Servers disponibles hoy: **magnus** y +**homer** (ambos VPS OVH). El tercero está pendiente de conseguir. + +| Nodo | IP pública | Estado | Notas | +|---|---|---|---| +| magnus | (en pass: `MAGNUS_ovh_ssh_ROOT`) | disponible, **cargado** | corre coolify, minio, postgres, authentik, portainer, dagu — revisar recursos antes | +| homer | `141.94.69.66` | disponible, vivo | creds en pass (`vps_ovhcloud_SSH_SERVER_HOMER_-_root`, `vps_SSH_SERVER_HOMER_dataherrero`); tenía coolify | +| nodo 3 | — | **pendiente** | conseguir un tercer VPS siempre-on, o reusar om/datardos si se liberan | + +Preparación previa al deploy de cada nodo: alta del alias SSH + clave, integración en +la WireGuard, y revisar/aligerar la carga existente (coolify, etc.). + +## Rollout R1 → R3: funcionar con 2 nodos hoy, HA con 3 mañana + +No se "desactiva el quorum"; se controla el **número de réplicas** de cada stream/KV: + +| Réplicas | Quorum | Tolera | Sirve con | +|---|---|---|---| +| R1 | ninguno (1 copia) | 0 caídas | 1-2 nodos, sin bloqueo | +| R3 | 2 de 3 | 1 caída | 3 nodos | + +- **Fase actual (magnus + homer):** desplegar con streams/KV en **R1** (flag + `decentralized: off`). El bus funciona al 100% para operar, sin tolerancia a fallo + todavía. Opción: streams en **R2** para duplicar los datos en ambos nodos + (durabilidad/backup vivo), asumiendo que la escritura necesita los dos hasta el 3er + nodo. +- **Cuando entre el nodo 3:** escalar en caliente `nats stream update --replicas 3` + (idem KV/Object Store) + añadir el nodo al cluster + flag `decentralized: on`. **HA + real, sin downtime, sin reescritura, sin migrar datos.** +- **Aviso de 2 nodos:** NO montar el meta-group de JetStream con 2 nodos como si + fuera HA — su quorum es 2, y la caída de uno bloquea la gestión de streams. Con 2 + servers, modelo recomendado: **magnus principal (R1) + homer 2º nodo/réplica**, y + escalar a R3 al tener el tercero. + +Mientras solo haya 2 nodos: el **data plane efímero** (core-NATS, rooms `ModeNATS`) +sí tolera la caída de uno (los clientes reconectan al otro), pero las **rooms +persistentes y el control plane** (que necesitan quorum) no. El issue se despliega +de verdad cuando haya 3 nodos. + +# Contexto — por qué hoy es un SPOF + +- `pkg/embeddednats` arranca un NATS **standalone** (sin cluster). +- `pkg/membership` guarda rooms/members/room_keys/users en una **SQLite local** al + proceso. +- `pkg/blobstore` guarda los blobs en el **disco local** del proceso. +- El cliente (`pkg/client`) conecta a **una** URL de NATS y **una** de control plane. + +Todo vive en un host. Ese host es el punto único de fallo. + +# Diseño + +## Pieza 1 — Cluster NATS (data plane replicado) + +`pkg/embeddednats` gana opciones de cluster: `server.Options.Cluster` (nombre + +host/puerto de routes) y `Routes` (los otros nodos). Cada `membershipd` arranca su +NATS embebido en cluster con los demás. JetStream se habilita con `Replicas: 3` en +streams y KV. Auth entre nodos (routes) con credenciales propias (no las de +clientes), y TLS también en las routes (reusa la CA del issue 0001). + +## Pieza 2 — Control plane sin estado local (SQLite → JetStream KV) + +Es el corazón del issue. Hoy `pkg/membership.Store` es SQLite. Se introduce, por +**branch-by-abstraction**, una interfaz `Store` con dos implementaciones: + +- `sqliteStore` — la actual (sigue siendo el default mientras el flag está off; útil + para un solo nodo / desarrollo). +- `jetstreamStore` — nueva: rooms, members, room_keys y users (la tabla del issue + 0001) viven en **JetStream KV** (buckets replicados R3). Cualquier nodo lee/escribe + el mismo estado; RAFT garantiza consistencia. El HTTP control plane pasa a ser + efectivamente **stateless**: cualquier `membershipd` sirve cualquier request + porque el estado está en el KV replicado. + +Flag `decentralized` (off → on). Migración inicial de datos SQLite → KV con un +comando `membershipd migrate-to-kv` (idempotente). Las claves de room siguen +selladas igual; solo cambia **dónde se guardan**, no el cifrado. + +## Pieza 3 — Blobs replicados (object store → NATS Object Store) + +`pkg/blobstore` gana una implementación sobre **NATS Object Store** (encima de +JetStream, replicado R3) además de la de disco local. Los blobs (ya ciphertext, E2E) +quedan disponibles desde cualquier nodo. Encaja con el GC del issue 0002. + +## Pieza 4 — Cliente con failover + +`pkg/client`: aceptar **lista** de seeds de NATS y **lista** de URLs de control +plane. `nats.go` ya hace reconnect/failover entre servidores del cluster nativamente +(`nats.Servers([...])`, `nats.MaxReconnects(-1)`). El control plane HTTP se prueba en +orden con reintento. Así, si un nodo cae, el cliente reconecta a otro de forma +transparente. + +## Pieza 5 — Despliegue multi-nodo + +3 nodos `membershipd`, cada uno con su NATS embebido en cluster, JetStream R3, mismo +`ca.crt`/credenciales de routes. systemd en cada VPS. Los clientes reciben la lista +de los 3 endpoints. Health/observabilidad por nodo (`/healthz` + métricas de +JetStream: líder RAFT, lag de réplica). + +# Decisiones técnicas + +| Decisión | Elegido | Alternativa | Razón | +|---|---|---|---| +| Nº de nodos de quorum | 3 (R3) | 2 (R2) | 2 no tolera caída de uno; 3 es el mínimo real de HA | +| Estado del control plane | JetStream KV replicado | SQLite replicada a mano / Postgres externo | KV ya viene con NATS, mismo RAFT que JetStream, cero infra extra | +| Migración del store | branch-by-abstraction (interfaz `Store`, dos impls, flag) | reescritura directa | master nunca se rompe; sqlite sigue para 1 nodo/dev | +| Blobs | NATS Object Store | disco compartido / S3 | replicado nativamente, sin dependencia externa | +| Failover de cliente | lista de seeds + reconnect nativo nats.go | balanceador externo | menos infra, nats.go ya lo hace | +| Federación multi-operador | **fuera de alcance** | — | no es el objetivo; es otra liga (trust entre dominios) | + +# Fases (TBD, ramas `issue/0003x-*`) + +1. **0003a — cluster NATS** — opciones de cluster/routes + TLS de routes en + `pkg/embeddednats`; arrancar 2-3 nodos locales en tests e2e y verificar que un + subject publicado en uno llega a un suscriptor en otro. +2. **0003b — interfaz Store + jetstreamStore (KV)** — abstraer `pkg/membership.Store`; + implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de + consistencia. Flag `decentralized: off`. +3. **0003c — migrate-to-kv** — comando idempotente SQLite → KV + test de paridad + (mismo estado antes/después). +4. **0003d — blobs en Object Store** — impl `pkg/blobstore` sobre NATS Object Store + replicado. +5. **0003e — cliente failover** — lista de seeds + lista de ctrl-urls + reconnect; + test que mata el nodo al que está conectado y verifica que sigue operando. +6. **0003f — despliegue 3 nodos** (humano) — 3 VPS en cluster, JetStream R3, flag + `decentralized: on`. Chaos test real: matar un nodo en producción y comprobar que + el servicio sigue. + +# Definition of Done (evidencia ejecutable) + +- **Golden:** 3 nodos en cluster; un cliente publica en un nodo y otro cliente + suscrito a otro nodo lo recibe; crear room + invitar funciona desde cualquier nodo. +- **Edge:** un cliente conectado al nodo A; se **mata el nodo A**; el cliente + reconecta a B automáticamente y sigue publicando/recibiendo sin perder la sesión. +- **Error path (chaos):** matar 1 de 3 nodos → el control plane sigue aceptando + escrituras (quorum 2/3); matar 2 de 3 → las escrituras se bloquean (quorum perdido, + comportamiento esperado y documentado, no corrupción). +- `CGO_ENABLED=0 go test ./...` verde, incluido un test e2e multi-nodo en proceso. + +# Riesgos y mitigaciones + +| Riesgo | Mitigación | +|---|---| +| Solo 2 nodos disponibles → sin quorum real | prerequisito explícito de 3 nodos antes de 0003f; hasta entonces, despliegue queda en standalone | +| Latencia inter-VPS afecta RAFT | nodos en la misma región o con buena red; medir; R3 tolera latencias moderadas | +| Migración SQLite→KV pierde datos | comando idempotente + test de paridad + backup de la SQLite antes | +| Partición de red (split-brain) | RAFT lo previene: el lado sin quorum se bloquea para escritura, no diverge | +| Complejidad operativa de 3 nodos | observabilidad de JetStream (líder, lag) + `/healthz` por nodo + runbook en deploy/ | + +# Orden recomendado respecto a otros issues + +1. **0001 (seguridad)** primero: la auth de clientes (nkey) y la CA/TLS se reutilizan + para las routes del cluster. Desplegar descentralizado sin auth sería abrir varios + puntos públicos sin protección. +2. **0003 (este)** después: una vez el bus es seguro, replicarlo en 3 nodos. +3. **0002 (media v2)** es ortogonal; su object store encaja con la pieza 3 (blobs + replicados) cuando ambos estén. + +# Fuera de alcance + +- Federación entre operadores/dominios distintos (otra liga; requiere protocolo de + trust entre dominios). +- Multi-tenant / accounts de NATS por organización. +- Auto-escalado dinámico de nodos. diff --git a/dev/issues/0004-security-hardening.md b/dev/issues/0004-security-hardening.md new file mode 100644 index 0000000..46da6e4 --- /dev/null +++ b/dev/issues/0004-security-hardening.md @@ -0,0 +1,144 @@ +--- +issue: 0004 +title: Hardening de seguridad — autorización, anti-DoS y confidencialidad antes de exponer público +status: spec +created: 2026-06-07 +domain: security +scope: unibus (pkg/membership/server.go, auth.go, pkg/embeddednats, pkg/client, cmd/membershipd, deploy/tls) +depends_on: 0001 (cierra los gaps que la auditoría 0004 encontró sobre lo entregado en 0001) +blocks: 0001f (deploy público) y 0003f (deploy descentralizado) +source: projects/message_bus/reports/0004-2026-06-07-unibus-security-audit.md +--- + +# Objetivo + +La auditoría red-team (report 0004) concluyó: la **autenticación** del bus es sólida, +pero faltan **autorización, disponibilidad y confidencialidad de metadata** — justo lo +que un bus *público* necesita. Veredicto: **NO exponer público hoy**. Este issue cierra +los hallazgos bloqueantes (1 crítico + 4 altos) y los medios relevantes, de modo que el +deploy 0001f (público) y luego 0003 (descentralizado) sean seguros. + +Cada fase corresponde a un hallazgo del report 0004. La **DoD de cada fase es portar el +test adversarial del auditor** (`TestAudit_*`) y verificar que ahora arroja el resultado +SEGURO (lo que antes pasaba el ataque, ahora lo rechaza). + +# Fases (TBD, ramas `issue/0004x-*`, una por hallazgo) + +## 0004a — H1 (Crítico): límite de cuerpo + anti-DoS pre-auth + +**Problema:** `Server.ServeHTTP` hace `io.ReadAll(r.Body)` **sin límite y antes** de +`authenticate()`; `handlePutBlob` repite el `io.ReadAll` sin límite. 400 MB sin +credenciales → 898 MB RSS → OOM con pocas conexiones. + +**Fix:** +- `http.MaxBytesReader` en el middleware **antes** del `io.ReadAll` (límite control plane, + p.ej. 1 MB). +- Límite separado y mayor para `/blobs`, con rechazo temprano por `Content-Length` antes + de bufferizar; idealmente stream a disco en vez de RAM. +- `Server.MaxHeaderBytes` ajustado. +- Rate-limit por IP (y por identidad tras auth). Reusar/crear una función del registry si + aplica (delegar a `fn-constructor` si es genérica). + +**DoD:** test que envía un cuerpo > límite sin firma → `413`/`401` **sin** que el RSS se +dispare (medir `/proc/self/status` antes/después, delta acotado). Golden (cuerpo normal +pasa) + edge (justo en el límite) + error (excede → rechazo barato). + +## 0004b — H2 (Alto): cerrar el fail-open de configuración + +**Problema:** default `--bus-auth off`; el nkey de NATS solo se activa en `enforce`; TLS +es flag independiente. `--bind 0.0.0.0 --tls-cert …` **sin** `--bus-auth enforce` deja el +bus abierto con apariencia de seguro. + +**Fix:** +- Si `--bind` no es loopback ⇒ exigir `--bus-auth enforce` (si no, `log.Fatal` con mensaje + claro). +- `--tls-cert`/`--tls-key` sin `--bus-auth enforce` ⇒ error de arranque. +- Arranque inseguro imposible o, como mínimo, ruidoso y rechazado. + +**DoD:** portar `TestAudit_FailOpenTLSWithoutAuth` → ahora el arranque público-sin-enforce +falla; cliente no registrado NO conecta. Golden (bind loopback dev sigue permitido) + error +(bind público sin enforce aborta). + +## 0004c — H3 (Alto): autorización por pertenencia en el control plane + +**Problema:** "autorizado" = "registrado", no "miembro". Los GET de room no comprueban +pertenencia: `/rooms/{id}`, `/rooms/{id}/members` (expone `sign_pub`+`kex_pub` de todos), +`/members/{endpoint}/rooms`, y `/rooms/{id}/key?endpoint=X` (devuelve la `sealed_key` ajena). + +**Fix:** +- Cada handler de room consulta `members` y exige que el firmante (`X-Unibus-Pub` → + endpoint) sea miembro. +- `/rooms/{id}/key` solo sirve la clave sellada **para el propio firmante** (`endpoint == + signer`), nunca de un tercero. +- `/members/{endpoint}/rooms` solo si `endpoint == signer`. +- No exponer la member-list completa a no-miembros. + +**DoD:** portar `TestAudit_HorizontalMetadataLeak` → bob (no miembro) ahora recibe `403` +en todos. Golden (miembro legítimo accede) + edge (owner accede) + error (no-miembro 403). + +## 0004d — H4 (Alto): control de acceso en el data plane NATS + +**Problema:** 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 entre usuarios. + +**Fix (elegir y documentar la estrategia):** +- Preferente: NATS `Permissions` por identidad (subjects que el usuario puede sub/pub), + derivadas de su pertenencia a rooms; o +- Subjects impredecibles (no derivables del nombre) + verificación de pertenencia + server-side; o +- Prohibir `ModeNATS` en despliegue público (forzar siempre E2E) como mínimo defensivo. + +**DoD:** portar `TestAudit_NoSubjectACL` → eve (no invitada) ya NO recibe el mensaje de la +room ajena. Documentar la estrategia elegida y su límite. + +## 0004e — H5 (Alto, público): TLS en el control plane + +**Problema:** HTTP `:8470` firmado pero **sin TLS** → metadata (subjects, endpoints, +pubkeys, sealed keys, hashes de blobs, grafo social) legible por un MITM en la red pública. + +**Fix:** +- Servir el control plane sobre TLS con la misma CA propia (o documentar un reverse-proxy + TLS delante). +- El cliente exige `https` cuando se le pasa una CA (`client.Connect(caPath)` ⇒ control + plane también TLS). + +**DoD:** cliente contra control plane `https` con la CA → OK; contra `http` con CA esperada +→ rechaza; un observador no ve la metadata (argumentado + test de esquema). + +## 0004f — medios: owner binding, nonce-cache, error leak + +- **H6** `handleCreateRoom`: exigir `Owner.Endpoint == frame.EndpointID(X-Unibus-Pub)` y + `Owner.SignPub == pub`. (Portar `TestAudit_OwnerSpoof` → ahora 403.) +- **H7** mover `IsAuthorized` **antes** de tocar el `nonceCache` (no cachear nonces de + no-autorizados); poda por expiry-bucket/heap en vez de O(n) bajo mutex global; cap de + tamaño. (Portar `TestAudit_NonceCachePoisonPreAuth`.) **Nota:** este fix es prerequisito + del cambio a nonce-cache replicado del issue 0003. +- **H12** mensajes de error genéricos al cliente; detalle solo al log (no filtrar rutas/SQL). + +# Fuera de alcance de este issue (encolado en otros) + +- **H9** (cuota/GC de blobs) → issue 0002 (media v2) ya lo cubre. +- **H10** (AEAD nonce 12B → XChaCha o rekey por volumen) → bajo, futuro; abrir issue propio + si se necesitan rooms de muy alto volumen. +- **H11** (firma de owner sin nonce/ts) → cubierto en la práctica por el envelope `enforce`; + documentar la dependencia. Reforzar si se relaja `enforce`. +- **H8** (custodia de la CA: generar en om, `ca.key` fuera del PC) → tarea operacional del + deploy 0001f/0003f, no de código. +- **govulncheck** sobre nats-server/nats.go/modernc → paso de CI aparte. + +# Definition of Done global + +- Las cuatro pruebas adversariales bloqueantes del report 0004 (DoS acotado, fail-open + cerrado, fuga horizontal 403, ACL data plane) portadas como tests de regresión y en verde. +- `CGO_ENABLED=0 go build ./...` + `go vet ./...` + `go test ./...` verdes. +- Re-evaluación: tras el hardening, el veredicto de exposición pública pasa de "NO" a + "sí-con-condiciones operacionales" (CA custodiada, Restart=always). Anotar en un report + nuevo o como addendum al 0004. + +# Orden respecto a otros issues + +1. **0004 (este)** — primero: hace el bus seguro para exponer. +2. **0003 (descentralización)** — después: absorbe el nonce-cache→KV replicado (apoyado en + 0004f-H7), la auth de routes del cluster y el guard de fail-open ×N nodos. +3. **0002 (media v2)** — ortogonal; incluye la cuota/GC de blobs (H9).