Files
message_bus/reports/0003-2026-06-07-unibus-bus-auth-tls.md
T
egutierrez 29fe688b7a ahora si funciona
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 16:23:53 +02:00

15 KiB
Raw Blame History

Report 0003 — unibus: seguridad del bus (issue 0001, fases 0001a0001e)

  • 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 0001a0001e (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 <bob-64hex>                                    # 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 <alice>   # 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; NewWithOptionsnats.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 0001a0001e)

$ 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 0001a0001e 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 <tu_handle> --sign-pub <tu_hex> --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.