# 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`.
