docs(security): spec issue 0001 — sistema de usuarios + auth firmada del control plane + NATS nkey + TLS

Diseño de las tres capas de seguridad del bus para que WireGuard pase a ser
opcional: tabla users (allowlist Ed25519 con roles/revocación), middleware de
firma Ed25519 + anti-replay en el control plane (generaliza signRequest/
verifyOwnerSig ya existentes), y NATS endurecido con CustomClientAuthentication
(nkey sobre la identidad del peer, revocación dinámica) + TLS con CA propia.
Incluye 6 fases TBD con feature flag bus-auth (off->soft->enforce), migración de
clientes (pkg/client centraliza el cambio), plan de despliegue a om y matriz de
tests (golden/edge/error).
This commit is contained in:
agent
2026-06-07 12:12:19 +02:00
parent 0dde60a05e
commit 9a915839c8
+199
View File
@@ -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 <hex> [--role admin]`, `user list`, `user revoke <sign-pub>`.
- **Bootstrap (chicken-egg):** el primer `admin` se siembra ejecutando el CLI
localmente en el host del bus (`user add --role admin --sign-pub <tu_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: <sign_pub hex>
X-Unibus-Ts: <unix seconds>
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.