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

202 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Report 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`; `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 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`.