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

18 KiB
Raw Blame History

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-closedtodos 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.