--- 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) **Exposición DECIDIDA: pública.** El bus se expone a internet protegido por auth+TLS (WireGuard pasa a ser una vía de acceso más, no la barrera). En consecuencia: `ufw` en om abre `8470/tcp` y `4250/tcp`, y el server cert incluye en su SAN la **IP pública de om `135.125.201.30`**, la **IP WG `10.42.0.1`** (los peers internos siguen funcionando) y el hostname de om. Los clientes son todos controlados por nosotros (`pkg/client`, binding móvil, gateway web, unibots), así que **embeben el `ca.crt`** propio — no hace falta Let's Encrypt ni un dominio público apuntando al NATS. - Generar una **CA propia** una vez (`deploy/tls/ca.{key,crt}`), y un **server cert** para el bus con SAN = `135.125.201.30`, `10.42.0.1`, hostname de om. - `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 0.0.0.0` (exposición pública), `Restart=always`. 4. `ufw allow 8470/tcp` y `ufw allow 4250/tcp` en om. 5. Seed del admin (tu identidad) por CLI local en om. 6. Verificar **desde fuera de la VPN** (red pública) y desde la WG: handshake TLS, `curl` firmado a `/healthz` OK, `curl` sin firma → 401, conexión NATS de un peer no registrado → rechazada. 7. unibots local: systemd-user con `caPath` + identidad registrada. > **Nota:** la fase de despliegue (0001f: abrir firewall público, scp a om, systemd > en el VPS) la ejecuta el humano en coordinación, no el agente autónomo — es una > acción outward sobre infraestructura pública. El agente entrega 0001a–0001e > (código + tests + CA/cert generados) en master de unibus, listos para desplegar. # 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.