Files
unibus/dev/issues/0001-bus-auth-and-tls.md
T
agent 7de05c8591 docs(security): fijar exposición pública en el spec 0001
Decisión del operador: el bus se expone a internet protegido por auth+TLS
(WireGuard pasa a ser una vía más, no la barrera). ufw en om abre 8470/4250;
el server cert lleva SAN con la IP pública 135.125.201.30 + la IP WG 10.42.0.1
+ hostname; los clientes controlados embeben el ca.crt propio (sin Let's Encrypt).
La fase de despliegue 0001f la ejecuta el humano; el agente entrega 0001a-0001e.
2026-06-07 12:15:32 +02:00

215 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)
**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 0001a0001e
> (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.