---
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.
