29fe688b7a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
15 KiB
Markdown
202 lines
15 KiB
Markdown
# 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 <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 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 <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`.
|