diff --git a/dev/issues/0001-bus-auth-and-tls.md b/dev/issues/0001-bus-auth-and-tls.md new file mode 100644 index 00000000..0f172167 --- /dev/null +++ b/dev/issues/0001-bus-auth-and-tls.md @@ -0,0 +1,199 @@ +--- +issue: 0001 +title: Seguridad del bus — sistema de usuarios, auth firmada del control plane, NATS nkey + TLS +status: spec +created: 2026-06-07 +domain: security +scope: unibus (membershipd, pkg/membership, pkg/embeddednats, pkg/client) + clientes (mobile, web gateway, unibots) +--- + +# Objetivo + +Hoy el bus unibus solo está protegido por la red (WireGuard) y por el cifrado E2E +por room (megolm). El **control plane** (HTTP `:8470`) y el **data plane** (NATS +`:4250`) **no tienen autenticación ni TLS**: cualquiera que alcance esos puertos +puede crear rooms, leer metadata, publicar, y hacer DoS. El contenido de las rooms +`ModeMatrix` está cifrado E2E, pero las rooms `ModeNATS` (cleartext), la metadata +de subjects y todo el control plane viajan en claro y sin control de acceso. + +Este issue añade tres capas de seguridad al propio bus, de modo que **WireGuard +pase a ser opcional** (defensa en profundidad) y el bus pueda exponerse de forma +segura incluso a un cliente móvil en una red ajena: + +1. **Sistema de usuarios** — un registro a nivel bus de las identidades autorizadas + (allowlist de claves públicas Ed25519), con roles y revocación. +2. **Auth del control plane** — cada request HTTP va firmado con la identidad del + peer; el server verifica la firma y que la identidad esté autorizada. +3. **NATS endurecido** — autenticación por nkey (Ed25519) contra el registro de + usuarios + TLS para cifrar todo el transporte del data plane. + +# Modelo de amenazas y capas + +| Capa | Qué protege | Estado hoy | Tras este issue | +|---|---|---|---| +| WireGuard | Acceso de red; oculta el bus de internet | activo (opcional) | sigue disponible, ya no imprescindible | +| TLS NATS | Confidencialidad/integridad del **canal** (cleartext rooms, metadata, nonces de auth) | ausente | CA propia self-signed | +| Auth (firma Ed25519 / nkey) | **Autenticación**: solo identidades registradas conectan/operan | ausente | control plane + data plane | +| E2E por room (megolm) | Confidencialidad del **contenido** de rooms cifradas | activo | sin cambios | + +Principio: cada capa es independiente. TLS cifra el canal, la auth decide quién +entra, el E2E protege el contenido aunque el bus fuera comprometido. + +# Diseño + +## Pieza 1 — Sistema de usuarios + +Registro a nivel bus (no por room) de las identidades autorizadas. Migración +**aditiva** `migrations/002_users.sql` (y su gemela embebida en +`pkg/membership/migrations/`): + +```sql +CREATE TABLE IF NOT EXISTS users ( + sign_pub TEXT PRIMARY KEY, -- clave pública Ed25519 en hex (identidad del peer) + handle TEXT NOT NULL, -- nombre legible (único recomendado, no PK) + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked' + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); +``` + +- `sign_pub` es la misma clave que ya deriva el `endpoint` (`frame.EndpointID(SignPub)`). +- CRUD en `pkg/membership/store.go`: `AddUser`, `GetUser`, `ListUsers`, + `RevokeUser`, `IsAuthorized(signPubHex) bool`. +- CLI de administración en `cmd/membershipd`: `membershipd user add --handle h + --sign-pub [--role admin]`, `user list`, `user revoke `. +- **Bootstrap (chicken-egg):** el primer `admin` se siembra ejecutando el CLI + localmente en el host del bus (`user add --role admin --sign-pub `). El + CLI local se considera de confianza (quien tiene shell en el host ya manda). Sin + al menos un admin, los endpoints de gestión de usuarios devuelven 403. + +## Pieza 2 — Auth del control plane (HTTP :8470) + +Generaliza la firma que ya existe (`pkg/client.signRequest` ↔ +`pkg/membership.verifyOwnerSig`) de "solo owner" a "todo request". + +**Cliente** (`pkg/client`): cada request añade cabeceras: + +``` +X-Unibus-Pub: +X-Unibus-Ts: +X-Unibus-Nonce: <16 bytes aleatorios, base64> +X-Unibus-Sig: Ed25519( canonical ) ; canonical = method "\n" path "\n" ts "\n" nonce "\n" sha256(body) +``` + +**Server** (middleware en `membershipd`): +1. Parsear cabeceras; reconstruir `canonical`; verificar firma con `X-Unibus-Pub`. +2. Comprobar `IsAuthorized(pub)` (status active). Si no → `401`. +3. **Anti-replay:** rechazar si `|now - ts| > 30s`; cachear `nonce` con TTL 60s y + rechazar repetidos (LRU en memoria, suficiente para un único membershipd). +4. Autorización fina: operaciones de gestión de usuarios exigen `role=admin`; + operaciones de room siguen exigiendo ownership donde ya aplica. + +Feature flag `bus-auth` en `dev/feature_flags.json` con tres estados de rollout: +`off` (sin verificar) → `soft` (verifica y **loguea** rechazos pero deja pasar) → +`enforce` (rechaza). Permite migrar clientes sin cortar el servicio. + +## Pieza 3 — NATS: nkey auth + TLS + +### Auth (nkey sobre la identidad Ed25519) + +Los nkeys de NATS **son** claves Ed25519, así que reutilizamos la identidad del +peer sin material nuevo. + +- **Server** (`pkg/embeddednats`): `server.Options.CustomClientAuthentication` con + un autenticador que, dado el nonce que NATS presenta al cliente y la firma que el + cliente devuelve, verifica la firma con la pubkey declarada y consulta + `store.IsAuthorized(pub)`. Validar dinámicamente contra la BD permite **revocar + sin reiniciar** el server (ventaja sobre precargar `Options.Nkeys`). +- **Cliente** (`pkg/client`): conectar con `nats.Nkey(pubSeedEncoded, sigCB)` donde + `sigCB` firma el nonce con la Ed25519 del peer. Convertir `cs.Identity` → + formato nkey con `github.com/nats-io/nkeys` (`nkeys.FromRawSeed(PrefixByteUser, + seed)`). + +### TLS (CA self-signed propia) + +- Generar una **CA propia** una vez (`deploy/tls/ca.{key,crt}`), y un **server + cert** para el bus con SAN = IP WG `10.42.0.1`, hostname de om, y (si se decide + exponer público) la IP/hostname público. +- `pkg/embeddednats`: `server.Options.TLSConfig` con el server cert. NATS pasa a + `tls://`. +- Cliente: `nats.Secure(&tls.Config{RootCAs: caPool})` cargando la CA propia. +- Las claves privadas (CA key, server key) **nunca** se commitean: van gitignored y + se distribuyen por `pass`/scp. Solo el `ca.crt` (público) viaja con los clientes. + +# Decisiones técnicas + +| Decisión | Elegido | Alternativa descartada | Razón | +|---|---|---|---| +| Auth NATS | `CustomClientAuthentication` contra tabla `users` | `Options.Nkeys` estático | revocación dinámica sin reinicio | +| TLS | CA self-signed propia | Let's Encrypt | infra privada, sin dependencia de dominio público apuntando al NATS | +| Anti-replay control plane | timestamp ±30s + cache de nonce | nonce emitido por server (round-trip extra) | menos latencia, suficiente con un solo membershipd | +| Material de identidad | reutilizar la Ed25519 del peer (firma + nkey) | claves separadas por capa | una identidad, menos gestión | +| Rollout | feature flag `bus-auth` off→soft→enforce | corte directo | no romper clientes en vuelo | + +# Fases (TBD, ramas `issue/0001x-*`, feature flags) + +1. **0001a — users store + CLI** — migración `002_users.sql`, CRUD en store, + comandos `membershipd user *`, seed admin. Flag `bus-auth: off`. Tests de store. +2. **0001b — control-plane auth** — firma generalizada en `pkg/client`, middleware + de verificación + anti-replay en `membershipd`. Flag `bus-auth: soft`. Tests: + request firmado OK, no-autorizado 401, replay rechazado, reloj desfasado 401. +3. **0001c — NATS nkey auth** — `CustomClientAuthentication` + cliente con + `nats.Nkey`. Tests: peer no registrado rechazado al conectar; revocado pierde + acceso sin reiniciar. +4. **0001d — TLS NATS** — generación de CA/cert (`deploy/tls/` + script), server + `TLSConfig`, cliente `RootCAs`. Flag `bus-tls`. Test: handshake TLS, cliente sin + CA rechazado. +5. **0001e — migrar clientes** — `mobile/` (binding), gateway web (`playground/`), + `unibots` (`shell/transportunibus`): todos firman requests y conectan con + nkey+TLS. Pasar `bus-auth` a `enforce`. +6. **0001f — deploy** — unibus en om (bind `10.42.0.1` o público con auth+TLS), + unibots como systemd-user en el PC local. Verificación E2E. + +# Migración de clientes + +Todo el cambio se concentra en `pkg/client` (firma de requests HTTP + conexión +NATS nkey+TLS). `mobile/`, el gateway web y `unibots` lo heredan al recompilar; solo +necesitan **pasar la ruta de la CA** y su identidad (que ya tienen). El binding +gomobile expone un parámetro nuevo `caPath` en `NewSession`. + +# Plan de despliegue (fase 0001f) + +1. Cross-build `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` del `membershipd`. +2. `scp` binario + `ca.crt` + server cert/key a om (`/opt/unibus/`), dir de datos + persistente para JetStream/db/blobs. +3. systemd-system unit, `--bind 10.42.0.1` (o público), `Restart=always`. +4. Seed del admin (tu identidad) por CLI local en om. +5. Verificar desde el PC (por WG): handshake TLS, `curl` firmado a `/healthz` OK, + `curl` sin firma → 401, conexión NATS de un peer no registrado → rechazada. +6. unibots local: systemd-user con `caPath` + identidad registrada. + +# Tests (DoD: golden + edge + error path, evidencia ejecutable) + +- **Golden:** peer autorizado crea room, publica y recibe por el bus con auth+TLS + activos. +- **Edge:** revocar un usuario activo → su próxima conexión NATS y su próximo + request HTTP son rechazados sin reiniciar el server. +- **Error path:** request con firma válida pero identidad no registrada → 401; + conexión NATS con nkey no autorizado → rechazada; cliente sin la CA → fallo de + handshake TLS; replay de un request firmado → rechazado. +- Suite completa `CGO_ENABLED=0 go test ./...` verde. + +# Riesgos y mitigaciones + +| Riesgo | Mitigación | +|---|---| +| Chicken-egg del primer admin | seed por CLI local en el host (confianza de shell) | +| Romper clientes en vuelo al activar auth | flag `bus-auth` off→soft→enforce; migrar clientes en soft | +| Rotación/caducidad de certs | CA propia de larga vida; documentar regeneración del server cert en `deploy/tls/README.md` | +| Coste de verificar firma por request | Ed25519 verify ≈ µs; despreciable frente a la latencia de red | +| Conversión Ed25519 → nkey mal hecha | test dedicado de ida y vuelta firma/verify nkey antes de tocar el server | +| Claves privadas filtradas en git | CA key / server key gitignored; distribución por `pass`/scp; solo `ca.crt` versionado | + +# Fuera de alcance (futuro) + +- Rotación automática de credenciales de usuario. +- Cuentas/multi-tenant de NATS (un solo account basta hoy). +- Federación entre buses.