Files
message_bus/reports/0008-2026-06-07-unibus-decentralization-audit.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- reports/0001-2026-06-07-unibus-grafana-monitoring.md
- reports/0008-2026-06-07-unibus-admin-users-wired.md
- reports/0008-2026-06-07-unibus-decentralization-audit.md
- reports/0009-2026-06-07-unibus-cluster-hardening.md
- reports/0010-2026-06-07-unibus-android-native.md
- reports/0011-2026-06-07-unibus-cluster-deploy.md
- reports/0012-2026-06-07-unibus-deploy-gaps-closed.md
- reports/0013-2026-06-07-unibus-admin-panel.md
- reports/0014-2026-06-07-unibus-users-http-admin-api.md
- reports/0015-2026-06-07-unibus-web-wired.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00

126 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Report 0008 — unibus: auditoría de seguridad de la superficie descentralizada (issue 0003, vectores N1N6)
- **Fecha:** 07/06/2026
- **Autor:** agente auditor (Claude Opus 4.8), mentalidad red-team
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`), HEAD `df3b62a` (v0.7.0, master). Paquetes `embeddednats`, `busauth`, `membership`, `client`, `cmd/membershipd`. Superficie nueva introducida por la descentralización (issue 0003, fases 0003a0003e).
- **Estado:** done — auditoría + verificación activa con cluster efímero en proceso. NO se modificó código de producción; los tests de ataque fueron efímeros (creados, ejecutados, borrados); el working tree quedó idéntico al baseline.
- **Origen:** la re-auditoría previa (report 0006-security-reaudit) auditó el commit PRE-0003 (618f6b6) porque durante 0003 el working tree no compilaba. La superficie distribuida de 0003 nunca se había red-teameado. Esta auditoría la cubre antes de exponer el bus público en cluster (0003f).
---
## Resumen ejecutivo — ¿es seguro exponer el bus DESCENTRALIZADO (cluster) público HOY?
**Veredicto: NO** para el bus descentralizado en cluster, tal como el binario lo cablea en v0.7.0. Dos hallazgos bloqueantes, ambos demostrados con ataque ejecutable sobre un cluster efímero en proceso:
1. **N3 — anti-replay roto en cualquier cluster real.** El binario `membershipd` **nunca llama** `Server.UseReplicatedNonces`. Cada nodo mantiene su `memNonceCache` por-proceso. Una petición firmada una vez y aceptada en el nodo A se **acepta también replayada al nodo B** (demostrado: 200 en A, 200 en B). El cierre de replay multi-nodo que el issue reivindica existe como API (`kvNonceStore`) y como test (`TestReplicatedNonceRejectsCrossNodeReplay`, que lo invoca explícitamente), pero el binario que se desplegaría no lo usa.
2. **N2 — fuga total del control plane vía `$JS.API.>` al activar el KV.** El grant ACL de cliente es `clientInfraSubjects = {"_INBOX.>", "$JS.API.>"}`. En cuanto el control plane descentralizado esté activo (los buckets `UNIBUS_users/rooms/members/room_keys` en JetStream KV — el objetivo central de 0003), `$JS.API.>` permite a **cualquier peer registrado** leer esos buckets directamente por NATS, **bypassando toda la autorización HTTP**. Demostrado: una identidad registrada, rol `member`, miembro de ninguna room, cosechó el allowlist completo (handles, roles, claves), el grafo de rooms (subjects, owners) y la metadata de sealed-keys de una room ajena.
**Matiz honesto (importante para priorizar):** el binario **v0.7.0 todavía no activa el control plane descentralizado**. El store es **siempre SQLite** (`membership.Open` en `main.go:90`); el flag `decentralized` existe en `dev/feature_flags.json` pero **ningún código Go lo lee**; `OpenJetStream` solo lo consume el comando `migrate-to-kv`. En consecuencia:
- Como **nodo único standalone** (`enforce` + TLS), unibus sigue tan seguro como tras el hardening 2 (report 0007): toda la regresión verde, `govulncheck` 0 alcanzables. **Eso sí es desplegable.**
- Como **cluster multi-nodo** (lo que 0003 persigue), **NO es seguro**: N3 es explotable de inmediato (cualquier cluster con el binario actual), y N2 se vuelve explotable en cuanto se complete el wiring del KV **o** simplemente se ejecute `migrate-to-kv` contra un nodo que sirva clientes bajo ACL (ese comando SÍ está cableado y crea+puebla los buckets).
Como la pregunta es específicamente sobre el bus **descentralizado en cluster**, la respuesta es **NO — corregir N3 y N2 antes de 0003f.**
---
## Hallazgos NUEVOS de la superficie 0003
| Sev | Vector | Descripción | Evidencia (comando + salida) | Fix |
|---|---|---|---|---|
| **ALTA** | N3 | El binario `membershipd` no llama `UseReplicatedNonces` ni siquiera con `--cluster-name`. Cada nodo usa `memNonceCache` por-proceso → un request firmado capturado se replaya con éxito a otro nodo del cluster (su cache nunca vio el nonce). El anti-replay multi-nodo es **nulo** en el binario desplegable. | Ataque efímero `TestAttack0008_N3` (2 `NewServer(...,AuthEnforce)` = wiring exacto del binario, sin `UseReplicatedNonces`): `node A first use -> 200 ; SAME ts+nonce replayed to node B -> 200`. | Cablear `UseReplicatedNonces(js, replicas)` en `main.go` cuando hay JetStream/cluster; fail-fast si el bucket no se crea. Idealmente: `--cluster-name != ""` ⇒ nonce replicado obligatorio. |
| **ALTA** | N2 | El grant ACL `$JS.API.>` (en `acl.go:20`, `clientInfraSubjects`) deja a cualquier peer registrado ejecutar la JetStream API y **leer los buckets KV del control plane** (`KV_UNIBUS_users`, `KV_UNIBUS_rooms`, `KV_UNIBUS_members`, `KV_UNIBUS_room_keys`) por NATS, saltándose `requireMember` y los chequeos own-endpoint del HTTP. Fuga del allowlist (handles+roles+claves), del grafo de rooms (subjects/owners) y de la metadata de sealed-keys (endpoint destino + existencia). El propio `TestReaudit_H4_WildcardMetadataLeak` documenta este residual y dice que se difirió a "la línea 0003" — pero **0003 no lo cerró**; con el KV escala de "fuga de metadata de subject" a "fuga del control plane entero". | Ataque efímero `TestAttack0008_N2` (poblar KV sin auth, rebootear el mismo store con la ACL de producción, eve `member` lee): `eve read UNIBUS_users[ceo] = handle="ceo-root-admin" role="admin" status="active"`; `eve read UNIBUS_rooms[PRIVROOM] subject="room.board.ma-deal" owner=... encrypt=true`; `eve read UNIBUS_room_keys[PRIVROOM.<ceo>.1]`. | NO conceder `$JS.API.>` entero. Derivar permisos JetStream mínimos por-room (API del stream/consumer de las rooms del peer) y **denegar** los streams `KV_UNIBUS_*`. Mejor aún: aislar el control plane KV en una NATS *account* separada inaccesible a clientes. Mientras tanto, **no** activar `decentralized: on` ni `migrate-to-kv` contra un nodo que sirva clientes con ACL. |
| **MEDIA** | N1 | **El cluster es tan seguro como su nodo más débil.** El data plane reenvía todos los subjects entre nodos; un nodo del cluster sin authenticator (o `--bus-auth off`) permite a un peer **no autenticado** `Subscribe(">")` y cosechar el tráfico reenviado de los nodos con ACL. Mitigado para el binario embedded en bind público por `validateBootConfig` (un bind no-loopback exige `enforce`), pero explotable si se usa NATS externo, un bind loopback que rutea, o un nodo mal configurado. | Ataque efímero `TestAttack0008_N1` escenario 2 (cluster 2 nodos ACL + 1 nodo `withACL=false`): `unauthenticated mallory on a no-auth cluster node harvested room-A traffic forwarded from the ACL'd node: "secret-A-2"`. | Forzar/documentar posture homogénea (enforce+ACL+TLS) en TODOS los nodos. Health/arranque que rechace formar cluster con un peer en posture inferior. Nunca exponer el puerto de cliente de un nodo sin enforce. |
| **MEDIA** | N4 | La ACL congela permisos al conectar; un peer que crea/se une a una room debe llamar `client.RefreshSession()` para poder pub/sub en su subject. **Ningún cliente del repo lo llama** (`cmd/chat`, `cmd/worker`, `mobile`, `gateway`): grep solo halla la definición y comentarios. El fallo es *fail-closed* (deniega, no abre), pero rompe la usabilidad bajo `enforce`+ACL, lo que empuja al operador a **desactivar la ACL** (volver a `NewNkeyAuthenticator` abierto) para que las apps funcionen → regresión de seguridad a discreción del operador. | `grep -rn RefreshSession --include="*.go" \| grep -v _test.go` → solo definición + comentarios; `cmd/worker/main.go` crea `room.ModeNATS` y publica sin refrescar. | Llamar `RefreshSession` tras cambios de membresía en todos los clientes, o implementar refresh transparente (rehacer suscripciones). Documentar el contrato como requisito de despliegue. |
| **MEDIA** | N3 | El bucket de nonces en **R1** es un SPOF de autenticación: si cae el nodo dueño del stream `KV_UNIBUS_nonces`, todo `Create` falla → el `kvNonceStore` (correctamente) hace *fail-closed***todos los requests autenticados se rechazan** (DoS de auth de todo el bus). Inherente a R1; se mitiga con R3 (quorum 2/3). | Análisis de `nonce_kv.go:67-77` (fail-closed) + `JetStreamConfig.Replicas` configurable; el rollout R1 del issue lo deja expuesto. | No vender R1 como "HA". Documentar que el control plane (incl. nonces) no tolera caída hasta R3. Considerar degradación controlada vs. DoS total. |
| **BAJA** | N1 | La CA de routes es **la misma** que la del data plane de clientes (`RouteTLSConfig` reusa la CA del 0001). Acopla dos fronteras de confianza: si algún día se emiten certs de cliente con esa CA, podrían presentarse al puerto de routes (el segundo factor sigue siendo el password de cluster). Además el password de cluster viaja en `--routes nats://user:pass@host` → visible en `ps`/`/proc/<pid>/cmdline`/journald. | Lectura de `tls.go:55-75` (misma `pool` para RootCAs+ClientCAs) y `main.go:59` (password en argv). | CA separada para routes (o restricción por EKU/nombre). Pasar el secreto de cluster por archivo/env, no por argv. |
| **BAJA** | N6 | `migrate-to-kv` es idempotente (Put-overwrite) y hace backup (`VACUUM INTO`) — correcto. Pero **ejecutarlo contra un nodo enforce+ACL crea y puebla los buckets KV**, que quedan legibles por `$JS.API.>` (ver N2) aunque el control plane siga leyendo de SQLite. Y si `--nats-url` apunta a un nodo remoto sin `--ca`, el allowlist (handles/roles/sign pubs) viaja **plaintext** por NATS (los sealed-keys ya son ciphertext E2E). | Lectura de `migrate.go` + `main.go` (el comando conecta con `--nats-url`/`--ca`); ata con la evidencia N2. | Ejecutar `migrate-to-kv` solo en loopback o con TLS. No correrlo contra un nodo que sirva clientes con ACL hasta resolver N2. |
### N5 (failover del cliente) — sin hallazgo de seguridad
Al reconectar a otro nodo, el cliente **re-autentica**: `RefreshSession`/el reconnect automático de nats.go reusan `natsOpts` (incluyen `nats.Nkey` y `nats.Secure(TLS)`), así que el nuevo nodo re-ejecuta el handshake nkey + verifica firma sobre su propio nonce + `IsAuthorized` + (con ACL) deriva permisos **frescos de su store**. No hay ventana operando sin re-autenticar ni con permisos ACL viejos. Los caches preservados (`keyCache`/`signCache`) son las propias claves del peer — reusarlas no es inseguro. *Nota funcional (no de seguridad):* con SQLite por-nodo el estado del control plane diverge entre nodos, así que el failover de transporte funciona pero un nodo puede no conocer una room creada en otro — esa es la razón por la que el control plane KV (no cableado) es necesario.
---
## Regresión — ¿0003 rompió algún fix de 0001/0004/0005?
**No.** Todos los tests de auditoría/re-auditoría/gap previos siguen verdes y siguen **rechazando** sus ataques. Re-ejecución sobre `df3b62a`:
```
$ CGO_ENABLED=0 go test ./pkg/membership/ ./pkg/client/ -run \
'TestAudit_|TestGap_|TestReaudit_|TestReplicatedNonce|TestSubjectACL|TestRefreshSession|TestClientFailover' -v -count=1
--- PASS: TestAudit_HorizontalMetadataLeak (autz por pertenencia, H3)
--- PASS: TestAudit_OwnerSpoof (owner binding, H6)
--- PASS: TestAudit_DoSBodyLimitNoAuth (body limit pre-auth)
--- PASS: TestAudit_NonceCachePoisonPreAuth (nonce poison: autorizar antes de cachear)
--- PASS: TestAudit_NoSubjectACL (confidencialidad de contenido E2E)
--- PASS: TestReaudit_DoSConcurrency (in-flight global cap, N2 del 0006)
--- PASS: TestReaudit_SigNilSpoof (sig-nil drop en rooms SignMsgs, N3 del 0006)
--- PASS: TestReaudit_H4_WildcardMetadataLeak (ACL confina Subscribe(">"))
--- PASS: TestReplicatedNonceRejectsCrossNodeReplay (la API KV cierra el replay — cuando se usa)
--- PASS: TestSubjectACLIsolation (aislamiento por subject single-node)
--- PASS: TestRefreshSessionGainsNewRoom (refresh re-deriva permisos)
--- PASS: TestClientFailoverAcrossNodes (failover transparente)
ok github.com/enmanuel/unibus/pkg/membership
ok github.com/enmanuel/unibus/pkg/client
$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestClusterConfigPolicy|TestSplitRoutes' -v
--- PASS: TestClusterConfigPolicy (8 sub-casos: rechaza cluster público sin secreto / sin TLS / TLS parcial)
--- PASS: TestSplitRoutes
```
Suite completa + toolchain:
```
$ CGO_ENABLED=0 go build ./... # OK
$ CGO_ENABLED=0 go vet ./... # limpio
$ CGO_ENABLED=0 go test -count=1 ./...
ok cmd/membershipd ; ok pkg/blobstore ; ok pkg/busauth
ok pkg/client ; ok pkg/embeddednats ; ok pkg/frame ; ok pkg/membership
$ govulncheck ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
(0 en paquetes importados; 13 en módulos requeridos pero NO alcanzables por el código)
```
`govulncheck`: **0 vulnerabilidades alcanzables**, confirmando lo que 0005a anotó (el bump de nats-server/nats.go/modernc sigue limpio en superficie alcanzable).
---
## Confirmaciones — qué SÍ resiste (verificado, no asumido)
- **N1 — auth de routes real y mutua.** Un nodo con el password de cluster incorrecto **no forma route** (`TestClusterRejectsBadRouteAuth`: impostor 0 routes, baseline del nodo legítimo intacto). Un nodo cuyo cert no está firmado por la CA del bus **no establece route en ninguna dirección** (`TestClusterRejectsUnsignedNode`, `RequireAndVerifyClientCert`). El listener de routes es un plano server-to-server distinto del de clientes; **no** reusa el authenticator nkey. `validateClusterConfig` rechaza arrancar un cluster en bind público sin secreto **y** sin TLS mutuo completo (8 sub-casos verdes).
- **N1.1 — la ACL aísla subjects CROSS-NODE** con posture homogénea. Ataque efímero (cluster 2 nodos ACL): `eve's cross-node sub to "room.cross.a" denied: Permissions Violation for Subscription`; alice publica en el nodo 0 y eve (nodo 1) **no recibe nada** — la ACL se aplica en el nodo de entrega antes del fan-out.
- **N2 — la ESCRITURA al KV está denegada.** La ACL no concede `$KV.>` para publicar; un peer no puede escribir los buckets ni escalar privilegios por esa vía. Verificado dos veces: el primer intento de seed bajo ACL falló con `Permissions Violation for Publish to "$KV.UNIBUS_users..."`, y el ataque N2 confirmó `eve KV Put rejected by ACL (good)`. **La fuga es solo de lectura** (grave igualmente), no de escritura.
- **N3 — la API de nonce replicado funciona cuando se usa.** `TestReplicatedNonceRejectsCrossNodeReplay` (que llama `UseReplicatedNonces` explícito) rechaza el replay cross-node (401). El `Create` atómico vía RAFT es linealizable (sin race de visibilidad inter-nodo). El handler valida skew → firma → allowlist **antes** de reclamar el nonce (`auth.go:189-238`), así que un atacante no autenticado **no** puede inundar el bucket de nonces (anti-DoS preservado). El problema es solo que el binario no la invoca (N3 arriba).
- **N5 — el failover re-autentica** (nkey+TLS) y deriva permisos frescos; sin ventana insegura.
- **N6 — migración** idempotente con backup consistente (`VACUUM INTO`); estado parcial recoverable.
- **Regresión completa** (auth firmada, anti-replay single-node, autz por pertenencia, DoS body+in-flight, fail-open guard, TLS forzado, sig-nil drop, nonce poison, owner spoof) verde + govulncheck limpio.
---
## Recomendaciones priorizadas antes del deploy 0003f
1. **[BLOQUEANTE] Cablear el nonce replicado en el binario.** En `membershipd`, cuando arranca con `--cluster-name` (o siempre que haya JetStream), llamar `srv.UseReplicatedNonces(js, replicas)` y **fail-fast** si el bucket no se crea. Sin esto, todo cluster tiene replay cross-node (N3). Test de no-regresión: replicar `TestAttack0008_N3` esperando 401 en el nodo B.
2. **[BLOQUEANTE antes de `decentralized: on`] Cerrar `$JS.API.>`.** Sustituir el grant amplio por permisos JetStream mínimos por-room (solo la API del stream/consumer de las rooms del peer) y **denegar explícitamente** los streams `KV_UNIBUS_*` (y `OBJ_*`). Opción robusta: mover el control plane KV a una NATS *account* separada, inaccesible desde la account de clientes. Hasta entonces, **no** activar `decentralized: on` y **no** correr `migrate-to-kv` contra un nodo que sirva clientes con ACL (N2/N6).
3. **[ALTA] Posture homogénea del cluster.** Garantizar (arranque/health) que todos los nodos corran `enforce`+ACL+TLS; un solo nodo débil exfiltra todo el data plane (N1.2). No exponer el puerto de cliente de ningún nodo sin enforce.
4. **[MEDIA] Completar el wiring del control plane KV** (selección de store + bootstrap del authenticator interno, el "ciclo bootstrap" pendiente) antes de afirmar HA del control plane; hoy es SQLite por-nodo → estado divergente (N5 nota).
5. **[MEDIA] RefreshSession en los clientes** (chat/worker/mobile/gateway) tras cambios de membresía, o refresh transparente; si no, la ACL es inutilizable y se desactivará (N4).
6. **[MEDIA] No vender R1 como HA.** El nonce/control plane en R1 es SPOF de auth; documentar y planificar R3 (N3 DoS).
7. **[BAJA] CA separada para routes** y secreto de cluster por archivo/env (no argv) (N1).
8. **[BAJA] `migrate-to-kv` solo en loopback o con TLS** (N6).
---
## Gaps honestos de esta auditoría
- **Todo in-process.** Varios `nats-server` embebidos en un proceso; sin 3 VPS reales. No se probó el **chaos test de red** del DoD del issue (matar 1/3, matar 2/3, partición/split-brain RAFT) — eso es 0003f y requiere despliegue real. El comportamiento de quorum se razonó, no se midió en red.
- **N2 modela el estado post-wiring.** El binario v0.7.0 no activa el control plane KV, así que el ataque N2 **simuló** el decentralized (poblar el KV sin auth y rebootear el store con la ACL de producción). La fuga es real a nivel API+ACL y se dispara en cuanto se cablee el KV **o** se corra `migrate-to-kv` en un nodo con ACL; no es explotable contra el binario tal cual mientras el store sea SQLite y no existan los buckets.
- **Object Store (blobs) no probado directo** vía `$JS.API.>`. Probable misma clase de fuga que N2 (los blobs son ciphertext E2E → impacto menor: metadata/tamaños), no verificado.
- **DoS por volumen del bucket de nonces** (millones de `Create` por tráfico autorizado legítimo) no medido bajo carga; solo se verificó que el atacante no-autenticado no lo alcanza.
- **`mobile`/`gateway`** no auditados para `RefreshSession` ni para reuso de sesión en failover.
- Los tests de ataque fueron **efímeros** (`pkg/membership/zzz_audit0008_attack_test.go`, creado/ejecutado/borrado); el working tree quedó en baseline (`git status --porcelain` vacío). Reproducir requiere recrearlos; sus cuerpos están descritos arriba.