diff --git a/reports/0003-2026-06-07-unibus-bus-auth-tls.md b/reports/0003-2026-06-07-unibus-bus-auth-tls.md new file mode 100644 index 0000000..15b49a9 --- /dev/null +++ b/reports/0003-2026-06-07-unibus-bus-auth-tls.md @@ -0,0 +1,201 @@ +# Report 0003 — unibus: seguridad del bus (issue 0001, fases 0001a–0001e) + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Opus 4.8) +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`), paquetes membership, client, embeddednats, busauth, cmd/membershipd, mobile. +- **Estado:** en curso — entrega 0001a–0001e (código + tests + CA local). La fase 0001f (deploy a om) la ejecuta el humano. + +## Resumen + +Issue 0001 añade tres capas de seguridad al bus para exponerlo a internet protegido por auth+TLS: (1) allowlist de usuarios Ed25519 con roles y revocación, (2) auth firmada del control plane HTTP con anti-replay, (3) NATS endurecido con nkey (sobre la identidad Ed25519 del peer) + TLS con CA self-signed propia. Rollout detrás de los flags `bus-auth` (off→soft→enforce) y `bus-tls`. Trunk-based: una rama por fase, merge `--no-ff` a master tras tests verdes. + +**Decisión registry-first:** se reutiliza la cripto Ed25519 del registry (`sign_ed25519`, `verify_ed25519`, `generate_identity`, grupo `e2e-messaging`). La conversión Ed25519→nkey y el cache anti-replay de nonces NO se promueven al registry: son glue de transporte específico de NATS/unibus y meterlos en el `go.mod` del registry padre (multi-dominio) arrastraría `github.com/nats-io/nkeys` por una sola función. Viven en `pkg/busauth` y `pkg/membership` de la app (KISS + menor acoplamiento). + +--- + +## Fase 0001a — users store + CLI + migración 002 ✅ (merge `e9711bf`) + +### Cambios +| Archivo | Qué | +|---|---| +| `migrations/002_users.sql` + `pkg/membership/migrations/002_users.sql` | Tabla `users` (sign_pub PK hex, handle, role, status, created_at, revoked_at) + índice `idx_users_status`. Aditiva, idempotente, copias idénticas. | +| `pkg/membership/users.go` | Tipo `User` + `AddUser/GetUser/ListUsers/RevokeUser/IsAuthorized/HasAdmin`. `IsAuthorized` fail-closed (clave desconocida/revocada/error → false). | +| `cmd/membershipd/users_cli.go` + `main.go` | Dispatch `membershipd user add/list/revoke` antes del flag set del server. Abre el store local sin red ni auth (confianza de shell en el host) → seed del primer admin. Valida sign-pub = 32 bytes hex. | +| `dev/feature_flags.json` | `bus-auth` (state=off) y `bus-tls` (enabled=false). | +| `pkg/membership/users_test.go` | golden + edge + error. | + +### Verificación (evidencia ejecutable) + +Unit tests: +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestAdd|TestRevoke|TestUserError|TestUsersMigration' +ok github.com/enmanuel/unibus/pkg/membership 0.156s + TestAddGetIsAuthorized PASS (golden: add->get->IsAuthorized true, HasAdmin true) + TestAddDefaultsAndListing PASS (edge: rol vacío->member, lookup hex case-insensitive, orden) + TestRevokeDeniesAuthorization PASS (edge: revoke -> IsAuthorized false + revoked_at sellado) + TestUserErrorPaths PASS (error: ErrUserExists, rol inválido, sign_pub vacío, unknown no autorizado, revoke unknown/doble) + TestUsersMigrationIdempotent PASS (tabla+índice creados, re-apply idempotente) +``` + +CLI real (binario, DB temporal): +``` +$ membershipd user add --handle alice --sign-pub <64hex> --role admin # added user "alice" role=admin +$ membershipd user add --handle bob --sign-pub <64hex> # added user "bob" role=member +$ membershipd user revoke # revoked user +$ membershipd user list # bob -> status=revoked +$ membershipd user add --handle x --sign-pub deadbeef # exit=2: sign-pub must be 32-byte (64 hex), got 4 bytes +$ membershipd user add --handle dup --sign-pub # exit=1: membership: user already exists +``` + +Suite completa: `CGO_ENABLED=0 go build ./...` y `go test ./...` verdes (client 4.3s, membership 0.24s, frame ok). + +### Gaps 0001a +- El flag `bus-auth` aún no se consume (no hay middleware todavía): es solo declarativo. El consumo entra en 0001b. Correcto por diseño (master no rompe). +- No hay loader de `feature_flags.json` en runtime todavía; se añade en 0001b cuando el middleware necesita el estado. + +--- + +## Fase 0001b — control-plane auth ✅ (merge `89e0d0e`) + +### Cambios +| Archivo | Qué | +|---|---| +| `pkg/membership/auth.go` | `AuthMode` (off/soft/enforce) + `ParseAuthMode`. `CanonicalRequest(method, path, ts, nonce, body)` = `method\npath\nts\nnonce\nhex(sha256(body))` — fuente única compartida con el cliente. `nonceCache` con TTL+poda perezosa. `authenticate()`: headers→parse pub/ts/sig→skew ±30s→verify Ed25519→anti-replay→IsAuthorized→GetUser (fail-closed). | +| `pkg/membership/server.go` | `NewServer(store, blobs, authMode)` + `nonces`. Middleware en `ServeHTTP`: buffer body, verifica; soft loguea y deja pasar, enforce 401. `/healthz` exento. | +| `pkg/client/client.go` | `newSignedRequest(method, path, body)` añade `X-Unibus-Pub/Ts/Nonce/Sig`. `doJSON`/`putBlob`/`getBlob` firman todas las requests (también GET). La firma de owner del payload (invite/rekey) se mantiene. | +| `cmd/membershipd/main.go` | flag `--bus-auth off\|soft\|enforce` (default off). | +| `playground/server.go` | `AuthOff` (gateway no migrado; pendiente 0001e). | + +### Verificación (evidencia ejecutable) +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestAuth -v + TestAuthGoldenAccepted PASS (firmado+registrado -> 200) + TestAuthUnregisteredRejected PASS (firma válida, identidad no registrada -> 401) + TestAuthReplayRejected PASS (mismo nonce reenviado -> 401) + TestAuthClockSkewRejected PASS (ts now-120s -> 401) + TestAuthTamperedBodyRejected PASS (body alterado tras firmar -> 401) + TestAuthMissingHeadersRejected PASS (sin headers en enforce -> 401) + TestAuthHealthExempt PASS (/healthz sin auth -> 200) + TestAuthSoftAllowsUnauthenticated PASS (soft: loguea y deja pasar) + TestAuthOffNoVerification PASS + +$ CGO_ENABLED=0 go test ./pkg/client/ -run TestControlPlaneAuthEnforceE2E -v + PASS (cliente real vs server enforce: registrado OK; no registrado 401; + revocado pierde acceso SIN reiniciar el server) +``` +Suite completa `go build ./...` + `go test ./...` verde. + +### Gaps 0001b +- El gateway web (`playground/`) y el bridge móvil no firman aún (siguen AuthOff/sin migrar): el rollout no puede pasar a `enforce` global hasta 0001e. Master sigue en `off`. +- El `nonceCache` es por-proceso (intencional, spec). Un despliegue multi-membershipd necesitaría un store compartido — fuera de alcance. + +## Fase 0001c — NATS nkey auth ✅ (merge `217daae`) + +### Cambios +| Archivo | Qué | +|---|---| +| `pkg/busauth/nkey.go` | `ClientNkey(signPriv)` → (nkey pub "U...", callback que firma el nonce). `SignPubHexFromNkey(nkeyPub)` → hex de 32B (clave del allowlist). `NkeyPublicFromSignPub`. Glue NATS específico (no se promueve al registry: evitaría arrastrar nats-io/nkeys al go.mod multi-dominio). | +| `pkg/busauth/authenticator.go` | `NewNkeyAuthenticator(isAuthorized)` implementa `server.Authentication`. `Check` verifica firma nkey del nonce (decodifica raw-url→std como nats-server) + mapea a hex + `isAuthorized`. Consulta en cada conexión → revocación viva. | +| `pkg/embeddednats/embeddednats.go` | `StartHostAuth(...,auth)`. Con auth setea `CustomClientAuthentication` + `AlwaysEnableNonce=true` (sino el server no emite nonce y nats.go da "nkeys not supported"). `Start`/`StartHost` siguen abiertos (auth=nil). | +| `pkg/client/client.go` | `Options{UseNkey}` + `NewWithOptions`. `New` = legacy (sin nkey). nats.go rechaza nkey contra server sin auth → opt-in obligatorio. | + +### Verificación (evidencia ejecutable) +``` +$ CGO_ENABLED=0 go test ./pkg/busauth/ -v + TestNkeyRoundTrip PASS (firma nkey == ed25519.Sign de la misma identidad; nkey->hex == hex(SignPub)) + TestClientNkeyBadKey PASS (clave de longitud errónea -> error) + TestSignPubHexFromNkeyBad PASS + +$ CGO_ENABLED=0 go test ./pkg/client/ -run TestNatsNkeyAuth -v + PASS golden: registrado conecta con nkey y publica + error: no registrado rechazado al conectar; sin nkey rechazado + edge: revocado en runtime rechazado en NUEVA conexión sin reiniciar +``` +Suite completa verde. + +### Gaps 0001c +- La auth de NATS y la del control plane son flags independientes en el harness/diseño. En despliegue real `enforce` activa ambos (lo cablea 0001e). En `off`/`soft` el data plane sigue abierto. +- Una conexión NATS ya establecida NO se corta al revocar (NATS no re-chequea conexiones vivas); la revocación aplica a la PRÓXIMA conexión. Es el comportamiento que el spec pide ("su próxima conexión... rechazada"). + +## Fase 0001d — TLS NATS ⏳ (pendiente) +## Fase 0001d — TLS NATS ✅ (merge `2ccd11b`) + +### Cambios +| Archivo | Qué | +|---|---| +| `pkg/embeddednats/embeddednats.go` | Refactor a `StartServer(ServerConfig{StoreDir,Host,Port,Auth,TLS})`; `Start/StartHost/StartHostAuth` quedan como wrappers. Con `TLS` setea `opts.TLSConfig` + `opts.TLS=true`. | +| `pkg/busauth/tls.go` | `LoadCATLSConfig(caPath)` → `*tls.Config{RootCAs}` (cliente pin a CA propia). `ServerTLSConfig(cert,key)` → cert del server. | +| `pkg/client/client.go` | `Options.TLS *tls.Config`; `NewWithOptions` → `nats.Secure(opts.TLS)`. | +| `deploy/tls/generate-certs.sh` + `README.md` + `.gitignore` + `ca.crt` | CA self-signed + server cert con SAN `135.125.201.30, 10.42.0.1, om, localhost, 127.0.0.1`. Solo `ca.crt` versionado; `*.key`/`server.crt` gitignored. | + +### Verificación (evidencia ejecutable) +``` +$ ./deploy/tls/generate-certs.sh && openssl verify -CAfile ca.crt server.crt # server.crt: OK +$ openssl x509 -in server.crt -noout -text | grep -A1 'Subject Alternative Name' + IP:135.125.201.30, IP:10.42.0.1, DNS:om, DNS:localhost, IP:127.0.0.1 +$ git add -n deploy/tls/ # solo .gitignore, README.md, ca.crt, generate-certs.sh (NO *.key ni server.crt) +$ CGO_ENABLED=0 go test ./pkg/client/ -run TestNatsTLS -v # PASS (con CA -> handshake OK; sin CA -> falla) +$ CGO_ENABLED=0 go test ./pkg/busauth/ -run TestLoadTLS -v # PASS (golden + error: archivo inexistente/no-PEM) +``` + +### Gaps 0001d +- El control plane HTTP sigue en claro (firmado, no TLS); el spec solo pide TLS del data plane NATS. En despliegue público conviene además TLS/HTTP o un proxy — fuera de alcance del issue. + +## Fase 0001e — migrar clientes + bus-auth enforce ✅ (merge `484a07d`) + +### Cambios +| Archivo | Qué | +|---|---| +| `pkg/client/client.go` | `Connect(natsURL, ctrlURL, id, caPath)` — seam único: con `caPath` → TLS+nkey; sin él → legacy plano. Control plane firmado siempre. | +| `cmd/worker/main.go`, `cmd/chat/main.go` | flag `--ca`; usan `client.Connect`. | +| `mobile/unibus.go` | `NewSession(idPath, natsURL, ctrlURL, caPath)` — nuevo parámetro `caPath` (binding gomobile). | +| `cmd/membershipd/main.go` | store antes que NATS; `--tls-cert/--tls-key`; bajo `enforce` activa el authenticator nkey del NATS embebido. | +| `dev/feature_flags.json` | `bus-auth: enforce`, `bus-tls: enabled` (estado objetivo declarado). | +| `playground/server.go`, `dev/0001e-remaining-clients.md` | gateway web + unibots documentados (NO implementados): qué necesitan + flags de servidor para 0001f. | + +### Verificación (evidencia ejecutable) +``` +$ CGO_ENABLED=0 go test ./pkg/client/ -run TestSecureBusEndToEnd -v + PASS golden completo del issue: enforce + nkey + TLS a la vez; A y B registrados + conectan con nkey+TLS, A crea room Matrix, invita B, publica, B descifra. + +$ /tmp/membershipd --bus-auth enforce --tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key ... + [membershipd] NATS nkey authentication: ON (enforce) + [membershipd] NATS TLS: ON (deploy/tls/server.crt) + [membershipd] embedded NATS (JetStream) ready: tls://127.0.0.1:14250 + [membershipd] control-plane auth: enforce +$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18470/healthz # 200 (exento) +$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:18470/rooms/x # 401 (sin firma) +``` + +### Suite completa (estado final 0001a–0001e) +``` +$ CGO_ENABLED=0 go build ./... # OK +$ CGO_ENABLED=0 go vet ./... # limpio +$ CGO_ENABLED=0 go test ./... + ok pkg/busauth ok pkg/client (4.8s) ok pkg/frame ok pkg/membership + 35 sub-tests PASS (--- PASS) entre busauth+membership+client. +``` +Historia TBD: 5 merges `--no-ff` (`e9711bf` 0001a → `89e0d0e` 0001b → `217daae` 0001c → `2ccd11b` 0001d → `484a07d` 0001e). + +### Gaps 0001e +- Gateway web (`playground/`) y unibots NO migrados (documentado en `dev/0001e-remaining-clients.md`): el gateway es herramienta dev local (AuthOff); unibots vive en otro repo (`agents_and_robots`). Migración = `client.Connect(ca.crt)` + registrar identidad en allowlist. +- El binding mobile cambió de firma (`NewSession` ahora pide `caPath`): el app Android debe actualizar la llamada y empaquetar `ca.crt`. + +## Fase 0001f — deploy (humano) ⏳ — RESUMEN PARA EJECUTAR + +El agente entrega 0001a–0001e en master de `dataforge/unibus` + la CA/cert generados en `deploy/tls/` (local, gitignored salvo `ca.crt`). Pasos del humano: + +1. **Cross-build**: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o membershipd ./cmd/membershipd`. +2. **Distribuir certs**: `deploy/tls/server.crt` + `deploy/tls/server.key` a om (`/opt/unibus/tls/`) por canal seguro (NO git). `ca.crt` ya viaja con los clientes. Si la CA generada en este PC no es la definitiva, regenerar en om con `./generate-certs.sh` y redistribuir `ca.crt` a los clientes. +3. **scp** binario + `tls/` + crear dir de datos persistente (`/opt/unibus/local_files/`) para db/blobs/jetstream. +4. **systemd-system unit**: `ExecStart=/opt/unibus/membershipd --bind 0.0.0.0 --bus-auth enforce --tls-cert /opt/unibus/tls/server.crt --tls-key /opt/unibus/tls/server.key --db /opt/unibus/local_files/unibus.db --store-dir /opt/unibus/local_files/blobs --nats-store /opt/unibus/local_files/jetstream`. **`Restart=always`** (NO `on-failure`: un SIGTERM limpio es exit 0 y `on-failure` no reinicia — gotcha conocido del ecosistema). +5. **ufw**: `ufw allow 8470/tcp` (control plane) y `ufw allow 4250/tcp` (NATS TLS). +6. **Seed admin** (CLI local en om, confianza de shell): `./membershipd user add --handle --sign-pub --role admin --db /opt/unibus/local_files/unibus.db`. Registrar también cada peer (worker/chat/mobile/unibots) con `user add`. +7. **Verificar desde fuera de la VPN y desde la WG**: + - handshake TLS: `openssl s_client -connect 135.125.201.30:4250 -CAfile ca.crt` → verify OK. + - `curl` firmado a `/healthz` → 200; `curl` sin firma a un endpoint mutante → 401. + - conexión NATS de un peer NO registrado → rechazada; peer registrado con `--ca ca.crt` → conecta y publica. +8. **unibots local**: systemd-user con `client.Connect(...ca.crt)` (recompilar contra este `pkg/client`) + identidad registrada. + +**Rollback**: bajar a `--bus-auth soft` (verifica y loguea, no corta) o `off` sin redeploy de clientes; quitar `--tls-cert/--tls-key` para volver a NATS plano. Rotación de cert: `deploy/tls/README.md`. diff --git a/reports/0004-2026-06-07-unibus-security-audit.md b/reports/0004-2026-06-07-unibus-security-audit.md new file mode 100644 index 0000000..4ebf85b --- /dev/null +++ b/reports/0004-2026-06-07-unibus-security-audit.md @@ -0,0 +1,111 @@ +# Report 0004 — Auditoría de seguridad del bus unibus (red-team del issue 0001) + +- **Fecha:** 07/06/2026 +- **Autor:** agente auditor (Claude Opus 4.8) — mentalidad adversarial, sin modificar producción +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `busauth`, `membership`, `embeddednats`, `blobstore`, `client`, `cmd/membershipd`, `deploy/tls`. Auditoría de lo entregado en el issue 0001 (fases 0001a–0001e). +- **Estado:** done — auditoría con verificación activa (instancia efímera local + tests adversariales) y limpieza completa. +- **Método:** lectura del código de seguridad + spec + report del implementador (0003), verificación activa levantando un `membershipd` efímero real en `enforce`+TLS (puertos altos, DB en `/tmp`, certs locales) y ejecutando bypasses reales (curl sin firma, tests Go white-box con firmas Ed25519 reales, conexiones NATS con/sin nkey). Todo artefacto efímero fue eliminado; el working tree del sub-repo quedó en su baseline (`git status` sin cambios salvo los issues 0002/0003 ya presentes). + +--- + +## Resumen ejecutivo + +**Veredicto: NO exponer el bus a internet público HOY tal cual. Sí-con-condiciones tras corregir los hallazgos críticos/altos.** + +Las tres capas de seguridad del issue 0001 están **bien construidas en su núcleo criptográfico y de autenticación**: la firma del control plane liga method+path+ts+nonce+sha256(body) y no es portable ni replayable; el anti-replay por ventana ±30s funciona; el nkey de NATS rechaza identidades no registradas y conexiones sin nkey; la revocación es viva (sin reinicio) en ambos planos; el TLS pinea la CA propia y rechaza al cliente que no la tiene; y no hay claves privadas filtradas en git. Eso **resiste** y está verificado con evidencia abajo. + +Pero el issue resolvió **autenticación** y dejó sin resolver **autorización, disponibilidad y confidencialidad de metadata**, que son exactamente lo que un bus *público* necesita: + +1. **DoS trivial pre-auth (CRÍTICO):** un atacante sin credenciales hace que el server bufferice en RAM cuerpos ilimitados *antes* de verificar la firma. 400 MB enviados → 898 MB de RSS. Unas pocas conexiones concurrentes = OOM y caída del bus. +2. **Fail-open por configuración (ALTO):** el binario arranca por defecto `--bus-auth off`; el nkey de NATS solo se activa en `enforce` y el TLS es un flag independiente. Exponer público con TLS pero sin `enforce` deja el bus **completamente abierto** con apariencia de seguro. No hay guard "bind público ⇒ enforce". +3. **Cero aislamiento entre rooms (ALTO):** "autorizado" significa "registrado en el allowlist", no "miembro de la room". Cualquier usuario registrado lee subject, lista de miembros (con claves públicas), el directorio de rooms de cualquiera, e incluso la `sealed_key` ajena — y en el data plane NATS no hay ACL por subject, así que lee/publica en cualquier room (las cleartext en claro). El E2E protege el contenido de las rooms cifradas, nada más. +4. **Control plane en claro (ALTO para exposición pública):** el HTTP `:8470` va firmado pero **sin TLS**. Toda la metadata (subjects, identidades, sealed keys, hashes de blobs, el grafo "quién habla con quién") viaja legible por la red pública. El report 0001 lo marcó "fuera de alcance", pero el despliegue decidido es público. + +Conclusión: la capa de **identidad** del bus es sólida; las capas de **autorización, cuota/anti-DoS y confidencialidad de metadata** faltan. Para un despliegue *solo-WireGuard* el riesgo es asumible (la red ya filtra). Para el despliegue *público* que el issue 0001 habilita (ufw abre 8470/4250 a internet), **NO está listo**: corregir al menos los cuatro puntos de arriba antes de 0001f. + +--- + +## Tabla de hallazgos + +| # | Sev | Vector | Descripción | Evidencia | Fix recomendado | +|---|---|---|---|---|---| +| H1 | **Crítico** | A10 DoS | El middleware `Server.ServeHTTP` hace `io.ReadAll(r.Body)` **sin límite y antes** de `authenticate()`. Un atacante no autenticado fuerza buffering ilimitado en RAM pre-auth. El comentario "bodies are small / already capped upstream" es falso: no hay cap. `handlePutBlob` repite el `io.ReadAll` sin límite. | `curl -X POST :18470/blobs --data-binary @400MB` (sin firma) → `401` pero RSS del proceso saltó de **18 744 kB a 898 004 kB** durante el upload (muestreo `/proc/PID/status`), en 0.6 s. | `http.MaxBytesReader` en el middleware (p.ej. 1 MB control plane); límite separado y mayor para `/blobs` con `Content-Length` rechazado temprano; `Server.MaxHeaderBytes`; rate-limit por IP/identidad; cuota de disco para blobs. | +| H2 | **Alto** | A9 fail-open | `cmd/membershipd/main.go`: nkey auth solo si `authMode==AuthEnforce`; TLS es flag independiente; default `--bus-auth off`. Control plane y NATS comparten `--bind`. Arrancar `--bind 0.0.0.0 --tls-cert … ` sin `--bus-auth enforce` = data plane TLS **sin auth** + control plane **sin auth**. | Test `TestAudit_FailOpenTLSWithoutAuth`: harness con TLS on + authenticator off → cliente **no registrado, sin nkey** `CONNECTED to the TLS data plane`. | Acoplar: si `bind` no es loopback ⇒ exigir `enforce` (o `log.Fatal`). `bus-tls` sin `bus-auth enforce` debe ser error. Default seguro o arranque ruidoso. Considerar mTLS (client certs) como segunda barrera. | +| H3 | **Alto** | A3 autz horizontal | "Autorizado" = "registrado", no "miembro". Los GET no comprueban pertenencia: `/rooms/{id}`, `/rooms/{id}/members` (devuelve `sign_pub`+`kex_pub` de todos), `/members/{endpoint}/rooms`, y `/rooms/{id}/key?endpoint=X` (¡devuelve la `sealed_key` de otro!). | `TestAudit_HorizontalMetadataLeak`: bob (registrado, NO miembro) → `GET /rooms/{id}` `200 {"subject":"secret.subject.payroll"…}`, `/members` `200 [{…sign_pub…kex_pub…}]`, `/members/alice-ep/rooms` `200 […]`, `/rooms/{id}/key?endpoint=alice-ep` `200 {"sealed_key":"…"}`. | Autorización por pertenencia en cada handler de room (consultar `members`); `/rooms/{id}/key` solo para `endpoint == signer`; no exponer member list completa a no-miembros. | +| H4 | **Alto** | A3 sin ACL NATS | El authenticator nkey solo decide "registrado sí/no"; no hay permisos por subject (un solo account, sin `Permissions`). Cualquier registrado se suscribe/publica en cualquier subject. Rooms `ModeNATS` (cleartext) quedan totalmente expuestas entre usuarios; en las E2E se filtra metadata de tráfico. | `TestAudit_NoSubjectACL`: eve (registrada, nunca invitada) se suscribe a la room cleartext de alice y **recibe** `"internal: salary numbers"`. | NATS accounts/permissions por identidad, o subjects derivados+impredecibles + verificación de pertenencia server-side, o cifrar siempre (prohibir `ModeNATS` en deploy público). | +| H5 | **Alto** (público) | A2 MITM | Control plane HTTP `:8470` firmado pero **sin TLS**. La firma da integridad/auth, no confidencialidad: un observador de red lee toda la metadata (subjects, endpoints, claves públicas, sealed keys, hashes de blobs, grafo social) y puede *dropear* requests. El cliente usa `http://` y no fuerza TLS. | Lectura de `client.newSignedRequest` (`ctrlURL+path`, esquema `http`) y `membership.Server` (sin `tls`). El report 0001 lo reconoce como gap "fuera de alcance". | TLS en el control plane (mismo CA propio) o reverse-proxy TLS delante; el cliente debe exigir `https` cuando hay CA. | +| H6 | **Medio** | A3 spoof | `handleCreateRoom` no liga `Owner.SignPub`/`Owner.Endpoint` del body al firmante del control plane (`X-Unibus-Pub`). Un registrado crea rooms a nombre de otra identidad. | `TestAudit_OwnerSpoof`: bob firma; body declara owner=victim → `201`; en DB `owner_endpoint="victim-endpoint-id"`. (Mitiga el daño: invite/rekey exigen firma del owner real, que el spoofer no posee.) | Exigir `Owner.Endpoint == frame.EndpointID(X-Unibus-Pub)` y `Owner.SignPub == pub` en create. | +| H7 | **Medio** | A4 / A10 | **Nonce-cache poisoning pre-auth.** En `authenticate`, `rememberOrReject` corre **antes** de `IsAuthorized`. Cualquiera con un par Ed25519 propio (no registrado) firma válido y puebla el `nonceCache`; las claves son gratis e infinitas. Además la poda es O(n) sobre todo el mapa en cada request bajo un mutex global → amplificación CPU/contención. | `TestAudit_NonceCachePoisonPreAuth`: identidad NO registrada → 1ª req `identity not authorized` (llegó a IsAuthorized), 2ª req mismo nonce → `replayed nonce` ⇒ el nonce del no-autorizado **fue cacheado**. | Mover `IsAuthorized` **antes** de tocar el cache (no recordar nonces de no-autorizados); poda por heap/expiry-bucket en vez de O(n); cap de tamaño del cache; rate-limit. | +| H8 | **Medio** (op) | A6 secretos | Limpio en git: solo `ca.crt` versionado; `*.key`/`*.csr`/`server.crt` gitignored; sin claves privadas en la historia. PERO el `ca.key` de la CA cuyo `ca.crt` está **commiteado** vive en el working tree de este PC; si esa CA es la de producción, comprometer el PC = poder firmar certs y MITM del data plane. | `git ls-files deploy/tls/` → `.gitignore, README.md, ca.crt, generate-certs.sh`. `git log --all` sin `*.key`/`*.crt` salvo `ca.crt`. Working tree tiene `ca.key`/`server.key` (perms 600, gitignored). | Generar la CA **en om** (no en el PC), custodiar `ca.key` offline/en `pass`; tratar el `ca.crt` commiteado como CA de dev y no reusarlo en prod (el README ya lo sugiere). | +| H9 | **Bajo** | A2/A10 disco | `handlePutBlob`: cualquier registrado sube blobs arbitrarios; sin cuota, sin GC, sin ligar el blob a una room/membresía. Exhaustión de disco lenta. `handleGetBlob`: cualquiera con el hash baja el blob (ciphertext, pero filtra tamaño/existencia). | Lectura de `server.go` + `blobstore` (sin límites). El filtro de traversal `ContainsAny(hash,"/\\.")` sí cubre `..`. | Cuota por identidad, GC (encaja con issue 0002), ligar blob→room, límite de tamaño. | +| H10 | **Bajo** | A7 cripto | `cs.SealAEAD` usa `chacha20poly1305` con nonce **aleatorio de 12 bytes** y un único K por epoch. Riesgo de colisión de nonce solo a ~2³² mensajes/epoch (birthday) — irrelevante para chat humano, relevante para rooms de muy alto volumen o agentes. | `functions/cybersecurity/seal_aead.go` (NonceSize 12, `rand.Reader`). | Para volúmenes altos: XChaCha20-Poly1305 (nonce 24 B) o rekey por volumen de mensajes. No urgente. | +| H11 | **Bajo** | A4 replay | La firma de owner del payload (`req.Sig` en invite/rekey) **no incluye nonce/ts**: es replayable a nivel payload. Bajo `enforce` la envuelve el anti-replay del control plane; bajo `soft`/`off` (o si se mueve a otro nodo, ver §descentralización) es replayable. | Lectura de `signRequest`/`verifyOwnerSig` (canonical = JSON con sig=nil, sin nonce). | Incluir nonce+ts en el canonical de la firma de owner, o depender explícitamente del envelope enforce (documentado). | +| H12 | **Bajo** | info-leak | Varios handlers devuelven `err.Error()` interno al cliente (`writeErr(w, …, err.Error())`), filtrando rutas/mensajes SQL. | `handleCreateRoom`/`handleInvite`/… retornan errores crudos. | Mensajes genéricos al cliente; detalle solo al log. | + +--- + +## Confirmaciones (lo que SÍ resiste — verificado, para no dar falsa alarma) + +| Vector | Prueba | Resultado | +|---|---|---| +| A1 — sin credenciales | `curl` sin firma a `/rooms` (POST), `/rooms/abc`, `/members/x/rooms`, `/blobs`, `/admin` | **401** en todos; `GET /healthz` → **200** (único exento, correcto). | +| A4 — firma no portable | `TestAudit_SignatureNotPortable`: firmar `GET /rooms/AAA`, reusar headers en `GET /rooms/BBB` y en `POST /rooms/AAA` | **401 invalid signature** en ambos. El canonical liga method+path+ts+nonce+sha256(body). | +| A4 — replay | Test existente `TestAuthReplayRejected` + reverificado | mismo nonce reenviado → **401 replayed nonce**. | +| A4 — clock skew | `TestAuthClockSkewRejected` (ts −120 s) | **401 timestamp out of range**. Ventana ±30 s; `nonceTTL=60s=2·skew` ⇒ un replay nunca sobrevive a su memoria (análisis confirmado). | +| A4 — tamper body | `TestAuthTamperedBodyRejected` | body alterado tras firmar → **401**. | +| A5 — revocación viva | `TestAudit_RevocationLive` (control plane) + `TestNatsNkeyAuth` (data plane) | tras `RevokeUser` **sin reiniciar**: control plane → **401**; data plane → la próxima conexión nkey rechazada. `IsAuthorized` fail-closed ante error de query. | +| A7 — Ed25519→nkey | `TestNkeyRoundTrip` + lectura `busauth/nkey.go` | nkey deriva de la misma seed Ed25519 (no debilita la clave); `nkey→hex == hex(SignPub)`; round-trip correcto. | +| A7 — TLS pin CA | `TestNatsTLS` + `TestAudit_NatsNkeyEnforced` | cliente con la CA → handshake OK; cliente sin la CA → **falla**; SAN del cert incluye `127.0.0.1` (verificado con openssl). `MinVersion` TLS 1.2. | +| A8 — nkey enforce | `TestAudit_NatsNkeyEnforced` | registrado+nkey → conecta; **nkey no registrado → `Authorization Violation`**; **sin nkey bajo enforce → `Authorization Violation`**. `AlwaysEnableNonce=true` correcto; `Check` fail-closed ante cualquier input malformado. | +| A6 — git secretos | `git ls-files` + `git log --all` | solo `ca.crt` público versionado; sin claves privadas en working tree trackeado ni en la historia; `.gitignore` correcto. | +| SQLi | Lectura de `store.go` | todas las queries parametrizadas (`?`); sin concatenación. | +| Path traversal blobs | `handleGetBlob` | rechaza `/`, `\`, `.` (cubre `..`). | + +--- + +## Recomendaciones priorizadas antes del deploy 0001f + +**Bloqueantes para exposición pública (corregir SÍ o SÍ):** + +1. **H1 — Límite de cuerpo + rate-limit (Crítico).** `http.MaxBytesReader` en el middleware antes de `io.ReadAll`; límite específico y `Content-Length`-aware para `/blobs`; rate-limit por IP. Sin esto, el bus público se tumba con un `curl`. +2. **H2 — Cerrar el fail-open (Alto).** Hacer que `--bind` no-loopback exija `--bus-auth enforce` (y que `--tls-cert` sin `enforce` sea error). Que el arranque inseguro sea imposible o, como mínimo, ruidoso y rechazado. +3. **H3 + H4 — Autorización por pertenencia (Alto).** Comprobar membresía en los handlers de room y, en el data plane, permisos por subject (o prohibir `ModeNATS` en público). Hoy un solo usuario registrado mapea todo el sistema. +4. **H5 — TLS en el control plane (Alto, público).** No exponer `:8470` en claro a internet; TLS propio o proxy. La firma no oculta la metadata. + +**Recomendado antes o justo después (no bloquean WireGuard-only):** + +5. **H6** ligar owner del create al firmante. **H7** mover `IsAuthorized` antes del nonce-cache + poda eficiente. **H9** cuota/GC de blobs. **H12** no filtrar errores internos. + +**Operacional:** + +6. **H8** generar la CA en om, no en el PC; custodiar `ca.key`. Usar `Restart=always` en systemd (ya advertido en el report 0001 — un SIGTERM limpio es exit 0 y `on-failure` no reinicia). + +**Si el deploy se mantiene solo-WireGuard:** H1 y H2 siguen siendo importantes (un peer interno comprometido o un error de bind), pero H3/H4/H5 bajan de prioridad porque la red ya restringe el acceso. La decisión del issue 0001 fue *pública*, así que aplican todos. + +--- + +## Implicaciones para la descentralización (issue 0003) + +El issue 0003 lleva unibus a 3 nodos en cluster con control plane *stateless* sobre JetStream KV y failover de cliente. Eso **amplifica** varios hallazgos de esta auditoría; conviene resolverlos en 0001/0003 conjuntamente (0003 ya declara `depends_on: 0001`): + +- **El anti-replay se ROMPE en multi-nodo (consecuencia directa de H7).** El `nonceCache` es **en memoria por proceso**. Con failover/balanceo entre 3 nodos, un atacante captura una request firmada y la **replaya a otro nodo** cuyo cache no tiene ese nonce → aceptada. El report 0001 ya anota "un despliegue multi-membershipd necesitaría un store compartido"; 0003 lo hace obligatorio. **El nonce-cache debe pasar a un KV replicado con TTL (o un esquema de nonce emitido por server) ANTES de 0003f.** Sin esto, el anti-replay del control plane es nulo en cluster. + +- **Auth de las routes del cluster (nueva superficie).** 0003a abre puertos de cluster (routes) entre nodos. El authenticator nkey actual (`busauth`) autentica **clientes**, no routes. Si las routes quedan sin auth/TLS mutuo, cualquiera que alcance el puerto de cluster inyecta mensajes en todo el bus o se une como nodo falso. Hay que configurar `Cluster.Authorization` + TLS de routes con credenciales **propias de nodo** (no las de cliente), reusando la CA del 0001. No reutilizar el client authenticator para routes. + +- **El fail-open (H2) se multiplica por 3.** Cada nodo arrancado sin `enforce` es un punto público abierto. El propio spec 0003 avisa: "desplegar descentralizado sin auth sería abrir varios puntos públicos sin protección". El guard "bind público ⇒ enforce" de H2 protege también aquí. + +- **`IsAuthorized` sobre KV debe seguir fail-closed.** Al mover `users` a JetStream KV (R3), una pérdida de quorum o timeout del KV debe **denegar** (como hoy con SQLite: error → false), nunca permitir. Verificar explícitamente en `jetstreamStore`. + +- **Custodia de la CA más crítica (H8).** El mismo `ca.key` firmará los certs de los 3 nodos (cada uno con su IP pública en SAN). Su compromiso permite MITM de todo el cluster. Generar y custodiar fuera de los hosts de aplicación. + +- **H3/H4 escalan con el nº de usuarios.** Más nodos suele implicar más usuarios registrados; la fuga de metadata horizontal y la ausencia de ACL por subject convierten a cada usuario en un observador global del grafo. La autorización por pertenencia es aún más necesaria en un despliegue grande. + +--- + +## Gaps / pendientes de esta auditoría (honestidad) + +- **No se probó MITM real con un proxy en la red** (H5 se argumenta por lectura de código: control plane `http://` sin TLS). El razonamiento es directo pero no hay captura de tráfico adjunta. +- **No se midió el DoS bajo concurrencia** (N conexiones simultáneas de varios GB) — se demostró la amplificación de memoria con una sola request (400 MB → 898 MB RSS), suficiente para evidenciar el vector; el OOM bajo carga concurrente es la extrapolación. +- **No se auditaron los clientes no migrados** (`playground/` gateway, unibots en `agents_and_robots`, app Android `mobile/`) más allá de constatar que el report los declara pendientes (`dev/0001e-remaining-clients.md`). Un cliente que conecte en claro/sin nkey reabre superficie. +- **No se ejecutó análisis estático de dependencias** (`govulncheck`) sobre `nats-server v2.10.22` / `nats.go v1.37.0` / `modernc.org/sqlite` — recomendable como paso aparte. +- La verificación activa usó tests Go white-box (requieren firmas Ed25519 reales) además de `curl`; son requests HTTP/NATS reales contra un server real, equivalentes a un atacante externo, pero ejecutados in-proceso. Los artefactos de prueba fueron **eliminados** y el sub-repo quedó en baseline. diff --git a/reports/0005-2026-06-07-unibus-security-hardening.md b/reports/0005-2026-06-07-unibus-security-hardening.md new file mode 100644 index 0000000..891ac0a --- /dev/null +++ b/reports/0005-2026-06-07-unibus-security-hardening.md @@ -0,0 +1,231 @@ +# 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. diff --git a/reports/0006-2026-06-07-unibus-decentralization.md b/reports/0006-2026-06-07-unibus-decentralization.md new file mode 100644 index 0000000..3b38a86 --- /dev/null +++ b/reports/0006-2026-06-07-unibus-decentralization.md @@ -0,0 +1,231 @@ +# Report 0006 — unibus: descentralización / alta disponibilidad (issue 0003, fases 0003a–0003e) + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Opus 4.8) +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `embeddednats`, `busauth`, `membership`, `blobstore`, `client`; `cmd/membershipd`; `dev/feature_flags.json`. +- **Estado:** done — entrega 0003a–0003e en `master` del sub-repo. El despliegue multi-nodo real (0003f) lo ejecuta el humano; al final hay un runbook. +- **Origen:** issue `dev/issues/0003-decentralization-ha.md`. Cierra además el residual H4 (ACL por subject) que el hardening (report 0005) difirió a este issue, y el agujero de anti-replay multi-nodo que la auditoría (report 0004) señaló como bloqueante antes del cluster. + +## Resumen + +unibus deja de ser un SPOF: el servidor NATS embebido puede formar cluster (data plane replicado), el control plane y los blobs pueden vivir en JetStream KV / Object Store replicados, el cliente hace failover entre nodos, y el anti-replay pasa a un store de nonces compartido. Todo está construido por **branch-by-abstraction** detrás del flag `decentralized` (off): el comportamiento de un solo nodo (SQLite + disco) no cambia y `master` sigue desplegable en cada paso. + +El issue marca el rollout R1→R3: con 2 nodos (magnus + homer) se despliega en R1 (réplicas=1, sin tolerancia a fallo todavía); cuando entre el tercer nodo se escala a R3 (`nats stream/kv update --replicas 3`) para HA real (quorum 2/3). El código soporta réplicas configurables; el despliegue elige el número. + +Historia (vista `git log --first-parent master`): + +``` +da42051 Merge issue/0003e-client-failover: client failover + replicated nonce store + subject ACL (H4) +649dc9e Merge issue/0003d-objectstore: replicated blobs on NATS Object Store +94e7ced Merge issue/0003c-migrate-kv: idempotent SQLite->KV migration + backup +b8c9b2b Merge issue/0003b-jetstream-store: Store interface + JetStream KV backend (fail-closed) +3230b31 Merge issue/0003a-cluster: NATS cluster routes (auth + mutual TLS) +``` + +Verificación global (sin caché, en `master` tras los 5 merges): + +``` +$ CGO_ENABLED=0 go build ./... # OK +$ CGO_ENABLED=0 go vet ./... # limpio +$ CGO_ENABLED=0 go test -count=1 ./... + ok cmd/membershipd 0.004s + ok pkg/blobstore 0.096s + ok pkg/busauth 0.007s + ok pkg/client 5.857s + ok pkg/embeddednats 6.559s + ok pkg/frame 0.002s + ok pkg/membership 3.059s +``` + +--- + +## Fase 0003a — Cluster NATS (routes con auth + TLS mutuo) ✅ + +**Qué.** `pkg/embeddednats.ServerConfig` gana `ServerName` (único por nodo, lo exige el RAFT de JetStream) y un `*ClusterConfig` opcional: nombre de cluster, host/puerto del listener de routes, URLs de los otros nodos, secreto de routes (`Username`/`Password` → `Cluster.Authorization`) y un `*tls.Config` de TLS mutuo. `Cluster` nil mantiene el servidor standalone (comportamiento legacy). + +**Seguridad de routes (clave, del report de auditoría 0004).** Las routes son una frontera de confianza **server-to-server**, distinta del plano de clientes: autentican NODOS, no usuarios del bus. NO se reutiliza el authenticator nkey de clientes. `busauth.RouteTLSConfig(cert, key, ca)` construye el TLS mutuo: el nodo presenta su certificado firmado por la CA **y** verifica el del nodo entrante contra la misma CA (`RequireAndVerifyClientCert`), reusando la CA del issue 0001. Un nodo sin el secreto de cluster y sin un certificado firmado por la CA no puede unirse ni inyectar mensajes. + +`cmd/membershipd` gana las flags de cluster (`--cluster-name/--server-name/--cluster-port/--routes/--cluster-user/--cluster-pass/--route-tls-cert/-key/-ca`). `validateClusterConfig` rechaza un cluster en bind público sin secreto de routes y sin TLS mutuo completo, y rechaza flags de route-TLS parciales (all-or-nothing). + +**Evidencia (DoD: golden + 2 edge + 2 error path):** + +``` +$ CGO_ENABLED=0 go test ./pkg/embeddednats/ -run TestCluster -v + --- PASS: TestClusterForwardsAcrossNodes (2 nodos: subject publicado en n1 llega a un suscriptor en n0) + --- PASS: TestClusterThreeNodesForward (3 nodos, forma HA: publish en n2 llega a n0) + --- PASS: TestClusterMutualTLSForwards (forwarding sobre routes con TLS mutuo) + --- PASS: TestClusterRejectsBadRouteAuth (password de cluster incorrecto -> 0 routes; el cluster legítimo no cambia) + --- PASS: TestClusterRejectsUnsignedNode (cert no firmado por la CA -> 0 routes) +$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestClusterConfigPolicy|TestSplitRoutes' # PASS +``` + +**Gaps.** Los tests son in-process (varios `nats-server` embebidos en un proceso). El forwarding entre `membershipd`s reales en 3 VPS + escalado a R3 es 0003f (humano). `NumRoutes()` cuenta conexiones del pool de routes, no peers; los tests de rechazo comparan contra un baseline estabilizado en vez de un número fijo. + +--- + +## Fase 0003b — Interfaz Store + jetstreamStore (KV) ✅ + +**Qué.** `membership.Store` pasa a interfaz (branch-by-abstraction). La implementación SQLite se renombra `sqliteStore` y sigue siendo el default (`Open(path)` la devuelve). `ErrNotFound` es un sentinel agnóstico del backend (la SQLite mapea `sql.ErrNoRows` a él; el control plane ya no importa `database/sql`). `jetstreamStore` (nuevo) implementa `Store` sobre cinco buckets KV replicados — `rooms`, `members`, `rooms_by_member` (índice inverso para `ListRoomsForEndpoint`), `room_keys`, `users` — con réplicas configurables (R1..R5). **Fail-closed**: cada lectura está acotada por `OpTimeout` e `IsAuthorized`/`HasAdmin` devuelven `false` ante cualquier error de backend (una pérdida de quorum del KV deniega, nunca admite), exactamente lo que la auditoría exigía. + +Flag `decentralized` (off) añadido a `dev/feature_flags.json`. + +**Evidencia (DoD: golden + edges + error path):** + +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestJetStreamStore -v + --- PASS: TestJetStreamStoreRoomsCRUD (room cifrada + owner + invitado; latest-epoch + rekey) + --- PASS: TestJetStreamStoreUsers (add/get/authorize/list/revoke + gate admin, normalización case-insensitive, duplicado rechazado) + --- PASS: TestJetStreamStoreNotFound (mapeo ErrNotFound en misses) + --- PASS: TestJetStreamStoreIsAuthorizedFailClosed (NATS apagado -> IsAuthorized y HasAdmin DENIEGAN dentro del timeout) +``` + +**Gaps.** KV no tiene transacción multi-key: `CreateRoom`/`AddMember` son secuencias de Puts idempotentes con orden recoverable; documentado en el código. El **wiring de arranque** de `membershipd`-en-KV (selección del store según flag) se difiere a 0003e/0003f: tiene un ciclo bootstrap (la conexión interna de `membershipd` al NATS embebido debería autenticarse contra el mismo store que aún no existe), que pertenece al rediseño de sesión/deploy. El `jetstreamStore` queda completo, testeado y consumido inmediatamente por `migrate-to-kv` (0003c). + +--- + +## Fase 0003c — migrate-to-kv (idempotente, con backup) ✅ + +**Qué.** `Snapshot`/`SealedKeyRecord` (volcado agnóstico del control plane: rooms con su epoch real, members, todas las filas de sealed-key por epoch, users con status). `ExportSnapshot` en ambos backends; `importSnapshot` del KV escribe con Puts crudos (conserva epoch/status, no resetea a defaults), así que la migración es fiel e idempotente (cada escritura es un overwrite → re-ejecutar converge). `MigrateSQLiteToKV` orquesta export→import; `BackupSQLite` hace una copia consistente con `VACUUM INTO` antes de migrar. Comando `membershipd migrate-to-kv --db --nats-url [--replicas N] [--ca ] [--no-backup]`. + +**Evidencia (DoD: paridad + idempotencia + backup) + smoke del binario:** + +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestMigrate|TestBackup' -v + --- PASS: TestMigrateSQLiteToKVParity (2 rooms una rekeyed a epoch 2 + user revocado; KV == SQLite tras migrar) + --- PASS: TestMigrateSQLiteToKVIdempotent (migrar dos veces -> mismo estado KV) + --- PASS: TestBackupSQLiteCreatesConsistentCopy (el backup reabre con los mismos datos) + +$ # smoke del binario (seed user -> server -> migrate -> re-run) +$ membershipd user add --handle alice --sign-pub <64hex> --role admin --db unibus.db +$ membershipd --bind 127.0.0.1 --http-port 18888 --nats-port 14888 --db unibus.db ... & +$ membershipd migrate-to-kv --db unibus.db --nats-url nats://127.0.0.1:14888 --replicas 1 + backed up unibus.db -> unibus.db.bak. + migrated to KV (replicas=1): 0 rooms, 0 members, 0 keys, 1 users +$ membershipd migrate-to-kv ... --no-backup # re-run idempotente: 1 users, igual +``` + +**Gaps.** El comando conecta al NATS con `--nats-url` (+ `--ca` opcional para TLS); la autenticación de la conexión interna con nkey de nodo en un cluster `enforce` se afina en el deploy 0003f. + +--- + +## Fase 0003d — Blobs replicados (NATS Object Store) ✅ + +**Qué.** `blobstore.Store` pasa a interfaz. El backend de disco se renombra `diskStore` (default). `objectStore` (nuevo) implementa `Store` sobre un bucket NATS Object Store con réplicas configurables (R1..R5). El direccionamiento por contenido (sha256-hex) es idéntico, así que el contrato de wire no cambia y un `BlobRef` es portable entre backends. `membership.Server.blobs`/`NewServer` toman la interfaz. + +**Evidencia (DoD: golden + edge + contrato):** + +``` +$ CGO_ENABLED=0 go test ./pkg/blobstore/ -v + --- PASS: TestObjectStoreRoundTrip (put/get/has + dedup content-addressed) + --- PASS: TestObjectStoreMissing (hash desconocido ausente e ilegible) + --- PASS: TestObjectStoreAddressMatchesDisk (Object Store y disco direccionan los mismos bytes al MISMO hash) +``` + +**Gaps.** Como el KV store, la selección del Object Store en `membershipd` se difiere al boot path decentralized; disco sigue default. Sin cuota/GC (eso es el issue 0002 / H9). + +--- + +## Fase 0003e — Cliente failover + nonce replicado + ACL por subject (H4) ✅ + +Tres componentes, tres commits atómicos en la rama. + +### 1. Failover de cliente + +`Options.NatsServers` (seeds extra) y `Options.CtrlURLs` (control planes extra). El cliente conecta a la lista unida con `MaxReconnects(-1)` + `RetryOnFailedConnect`, así nats.go hace failover a un nodo vivo cuando cae el que tenía. `doJSON`/`putBlob`/`getBlob` prueban cada control plane en orden (failover de transporte; cada intento firma con nonce fresco, nunca es replay). `New/Connect/NewWithOptions` mantienen su firma (worker/chat/mobile/playground intactos). `ConnectedServer()`/`IsConnected()` para observabilidad. + +``` +$ CGO_ENABLED=0 go test ./pkg/client/ -run TestClientFailoverAcrossNodes -v + --- PASS (A suscrito en n0 recibe; se MATA el nodo de A; A reconecta al superviviente y SIGUE recibiendo — sesión intacta) +``` + +### 2. Anti-replay replicado (nonce store en KV) + +El `nonceCache` por-proceso rompe el anti-replay en multi-nodo: un request capturado y reenviado a OTRO nodo (cuyo cache nunca vio el nonce) se aceptaría. `nonceStore` pasa a interfaz; `memNonceCache` (default) + `kvNonceStore` nuevo que reclama cada nonce con un `Create` atómico en un bucket compartido (primer uso gana; cualquier uso posterior en cualquier nodo rechaza). Error de backend → **fail closed** (rechaza). TTL del bucket = `nonceTTL` (2·clockSkew). `Server.UseReplicatedNonces(js, replicas)` lo activa por nodo. + +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestReplicatedNonceRejectsCrossNodeReplay -v + --- PASS (request aceptada (200) en nodo A; mismo ts+nonce reenviado a nodo B -> 401 replayed; replay a A de nuevo -> 401) +``` + +### 3. ACL por subject derivada de pertenencia (residual H4) + +`busauth.NewNkeyAuthenticatorACL` + `PermissionsFunc`: el authenticator, tras autorizar, deriva y `RegisterUser()`a permisos por subject; error de derivación → deniega (fail closed). `membership.SubjectACLFor(store)` mapea una identidad a los subjects de sus rooms + la infraestructura de cliente (`_INBOX.>`, `$JS.API.>`). `client.RefreshSession()` reconecta el data plane para que el authenticator re-derive permisos tras un cambio de membresía (NATS congela permisos al conectar). + +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestSubjectACLIsolation|TestRefreshSessionGainsNewRoom' -v + --- PASS: TestSubjectACLIsolation (alice miembro de room.A: sub/pub room.A OK; sub y pub room.B -> permissions violation; + alice nunca lee el tráfico de bob en room.B; bob nunca recibe el publish cruzado de alice) + --- PASS: TestRefreshSessionGainsNewRoom (alice sin permiso para room B hasta que se la añade y llama RefreshSession; el reconnect + le concede el subject y entonces recibe el tráfico de room B) +``` + +**Estado del residual H4.** El aislamiento por subject derivado de pertenencia está **implementado y demostrado** a nivel de data plane: un peer registrado ya no puede sub/pub en subjects arbitrarios, queda confinado a las rooms a las que pertenece, y el contenido de las demás (ya E2E desde el hardening) le es invisible incluso como metadata de subject. El authenticator ACL es **opt-in** (`NewServer`/`membershipd` mantienen el authenticator abierto por defecto) y se cablea con el boot path decentralized. Lo que queda para 0003f/futuro: el **refresco automático y transparente** de permisos en cada cambio de membresía — hoy `RefreshSession` es una llamada explícita que el peer hace tras unirse a una room (porque NATS congela permisos al conectar, el flujo "connect → create → publish en la misma conexión" requiere un refresh entre el create y el publish). Esto es deliberado para no romper el flujo legacy ni los tests existentes; se activa con el rediseño de sesión del deploy. + +--- + +## Cobertura de tests (DoD) + +| Fase | Tests nuevos | Tipo | +|---|---|---| +| 0003a | `TestCluster*` (5) + `TestClusterConfigPolicy` + `TestSplitRoutes` | golden + 2 edge + 2 error path | +| 0003b | `TestJetStreamStore*` (4) | golden + edges + fail-closed | +| 0003c | `TestMigrate*` (2) + `TestBackup*` + smoke binario | paridad + idempotencia + backup | +| 0003d | `TestObjectStore*` (3) | golden + edge + contrato | +| 0003e | `TestClientFailoverAcrossNodes`, `TestReplicatedNonceRejectsCrossNodeReplay`, `TestSubjectACLIsolation`, `TestRefreshSessionGainsNewRoom` | failover + replay + aislamiento + refresh | + +Todo el suite previo sigue verde: el comportamiento de un solo nodo (SQLite + disco, authenticator abierto, nonce en memoria) no cambia. + +--- + +## Resumen para 0003f (despliegue multi-nodo — lo ejecuta el humano) + +Pre-requisitos del issue: alta del alias SSH + clave de cada nodo, integración en la WireGuard, y revisar/aligerar la carga existente (magnus corre coolify/minio/postgres/etc.). Nodos hoy: **magnus** y **homer** (`141.94.69.66`); falta el **tercero** para quorum real. + +### Despliegue R1 (2 nodos, magnus + homer — funciona, sin tolerancia a fallo todavía) + +1. **CA y certificados de nodo.** Generar (o reusar) la CA del 0001 (`deploy/tls/ca.crt` + `ca.key` custodiada **fuera** de los hosts de aplicación, en `pass`). Emitir un certificado de **route** por nodo cuyo SAN cubra la IP/host de su puerto de cluster, firmado por esa CA (sirve para server y client auth en el handshake mutuo de routes). +2. **Secreto de cluster.** Un `--cluster-user`/`--cluster-pass` compartido por los dos nodos (en `pass`). +3. **Arrancar cada `membershipd`** con bind público + `--bus-auth enforce` + TLS del data plane (0001) + cluster: + ``` + membershipd --bind --bus-auth enforce \ + --tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key \ + --cluster-name unibus --server-name --cluster-port 6250 \ + --cluster-user "$CL_USER" --cluster-pass "$CL_PASS" \ + --route-tls-cert deploy/tls/.crt --route-tls-key deploy/tls/.key --route-tls-ca deploy/tls/ca.crt \ + --routes nats://$CL_USER:$CL_PASS@:6250 + ``` + `validateClusterConfig` exige el secreto + TLS mutuo completo en bind público (rechaza arrancar inseguro). +4. **systemd `Restart=always`** en cada nodo (un SIGTERM limpio es exit 0; `on-failure` no reiniciaría — gotcha conocido). Reusar `deploy/unibus-membershipd.service` + `deploy/install.sh`. +5. **Streams/KV/Object Store en R1** mientras solo haya 2 nodos (réplicas=1): el data plane efímero (core-NATS) ya tolera la caída de uno (los clientes reconectan al otro); el control plane KV y las rooms persistentes NO toleran caída hasta R3. +6. **Clientes:** pasarles la lista de los 2 endpoints — `Options{NatsServers: [n2], CtrlURLs: [ctrl2]}` además del primario — para que el failover de cliente (0003e) funcione. + +> Nota sobre el flag `decentralized`: en R1 con 2 nodos se puede operar el control plane aún en SQLite por-nodo (cada `membershipd` su BD) si no se quiere KV todavía; para que cualquier nodo sirva cualquier request hay que activar el control plane KV (`decentralized: on`) — eso requiere completar el wiring de arranque KV (selección del store + bootstrap del authenticator interno) que esta entrega dejó documentado como el paso de integración de 0003f. La migración de los datos existentes la hace `membershipd migrate-to-kv` (probado) **antes** de activar el flag. + +### Escalado a R3 (cuando entre el tercer nodo — HA real) + +1. Preparar el tercer nodo (SSH + WireGuard + cert de route firmado por la CA). +2. Añadirlo al cluster (su `--routes` apunta a magnus+homer y viceversa; mismo `--cluster-name` y secreto). +3. Escalar en caliente, sin downtime ni migración de datos: + ``` + nats stream update --replicas 3 + nats kv update --replicas 3 # rooms/members/rooms_by_member/room_keys/users/nonces + nats obj update UNIBUS_blobs --replicas 3 + ``` + o crear los buckets con `Replicas: 3` desde el inicio en el tercer nodo (el código ya lo soporta vía `JetStreamConfig.Replicas` / `ObjectStoreConfig.Replicas`). +4. Activar `decentralized: on` en `dev/feature_flags.json` (con el wiring de arranque KV completo). +5. **Chaos test real (DoD del issue):** 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). + +### No olvidar en 0003f + +- **Nonce replicado obligatorio antes de exponer el cluster:** llamar `Server.UseReplicatedNonces(js, replicas)` en cada nodo. Sin esto el anti-replay es nulo en multi-nodo (un replay a otro nodo se acepta). Probado en `TestReplicatedNonceRejectsCrossNodeReplay`. +- **ACL por subject:** si se quiere el aislamiento de data plane en producción, usar `busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, perms)` con `perms` derivado de `membership.SubjectACLFor(store)`, y que los clientes llamen `RefreshSession()` tras unirse a una room. Si no se activa, el residual sigue siendo: un peer registrado observa metadata de tráfico (no contenido) de subjects que conozca y puede spamear bytes descartados — irrelevante en un despliegue solo-WireGuard. + +--- + +## Gaps / pendientes honestos de este trabajo + +- **No hay despliegue real ni chaos test sobre 3 VPS** — todo se valida in-process (varios `nats-server` embebidos en un proceso). El chaos test real (matar 1/3, matar 2/3) es 0003f. +- **El wiring de arranque `membershipd`-en-decentralized no se cableó** (selección del store/blobstore KV + bootstrap del authenticator interno). El `jetstreamStore`, el `objectStore`, el `kvNonceStore` y el authenticator ACL están completos y testeados, pero el binario solo los expone como API; conectarlos en `main.go` (con el flag) pertenece a la integración de 0003f por el ciclo bootstrap descrito. Es branch-by-abstraction correcto (flag off, master verde), pero significa que `--decentralized` no es todavía un interruptor del binario. +- **KV no es transaccional:** `CreateRoom`/`AddMember` son secuencias de Puts idempotentes; una caída a mitad deja un estado parcial recoverable (no corrupto), no atómico. Aceptable para el control plane (baja frecuencia, owner-serializado). +- **El refresco de ACL no es transparente:** `RefreshSession` es una llamada explícita; el auto-refresh en cada cambio de membresía (rehaciendo suscripciones) queda para el rediseño de sesión. +- **No se ejecutó `govulncheck`** sobre nats-server/nats.go/modernc (paso de CI aparte, ya anotado en el report 0005). diff --git a/reports/0006-2026-06-07-unibus-security-reaudit.md b/reports/0006-2026-06-07-unibus-security-reaudit.md new file mode 100644 index 0000000..a42d709 --- /dev/null +++ b/reports/0006-2026-06-07-unibus-security-reaudit.md @@ -0,0 +1,217 @@ +# Report 0006 — Re-auditoría de seguridad del bus unibus (verificación del fix 0004 + cobertura ampliada) + +- **Fecha:** 07/06/2026 +- **Autor:** agente auditor (Claude Opus 4.8) — red-team adversarial, sin modificar producción +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Verificación de los fixes del issue 0004 (H1–H7, H12) sobre los hallazgos del report 0004, más cobertura nueva: `govulncheck`, fuzz del frame parser, DoS bajo concurrencia, clientes no migrados, y vectores nuevos introducidos por el propio fix. +- **Commit auditado:** `618f6b6` ("chore(0004): close issue, bump unibus to 0.5.0") — el HEAD de master cuando empezó esta re-auditoría. +- **Método:** auditoría sobre un **clon aislado en `/tmp`** del commit `618f6b6` (ver §"Nota de proceso": el working tree del sub-repo estaba siendo modificado en paralelo por otro agente y en un momento no compilaba). Build + suite verdes en el clon; instancia efímera `membershipd` real en `enforce` + TLS de control plane y de datos (puertos altos, DB en `/tmp`, certs generados con `deploy/tls/generate-certs.sh`); bypasses reales (curl, tests Go adversariales white-box, conexiones NATS raw con nkey). Todo artefacto efímero eliminado; el working tree del sub-repo real **no fue tocado** por esta auditoría. + +--- + +## Resumen ejecutivo + +**El fix 0004 cerró los cuatro hallazgos de alto riesgo del report 0004 — verificado con evidencia.** Quedan, sin embargo, motivos para NO exponer aún el bus a internet público: aparecieron tres bloqueantes nuevos (dependencias con CVEs, DoS por concurrencia, spoof por firma omitida) y dos gaps en el propio fix. + +**Veredicto: sigue siendo NO-aún para exposición pública, pero mucho más cerca.** El trabajo de hardening fue real y de calidad; los hallazgos restantes son acotados y con fix claro. + +Cerrados y verificados (report 0004 → fix 0004): + +| Original | Estado | Evidencia resumida | +|---|---|---| +| H1 DoS pre-auth ilimitado | **CERRADO** | 400 MB sin firma → `413` con RSS plano (antes 898 MB). | +| H2 fail-open por config | **CERRADO** | `validateBootConfig` aborta público-sin-enforce y TLS-sin-enforce. | +| H3 autz horizontal | **CERRADO** | no-miembro → `403` en room/members/others-rooms/others-key. | +| H5 control plane MITM | **CERRADO (capacidad)** | control plane sirve HTTPS; cliente fuerza `https://` y pinea la CA. | +| H6 owner spoof | **CERRADO** | crear room a nombre de otro → `403`. | +| H7 nonce-cache poison | **CERRADO** | `IsAuthorized` antes del cache; poda O(expired) + cap. | +| H12 error leak | **CERRADO** | `writeServerErr` registra el detalle y devuelve mensaje genérico. | + +Pendientes / nuevos (esta re-auditoría): + +| # | Sev | Qué | +|---|---|---| +| N1 | **Alto** | 14 CVEs en `nats-server v2.10.22` (servidor embebido, expuesto público) + 2 en la stdlib de Go. Fix disponible. | +| N2 | **Medio-Alto** | DoS por concurrencia: el límite por-request (16 MiB) + rate-limit per-IP NO acotan la memoria agregada. 40 subidas simultáneas → 1.42 GB RSS. | +| N3 | **Alto** (cleartext-signed) / Medio (E2E) | Spoof por firma omitida: un frame con `Sig==nil` salta la verificación en una room `SignMsgs` → suplantación de `Sender`. | +| N4 | **Medio** | El control plane TLS (H5) no se fuerza: público + enforce + **sin** `--tls-cert` arranca el control plane en HTTP plano. | +| H4 | **Medio** (residual conocido) | Sin ACL por subject en NATS: un registrado con `Subscribe(">")` capta subjects + metadata de todas las rooms. Mitigado parcialmente (E2E forzado en público), no cerrado. | + +--- + +## Verificación de los fixes (regresión, con evidencia ejecutable) + +### H1 — DoS pre-auth → CERRADO + +`Server.ServeHTTP` ahora: rate-limit per-IP primero → rechazo por `Content-Length` sobre el techo → `http.MaxBytesReader` antes de `io.ReadAll`. Techos: 1 MiB control / 16 MiB blobs. `http.Server.MaxHeaderBytes` 1 MiB. + +``` +# 400 MB sin firma, con Content-Length: +H1a code: 413 body: {"error":"request body too large"} RSS base 20988 kB -> peak 21216 kB (antes: 898 004 kB) +# 400 MB chunked (sin Content-Length): MaxBytesReader corta +H1b code: 413 RSS peak 58 096 kB +# rate-limit: 80 GET rápidos desde 1 IP +H1c -> 58×401 + 22×429 +``` + +La amplificación de memoria por request quedó eliminada (≈ +0.2 MB con Content-Length; el chunked deja un transitorio acotado de ~37 MB). **Pero ver N2: la concurrencia no está acotada.** + +### H2 — fail-open → CERRADO + +`cmd/membershipd/config.go::validateBootConfig` (función pura, testeada) rechaza el arranque inseguro: bind no-loopback sin `enforce`, y `--tls-cert/--tls-key` sin `enforce`. `isLoopbackBind` trata `""` y los hostnames no resolubles como público (conservador). Verificado: público+soft y tls+off → error. **Pero ver N4: no exige TLS en bind público.** + +### H3 — autorización por pertenencia → CERRADO + +`requireMember` + `signerEndpoint` (del contexto, sólo tras autenticar). Aplicado en `GET /rooms/{id}`, `/members`, `/members/{ep}/rooms` (ep==signer), `/rooms/{id}/key` (endpoint==signer + member). `handleCreateRoom` liga el owner al firmante. + +``` +H3 owner-room -> 200 (el miembro sí accede) +H3 bob-room -> 403 forbidden: not a member of this room +H3 bob-members -> 403 forbidden: not a member of this room +H3 bob-others-rooms -> 403 forbidden: may only list your own rooms +H3 bob-others-key -> 403 forbidden: may only fetch your own sealed key +H3 bob-own-rooms -> 200 (su propio directorio sigue accesible) +``` + +### H5 — control plane TLS → CERRADO (capacidad) + +`main.go` sirve `ListenAndServeTLS` cuando hay `--tls-cert`; `client.Connect` **rechaza** un `ctrlURL` no-`https://` cuando se aporta CA y pinea la CA en el `http.Transport`. + +``` +http plano a puerto TLS -> 400 ; https + CA -> 200 ; https sin CA -> handshake fail +``` + +### H6 — owner spoof → CERRADO + +``` +H6 spoof create (bob firma, owner=victim) -> 403 forbidden: room owner must be the authenticated signer +``` + +### H7 — nonce-cache poison → CERRADO + +`authenticate` mueve `IsAuthorized`/`GetUser` **antes** de `rememberOrReject`; el cache pasó a poda O(expired) con orden de inserción == orden de expiración + cap 100 000. + +``` +H7 unregistered 1st -> 401 identity not authorized +H7 unregistered 2nd -> 401 identity not authorized (NO "replayed nonce": el no-autorizado nunca entra al cache) +H7 authorized replay -> 1ª pasa auth, 2ª 401 replayed nonce (anti-replay sigue intacto) +``` + +### H12 — error leak → CERRADO + +`writeServerErr` registra el detalle interno (SQL/paths) y devuelve `"internal error"` genérico al cliente. + +--- + +## Hallazgos nuevos / residuales + +### N1 — Dependencias con CVEs conocidas (Alto) + +`govulncheck ./...` sobre el clon: **"Your code is affected by 16 vulnerabilities from 1 module and the Go standard library."** + +- **14 en `github.com/nats-io/nats-server/v2@v2.10.22`** — el servidor NATS embebido, que en el despliegue decidido queda **expuesto a internet**. IDs: GO-2026-4841/4837/4836/4835/4834/4833/4832/4831/4830/4829/4828/4827/4533, GO-2025-3600. Fix: **bump a `v2.11.15`** (cubre todas). +- **2 en la stdlib (go1.26.3):** GO-2026-5039 (`net/textproto` — parsing de cabeceras HTTP, relevante al control plane) y GO-2026-5037 (`crypto/x509` — validación de certificados, relevante al TLS). Fix: **toolchain go1.26.4**. + +`govulncheck` reporta traces que **alcanzan** el código vulnerable (no son sólo "presentes"). Para un bus público es bloqueante: actualizar antes de 0001f/0003f. + +### N2 — DoS por concurrencia (Medio-Alto) + +El fix H1 acota cada request, pero **no la memoria agregada**: el rate-limit es por-IP (token bucket burst 40) y no hay límite de conexiones concurrentes ni de bytes en vuelo. Subiendo blobs de 16 MiB (el techo) en paralelo: + +``` +40 subidas de 16 MiB simultáneas desde 1 IP, sin firma -> RSS base 22 336 kB -> peak 1 419 036 kB (~1.42 GB) +``` + +40 = el burst per-IP, así que la ráfaga entra entera antes de throttle. Desde varias IPs (botnet) escala sin techo: el rate-limit per-IP no defiende contra distribuido ni acota memoria total. **Fix:** límite global de conexiones concurrentes y/o de bytes-en-vuelo; streamear el blob a disco en vez de `io.ReadAll` en RAM; o bajar `maxBlobBytes`. (Se combina con H9: blobs sin cuota.) + +### N3 — Spoof por firma omitida en rooms firmadas (Alto en cleartext-signed / Medio en E2E) + +`pkg/client/client.go::processFrame`: `if info.Policy.SignMsgs && f.Sig != nil { verify... }`. La verificación **sólo corre si el frame trae firma**. Un atacante con acceso al data plane publica un frame con `Sig==nil` y `Sender` forjado, y el receptor lo acepta como auténtico. + +``` +TestReaudit_SigNilSpoof: + alice crea room {Encrypt:false, SignMsgs:true}, invita a bob; + atacante (nkey registrado) publica RAW un frame Sig=nil, Sender="victim-..."; + -> "SIG-NIL SPOOF CONFIRMED: receiver ACCEPTED an unsigned frame with forged Sender in a SignMsgs room" +``` + +Bug **preexistente** (no introducido por 0001/0004) pero relevante: en una room que exige firma, un frame sin firma debe **dropearse**, no aceptarse. En rooms `SignMsgs` sin cifrar (permitidas fuera de un bind público) cualquier peer con acceso al subject suplanta a cualquiera; en rooms E2E el atacante necesita ser miembro (tener K para cifrar), por lo que el riesgo es miembro-suplanta-a-miembro. **Fix:** `if SignMsgs { if f.Sig == nil { return }; verify(...) }`. + +### N4 — Control plane TLS no forzado en bind público (Medio) + +El guard de H2 cierra "TLS sin enforce" y "público sin enforce", pero **no** exige TLS en un bind público: + +``` +TestGap_PublicEnforceNoTLS: + validateBootConfig("0.0.0.0", AuthEnforce, "", "") == nil + -> "public bind + enforce + NO --tls-cert is ALLOWED -> control plane serves PLAINTEXT HTTP publicly" +``` + +Es decir, H5 está disponible pero no obligatorio: un operador que despliega público con `enforce` y olvida `--tls-cert` expone el control plane en HTTP plano (metadata en claro, el hallazgo H5 reaparece). **Fix:** el guard debe exigir `--tls-cert/--tls-key` cuando el bind no es loopback. + +### H4 — Sin ACL por subject en NATS (Medio, residual conocido) + +El fix `RequireEncryptedRooms` (prohibir cleartext en bind público) protege el **contenido**, no la **metadata** del data plane. Un registrado con conexión NATS raw y `Subscribe(">")` recibe todo: + +``` +TestReaudit_H4_WildcardMetadataLeak (room E2E ModeMatrix de alice; eve registrada, NO miembro): + eve '>' capturó 13 frames, subjects: [ room.e2e.confidential, $JS.API.STREAM.CREATE.UNIBUS_..., $JS.EVENT.ADVISORY.STREAM.CREATED..., ... ] + -> el subject real de la room y la actividad de JetStream se filtran; el payload sí es ciphertext E2E +``` + +El report del fix lo reconoce explícitamente como exposición residual. Se cierra de verdad con **NATS accounts/permissions por identidad** — encaja en el issue 0003 (descentralización), donde además se agrava (más nodos, más usuarios). Hasta entonces, el aislamiento entre rooms en el data plane depende del E2E para el contenido y queda nulo para la metadata. + +### Pendientes del report 0004 no abordados por el fix 0004 + +- **H8** (operacional): la CA cuyo `ca.crt` está commiteado tiene su `ca.key` en el working tree del PC. Generar/custodiar en om. +- **H9** (bajo, se combina con N2): blobs sin cuota ni GC. +- **H10** (bajo): AEAD `chacha20poly1305` nonce 12 B aleatorio (irrelevante a volumen de chat). +- **H11** (bajo): la firma de owner (invite/rekey) no lleva nonce/ts propio; protegida sólo por el envelope `enforce`. + +--- + +## Confirmaciones (resisten — verificado) + +| Prueba | Resultado | +|---|---| +| Fuzz `frame.Unmarshal` (+ `SigningBytes`/`Marshal` del resultado) | 11 655 853 ejecuciones en 15 s, **0 panics/crashes**. Parser robusto a input malformado. | +| Suite completa en HEAD limpio | `go build ./...` + `go vet` + `go test ./pkg/... ./cmd/...` **verdes**. | +| H7 anti-replay autorizado | replay de identidad autorizada → `401 replayed nonce` (sigue intacto tras reordenar checks). | +| Cliente móvil (`mobile/unibus.go`) | migrado: `NewSession(...caPath)` → `client.Connect` (TLS+nkey+https forzado con caPath). | +| Rate-limit per-IP | ráfaga > burst desde 1 IP → `429` (funciona; ver N2 para su límite). | + +--- + +## Recomendaciones priorizadas (antes de exponer público) + +**Bloqueantes:** +1. **N1** — `go get github.com/nats-io/nats-server/v2@v2.11.15` (o superior) + toolchain **go1.26.4**; re-correr `govulncheck` hasta 0 affected. +2. **N3** — en `processFrame`, dropear el frame sin firma en rooms `SignMsgs` (no saltar la verificación). +3. **N2** — acotar memoria agregada: límite global de conexiones concurrentes / bytes-en-vuelo, o streaming del blob a disco. +4. **N4** — el guard de arranque debe exigir `--tls-cert` en bind no-loopback. + +**Recomendado:** +5. **H4** — planificar NATS accounts/permissions por identidad (cerrar la fuga de metadata del data plane); coordinar con issue 0003. +6. **H9** — cuota/GC de blobs (se solapa con N2). **H11** — nonce/ts en la firma de owner. **H8** — custodia de la CA. + +**Para el cliente gateway (`playground/`):** corre su propio `membershipd` en `AuthOff` + `http://`. Es dev-local y está documentado, pero debe quedar claro que **no** se exponga en una interfaz pública. + +--- + +## Nota de proceso (importante) + +Durante esta re-auditoría, el **working tree del sub-repo estaba siendo modificado en paralelo por otro agente** (trabajo del issue 0003 — cluster NATS): en un punto `pkg/embeddednats/embeddednats.go` tenía cambios sin commitear que **no compilaban** (`applyClusterOpts` undefined, import `net/url` sin usar), y minutos después el conjunto de archivos modificados era otro distinto. Esto coincide con el patrón conocido "dos agentes sobre el mismo working tree se pisan" (memoria `multi-agent-git-race-same-repo`). + +Para no auditar código a medias ni tocar el árbol del otro agente, esta auditoría se hizo sobre un **clon aislado del commit `618f6b6`** (master, fix 0004 cerrado, que compila y pasa la suite). Implicaciones: + +- El **veredicto aplica al commit `618f6b6`**, no al working tree actual (que tiene WIP del 0003 y, al cierre de este report, otros 7 archivos modificados sin commitear). +- **Recomendación de proceso:** serializar el trabajo sobre `dataforge/unibus` o usar `git worktree`/clones por agente. Un build roto en el working tree compartido bloquea cualquier verificación. El master commiteado está sano; el árbol de trabajo no lo estaba. + +--- + +## Gaps de esta re-auditoría (honestidad) + +- **N1** se reporta a partir de `govulncheck` (traces "affected"); no se construyó un exploit por cada CVE — el bump de versión es la acción correcta independientemente del exploitability individual. +- **N2** se midió desde una sola máquina/IP (1.42 GB con 40 conexiones); el caso distribuido (multi-IP) es extrapolación directa del diseño del rate-limit (per-IP). +- No se auditó el WIP del issue 0003 (cluster/routes auth, jetstreamStore) porque está incompleto y no compila; queda para una auditoría dedicada cuando 0003 cierre (el report 0004 ya dejó las implicaciones de descentralización: anti-replay multi-nodo, auth de routes, fail-closed sobre KV). +- No se re-ejecutó `govulncheck` tras un bump hipotético (no se modificó `go.mod`, por la regla de no tocar producción). +- El clon usó `go1.26.x` del sistema; las 2 CVEs de stdlib reflejan ese toolchain. diff --git a/reports/0007-2026-06-07-unibus-security-hardening-2.md b/reports/0007-2026-06-07-unibus-security-hardening-2.md new file mode 100644 index 0000000..eee2cd3 --- /dev/null +++ b/reports/0007-2026-06-07-unibus-security-hardening-2.md @@ -0,0 +1,246 @@ +# Report 0007 — Hardening de seguridad 2 del bus unibus (cierre de la re-auditoría 0006) + +- **Fecha:** 07/06/2026 +- **Autor:** agente implementador (Claude Opus 4.8) +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Implementación del issue 0005 (fases 0005a–0005e), que cierra los hallazgos NUEVOS de la re-auditoría red-team (report 0006). Trabajo confinado al sub-repo unibus; el repo padre `fn_registry` no se tocó. +- **Estado:** done +- **Punto de partida:** master post-0003 (`fb0291a`), unibus v0.6.0. **Resultado:** unibus v0.7.0 (`df3b62a`). +- **Método:** trunk-based, una rama corta por fase (`issue/0005x-*`), commits atómicos, `merge --no-ff` a master tras tests verdes. Cada hallazgo se verificó vivo en el master actual antes de arreglarlo, y cada fix lleva un test adversarial portado de la re-auditoría que se confirmó como guard real (falla al revertir el fix). + +--- + +## Resumen ejecutivo + +Las 5 fases del issue 0005 están implementadas, testeadas y mergeadas a master. Los tres bloqueantes nuevos (N1 CVEs, N3 spoof por firma omitida, N2 DoS por concurrencia), el gap N4 (TLS no forzado) y el residual H4 (ACL por subject sin cablear) quedan cerrados, con un residual menor documentado en H4 y una nota operativa sobre el cliente. + +| Fase | Hallazgo | Sev | Estado | Evidencia | +|---|---|---|---|---| +| 0005a | N1 — 16 CVEs alcanzables (14 nats-server + 2 stdlib) | Alto | **CERRADO** | `govulncheck` 16 → 0 alcanzables | +| 0005b | N3 — spoof por firma omitida (`Sig==nil`) en rooms `SignMsgs` | Alto | **CERRADO** | `TestReaudit_SigNilSpoof` | +| 0005c | N2 — DoS por concurrencia (memoria agregada sin cota) | Medio-Alto | **CERRADO** (residual factor 2 documentado) | `TestReaudit_DoSConcurrency` | +| 0005d | N4 — TLS del control plane no forzado en bind público | Medio | **CERRADO** | `TestGap_PublicEnforceNoTLS` | +| 0005e | H4 — ACL por subject existía pero no estaba cableada | Medio (residual) | **CERRADO** el wildcard; residual `$JS.API.>` documentado | `TestReaudit_H4_WildcardMetadataLeak` | + +**Veredicto: la exposición pública pasa de "NO-aún" a "sí-con-condiciones".** Ver §Veredicto para las condiciones exactas. + +--- + +## Verificación de partida (hallazgos vivos en el master actual) + +Antes de tocar nada, `govulncheck` confirmó N1 vivo sobre el master post-0003: + +``` +$ CGO_ENABLED=0 govulncheck ./... +... +Your code is affected by 16 vulnerabilities from 1 module and the Go standard library. +``` + +N3 vivo (`pkg/client/client.go:802`: `if info.Policy.SignMsgs && f.Sig != nil`), N4 vivo (`validateBootConfig` permitía público+enforce+sin-TLS), H4 vivo en el binario (`cmd/membershipd/main.go:141` usaba `NewNkeyAuthenticator`, NO el `NewNkeyAuthenticatorACL` que existía huérfano desde 0003e). N2 vivo (`handlePutBlob` hace `io.ReadAll` sin cota agregada). + +--- + +## 0005a — N1: CVEs en dependencias (Alto) → CERRADO + +**Hallazgo:** 16 vulnerabilidades alcanzables: 14 en `github.com/nats-io/nats-server/v2@v2.10.22` (servidor NATS embebido, expuesto público en el deploy decidido) + 2 en la stdlib de Go (GO-2026-5039 `net/textproto`, GO-2026-5037 `crypto/x509`). + +**Fix:** `go get github.com/nats-io/nats-server/v2@v2.11.15` (arrastra `nats.go` v1.49.0, `nkeys` v0.4.15, `jwt` v2.8.1 y otras transitivas); directiva `go 1.25.0 → 1.26.4` para que la toolchain incluya los dos fixes de stdlib. Cambio de `go.mod`/`go.sum` justificado por CVE (excepción explícita a la regla "no tocar deps"). + +**Verificación:** + +``` +$ CGO_ENABLED=0 govulncheck ./... +=== Symbol Results === +No vulnerabilities found. +Your code is affected by 0 vulnerabilities. +``` + +``` +$ CGO_ENABLED=0 go build ./... && go vet ./... && go test -count=1 ./... +ok github.com/enmanuel/unibus/cmd/membershipd 0.003s +ok github.com/enmanuel/unibus/pkg/blobstore 0.100s +ok github.com/enmanuel/unibus/pkg/busauth 0.006s +ok github.com/enmanuel/unibus/pkg/client 5.741s +ok github.com/enmanuel/unibus/pkg/embeddednats 6.002s <- cluster/JetStream e2e de 0003 +ok github.com/enmanuel/unibus/pkg/frame 0.002s +ok github.com/enmanuel/unibus/pkg/membership 3.032s +``` + +El bump del servidor NATS no rompió el cluster ni el plano durable (el e2e multi-nodo de 0003 en `pkg/embeddednats` sigue verde). Las 13 vulnerabilidades que `govulncheck` aún lista están en módulos requeridos pero NO llamados (no alcanzables) — fuera del objetivo "0 alcanzables". + +**Gaps:** no se construyó un exploit por CVE; el bump de versión es la acción correcta con independencia del exploitability individual. + +--- + +## 0005b — N3: spoof por firma omitida (Alto) → CERRADO + +**Hallazgo:** `pkg/client/client.go::processFrame` verificaba la firma SOLO cuando el frame la traía (`info.Policy.SignMsgs && f.Sig != nil`). En una room que EXIGE firma, un atacante con acceso al data plane publicaba un frame con `Sig==nil` y `Sender` forjado y el receptor lo aceptaba como auténtico. + +**Fix:** en una room `SignMsgs`, un frame sin firma es en sí mismo un rechazo: + +```go +if info.Policy.SignMsgs { + if f.Sig == nil { return } // firma exigida por la política pero ausente: drop + // verify ... +} +``` + +Las rooms no firmadas (`ModeNATS`) no se ven afectadas. + +**Verificación** (`pkg/client/sig_nil_spoof_test.go`, `TestReaudit_SigNilSpoof`): golden (frame firmado válido entregado) + error (frame `Sig==nil` con `Sender` forjado en room `SignMsgs` → drop) + edge (room sin `SignMsgs` sigue entregando frames sin firma). + +``` +$ CGO_ENABLED=0 go test -run TestReaudit_SigNilSpoof ./pkg/client/ +ok github.com/enmanuel/unibus/pkg/client 0.448s + +# Confirmación de guard real (fix revertido): +--- FAIL: TestReaudit_SigNilSpoof (0.26s) + sig_nil_spoof_test.go:115: SIG-NIL SPOOF: receiver accepted an unsigned frame with a forged Sender in a SignMsgs room +``` + +**Gaps:** ninguno; el bug era preexistente (no introducido por 0001/0004) y queda cerrado en ambos planes (efímero y persistido comparten `processFrame`). + +--- + +## 0005c — N2: DoS por concurrencia (Medio-Alto) → CERRADO (residual documentado) + +**Hallazgo:** el límite por-request (16 MiB blob / 1 MiB control) y el rate-limit por-IP no acotan la memoria AGREGADA. La re-auditoría midió 40 subidas concurrentes de 16 MiB → ~1.42 GB RSS, y un flood multi-IP (botnet) escala sin techo porque el rate-limit es por-IP. + +**Fix:** limiter global no bloqueante de bytes en vuelo (`pkg/membership/inflight.go`, `inflightLimiter` con contador atómico CAS). `ServeHTTP` reserva el peor caso de un POST (el ceiling de su ruta) antes de leer el body y lo libera al terminar; cuando se alcanza la cota global (`maxInflightBytes = 128 MiB`) los POST adicionales se descartan con 503 (backpressure, sin parquear goroutines). Los GET no consumen presupuesto. + +> Nota de diseño: la primitiva se implementó dentro de unibus en vez de delegarla al registry `fn_registry`. Razón doble: (1) el trabajo está confinado al sub-repo por instrucción explícita, y (2) `functions/core` del registry arrastra dependencias transitivas que requieren CGO (`mattn/go-sqlite3`) y módulos externos incompatibles con el build `CGO_ENABLED=0` de unibus, así que unibus no podría importarlo aunque quisiera. Queda documentado en los comentarios de `inflight.go`. + +**Verificación** (`pkg/membership/inflight_test.go` + `dos_concurrency_test.go`): + +``` +$ CGO_ENABLED=0 go test -run 'TestInflightLimiter|TestReaudit_DoSConcurrency' ./pkg/membership/ +--- PASS: TestInflightLimiterBasics +--- PASS: TestInflightLimiterDisabled +--- PASS: TestInflightLimiterConcurrent +--- PASS: TestReaudit_DoSConcurrency + dos_concurrency_test.go:146: N2 bound: 40 uploads -> 200=3 503=37, RSS delta 92940 kB (bound 262144 kB), cap 48 MiB +``` + +40 subidas concurrentes de 16 MiB desde IPs distintas (la forma multi-IP que el rate-limit por-IP NO defiende) contra una cota de test de 48 MiB: solo 3 bufferizan a la vez (200=3), el resto se descarta (503=37), y el RSS sube ~93 MiB en vez de ~1.42 GB — **acotado, no lineal con N**. Confirmación de guard real (limiter deshabilitado, cota 0): + +``` +--- FAIL: TestReaudit_DoSConcurrency + dos_concurrency_test.go:125: a concurrent flood of 40 uploads past the cap should shed some with 503; got 200=40 503=0 +``` + +Invariante de concurrencia verificada con el race detector: + +``` +$ CGO_ENABLED=1 go test -race -run TestInflightLimiter ./pkg/membership/ +ok github.com/enmanuel/unibus/pkg/membership 1.336s +``` + +**Gaps / residual:** bajo enforce el body se bufferiza dos veces (verificación de firma en `ServeHTTP` + `io.ReadAll` del handler), así que el RSS real es ~2× los bytes reservados. Cerrar eso del todo implica streamear los blobs a disco en vez de `io.ReadAll` en RAM — se solapa con la cuota/GC de blobs del issue 0002 (H9). La cota CONTABILIZADA sí es fiel; el factor 2 es un residual menor declarado. + +--- + +## 0005d — N4: TLS del control plane no forzado (Medio) → CERRADO + +**Hallazgo:** el guard `validateBootConfig` cerraba "público sin enforce" y "TLS sin enforce", pero permitía público + enforce + SIN `--tls-cert` → el control plane servía metadata (subjects, pubkeys, sealed keys, grafo social) sobre HTTP plano público (H5 reapareciendo). + +**Fix:** `validateBootConfig` ahora también rechaza un `--bind` no-loopback salvo que `--tls-cert` y `--tls-key` estén ambos presentes. El dev loopback no se ve afectado. + +**Verificación** (`cmd/membershipd/config_test.go`): + +``` +$ CGO_ENABLED=0 go test -run 'TestGap_PublicEnforceNoTLS|TestBootConfigPolicy' ./cmd/membershipd/ +--- PASS: TestGap_PublicEnforceNoTLS +--- PASS: TestBootConfigPolicy (16 subcasos) +``` + +`TestGap_PublicEnforceNoTLS`: `validateBootConfig("0.0.0.0", AuthEnforce, "", "")` ahora retorna error mencionando `--tls-cert` (golden público+enforce+TLS permitido; edge loopback-sin-TLS permitido). La tabla `TestBootConfigPolicy` se amplió: `public+enforce+notls`, `+certonly`, `+keyonly`, `lan-ip+enforce+notls` ahora rechazados. + +**Gaps:** ninguno; el guard es función pura y la tabla cubre las combinaciones bind × mode × cert/key. + +--- + +## 0005e — H4: ACL por subject (Medio, residual) → CERRADO el wildcard; residual documentado + +**Hallazgo y evaluación:** la ACL por subject existía desde 0003e (`membership.SubjectACLFor` + `busauth.NewNkeyAuthenticatorACL`, con test unitario `TestSubjectACLIsolation`), **pero el binario nunca la usaba**: `cmd/membershipd` instalaba el `NewNkeyAuthenticator` plano. Por eso, en producción, un registrado NO-miembro podía abrir una conexión NATS cruda, `Subscribe(">")` y captar los subjects de todas las rooms + la actividad de JetStream (el payload seguía siendo ciphertext E2E, pero la metadata se filtraba). El ataque `TestReaudit_H4_WildcardMetadataLeak` confirmó que el `acl.go` de 0003 NO lo cerraba — porque no estaba cableado. + +**Fix:** +- Nuevo adaptador de producción `busauth.PermissionsFromSubjects` (convierte la función que deriva subjects en la `PermissionsFunc` que espera el authenticator; concede los subjects como allow de publish Y subscribe; un error de derivación falla cerrado). Vive en `busauth` para que `membership` no dependa de `nats-server`. +- `cmd/membershipd`, bajo enforce, ahora instala `NewNkeyAuthenticatorACL(store.IsAuthorized, PermissionsFromSubjects(membership.SubjectACLFor(store)))`. +- El helper del test ahora delega en el adaptador de producción, así que los tests ejercitan el camino real. + +**Verificación** (`pkg/membership/acl_test.go`): + +``` +$ CGO_ENABLED=0 go test -run 'TestReaudit_H4_WildcardMetadataLeak|TestSubjectACLIsolation|TestRefreshSessionGainsNewRoom' ./pkg/membership/ +--- PASS: TestSubjectACLIsolation +--- PASS: TestReaudit_H4_WildcardMetadataLeak +--- PASS: TestRefreshSessionGainsNewRoom +``` + +`TestReaudit_H4_WildcardMetadataLeak`: un no-miembro registrado recibe permission violation al `Subscribe(">")` y al subscribirse al subject exacto de una room ajena; la miembro sigue pub/subscribiendo su room y el no-miembro no capta nada. Confirmación de guard real (authenticator plano, el cableado pre-0005e): + +``` +--- FAIL: TestReaudit_H4_WildcardMetadataLeak + acl_test.go:269: a non-member's Subscribe(">") must raise a permissions violation (wildcard metadata leak still open) +``` + +**Gaps / residual (documentado, no cerrado aquí):** +1. **`$JS.API.>` compartido.** Todos los peers tienen permitido `$JS.API.>` (necesario para que JetStream funcione por-conexión). Un peer que se suscriba específicamente a `$JS.API.>` aún puede observar requests de gestión de streams cuyos subjects llevan el nombre de stream derivado del room id. Cierre completo = aislamiento por NATS accounts/permissions por identidad — diferido a la línea 0003 (descentralización). El leak de alto impacto que la auditoría explotó (el subject de la room + advisories vía `Subscribe(">")`) sí queda cerrado. +2. **Nota operativa (gap funcional, no de seguridad):** NATS congela los permisos al conectar, así que un cliente debe llamar `client.RefreshSession` tras un cambio de membresía para ganar el subject de la nueva room. El patrón existe y está testeado (`TestRefreshSessionGainsNewRoom`), pero `cmd/chat` y `cmd/worker` aún NO lo invocan. Es un gap funcional del cliente a cerrar ANTES de un despliegue enforce+ACL real (sin él, un cliente no podrá pub/sub en rooms creadas/unidas tras conectar). No afecta la postura de seguridad (falla cerrado: deniega, no filtra). + +--- + +## Verificación global (master `df3b62a`, unibus v0.7.0) + +``` +$ CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... +BUILD+VET OK + +$ CGO_ENABLED=0 go test -count=1 ./... +ok github.com/enmanuel/unibus/cmd/membershipd 0.004s +ok github.com/enmanuel/unibus/pkg/blobstore 0.096s +ok github.com/enmanuel/unibus/pkg/busauth 0.007s +ok github.com/enmanuel/unibus/pkg/client 6.238s +ok github.com/enmanuel/unibus/pkg/embeddednats 5.899s +ok github.com/enmanuel/unibus/pkg/frame 0.002s +ok github.com/enmanuel/unibus/pkg/membership 4.093s + +$ CGO_ENABLED=0 govulncheck ./... +No vulnerabilities found. Your code is affected by 0 vulnerabilities. + +$ CGO_ENABLED=1 go test -race -run TestInflightLimiter ./pkg/membership/ +ok github.com/enmanuel/unibus/pkg/membership 1.336s +``` + +Historia (first-parent): + +``` +df3b62a Merge quick/0005-bump-close: unibus 0.7.0 + close issue 0005 +a4bbe82 Merge issue/0005e-acl-wire: wire per-subject ACL into membershipd (audit H4) +a2ec78c Merge issue/0005d-tls-guard: require TLS on public bind (audit N4) +db8618d Merge issue/0005c-inflight: global in-flight byte limiter bounds aggregate memory (audit N2) +0f79708 Merge issue/0005b-sig-nil: drop unsigned frames in SignMsgs rooms (audit N3) +88b4791 Merge issue/0005a-cve-bump: bump nats-server to v2.11.15 + go1.26.4 (16 CVEs -> 0 reachable) +``` + +--- + +## Veredicto: exposición pública + +**Pasa de "NO-aún" (report 0006) a "SÍ, con condiciones".** Los cinco hallazgos de la re-auditoría están cerrados con evidencia ejecutable; los residuales restantes son acotados y conocidos. + +**Condiciones para el despliegue público (0001f / 0003f, las ejecuta el humano):** +1. Arrancar con `--bus-auth enforce` y `--tls-cert/--tls-key` (el guard ahora lo obliga en bind no-loopback; el control plane sirve HTTPS y el data plane nkey+TLS+ACL). +2. **Cerrar el gap del cliente antes de exponer:** `cmd/chat` y `cmd/worker` deben llamar `client.RefreshSession` tras crear/unirse a rooms, o no operarán bajo la ACL. Es el único bloqueante funcional pendiente para enforce+ACL. +3. Asumir el residual de metadata de `$JS.API.>` (un peer dedicado puede inferir nombres de stream) hasta que se implementen NATS accounts por identidad — apropiado tratarlo en la línea 0003. +4. Tener presente el factor 2 de RAM por request bajo enforce (doble buffer); el `maxInflightBytes` de 128 MiB lo cubre con margen para un bus interactivo, pero el cierre definitivo es el streaming de blobs a disco (issue 0002 / H9). + +**Fuera de alcance de este issue (otros tickets):** H9 (cuota/GC de blobs → 0002), H10/H11 (AEAD nonce / nonce+ts en firma de owner → bajo, futuro), H8 (custodia de la CA → operacional), y la auditoría dedicada de la superficie nueva de 0003 (cluster routes auth, jetstreamStore KV fail-closed, nonce-cache replicado, failover), que la re-auditoría 0006 NO cubrió (auditó pre-0003). + +--- + +## Gaps de este trabajo (honestidad) + +- La primitiva del limiter (0005c) se duplicó conceptualmente respecto al registry (se implementó local en unibus) por la restricción de scope + la incompatibilidad CGO de `functions/core`. No es deuda silenciosa: está documentada en el código y aquí. +- El wiring de `main.go` (0005e) no tiene un test que arranque el binario completo; se validó por inspección + build + el test del MECANISMO (`startACLNats` usa el mismo adaptador de producción, y la regresión con el authenticator plano confirma que es el wiring lo que cierra el leak). +- El gap de `RefreshSession` en chat/worker (0005e) se documenta pero no se cierra: es trabajo funcional del cliente, fuera del scope de seguridad de este issue. +- Toda la verificación es en una sola máquina (`enmanuel`, Linux). El e2e multi-nodo de 0003 corre embebido en proceso, no en VPS reales.