7de05c8591
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.
215 lines
11 KiB
Markdown
215 lines
11 KiB
Markdown
---
|
||
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 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.
|