29fe688b7a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
18 KiB
Markdown
112 lines
18 KiB
Markdown
# Report 0004 — Auditoría de seguridad del bus unibus (red-team del issue 0001)
|
||
|
||
- **Fecha:** 07/06/2026
|
||
- **Autor:** agente auditor (Claude Opus 4.8) — mentalidad adversarial, sin modificar producción
|
||
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `busauth`, `membership`, `embeddednats`, `blobstore`, `client`, `cmd/membershipd`, `deploy/tls`. Auditoría de lo entregado en el issue 0001 (fases 0001a–0001e).
|
||
- **Estado:** done — auditoría con verificación activa (instancia efímera local + tests adversariales) y limpieza completa.
|
||
- **Método:** lectura del código de seguridad + spec + report del implementador (0003), verificación activa levantando un `membershipd` efímero real en `enforce`+TLS (puertos altos, DB en `/tmp`, certs locales) y ejecutando bypasses reales (curl sin firma, tests Go white-box con firmas Ed25519 reales, conexiones NATS con/sin nkey). Todo artefacto efímero fue eliminado; el working tree del sub-repo quedó en su baseline (`git status` sin cambios salvo los issues 0002/0003 ya presentes).
|
||
|
||
---
|
||
|
||
## Resumen ejecutivo
|
||
|
||
**Veredicto: NO exponer el bus a internet público HOY tal cual. Sí-con-condiciones tras corregir los hallazgos críticos/altos.**
|
||
|
||
Las tres capas de seguridad del issue 0001 están **bien construidas en su núcleo criptográfico y de autenticación**: la firma del control plane liga method+path+ts+nonce+sha256(body) y no es portable ni replayable; el anti-replay por ventana ±30s funciona; el nkey de NATS rechaza identidades no registradas y conexiones sin nkey; la revocación es viva (sin reinicio) en ambos planos; el TLS pinea la CA propia y rechaza al cliente que no la tiene; y no hay claves privadas filtradas en git. Eso **resiste** y está verificado con evidencia abajo.
|
||
|
||
Pero el issue resolvió **autenticación** y dejó sin resolver **autorización, disponibilidad y confidencialidad de metadata**, que son exactamente lo que un bus *público* necesita:
|
||
|
||
1. **DoS trivial pre-auth (CRÍTICO):** un atacante sin credenciales hace que el server bufferice en RAM cuerpos ilimitados *antes* de verificar la firma. 400 MB enviados → 898 MB de RSS. Unas pocas conexiones concurrentes = OOM y caída del bus.
|
||
2. **Fail-open por configuración (ALTO):** el binario arranca por defecto `--bus-auth off`; el nkey de NATS solo se activa en `enforce` y el TLS es un flag independiente. Exponer público con TLS pero sin `enforce` deja el bus **completamente abierto** con apariencia de seguro. No hay guard "bind público ⇒ enforce".
|
||
3. **Cero aislamiento entre rooms (ALTO):** "autorizado" significa "registrado en el allowlist", no "miembro de la room". Cualquier usuario registrado lee subject, lista de miembros (con claves públicas), el directorio de rooms de cualquiera, e incluso la `sealed_key` ajena — y en el data plane NATS no hay ACL por subject, así que lee/publica en cualquier room (las cleartext en claro). El E2E protege el contenido de las rooms cifradas, nada más.
|
||
4. **Control plane en claro (ALTO para exposición pública):** el HTTP `:8470` va firmado pero **sin TLS**. Toda la metadata (subjects, identidades, sealed keys, hashes de blobs, el grafo "quién habla con quién") viaja legible por la red pública. El report 0001 lo marcó "fuera de alcance", pero el despliegue decidido es público.
|
||
|
||
Conclusión: la capa de **identidad** del bus es sólida; las capas de **autorización, cuota/anti-DoS y confidencialidad de metadata** faltan. Para un despliegue *solo-WireGuard* el riesgo es asumible (la red ya filtra). Para el despliegue *público* que el issue 0001 habilita (ufw abre 8470/4250 a internet), **NO está listo**: corregir al menos los cuatro puntos de arriba antes de 0001f.
|
||
|
||
---
|
||
|
||
## Tabla de hallazgos
|
||
|
||
| # | Sev | Vector | Descripción | Evidencia | Fix recomendado |
|
||
|---|---|---|---|---|---|
|
||
| H1 | **Crítico** | A10 DoS | El middleware `Server.ServeHTTP` hace `io.ReadAll(r.Body)` **sin límite y antes** de `authenticate()`. Un atacante no autenticado fuerza buffering ilimitado en RAM pre-auth. El comentario "bodies are small / already capped upstream" es falso: no hay cap. `handlePutBlob` repite el `io.ReadAll` sin límite. | `curl -X POST :18470/blobs --data-binary @400MB` (sin firma) → `401` pero RSS del proceso saltó de **18 744 kB a 898 004 kB** durante el upload (muestreo `/proc/PID/status`), en 0.6 s. | `http.MaxBytesReader` en el middleware (p.ej. 1 MB control plane); límite separado y mayor para `/blobs` con `Content-Length` rechazado temprano; `Server.MaxHeaderBytes`; rate-limit por IP/identidad; cuota de disco para blobs. |
|
||
| H2 | **Alto** | A9 fail-open | `cmd/membershipd/main.go`: nkey auth solo si `authMode==AuthEnforce`; TLS es flag independiente; default `--bus-auth off`. Control plane y NATS comparten `--bind`. Arrancar `--bind 0.0.0.0 --tls-cert … ` sin `--bus-auth enforce` = data plane TLS **sin auth** + control plane **sin auth**. | Test `TestAudit_FailOpenTLSWithoutAuth`: harness con TLS on + authenticator off → cliente **no registrado, sin nkey** `CONNECTED to the TLS data plane`. | Acoplar: si `bind` no es loopback ⇒ exigir `enforce` (o `log.Fatal`). `bus-tls` sin `bus-auth enforce` debe ser error. Default seguro o arranque ruidoso. Considerar mTLS (client certs) como segunda barrera. |
|
||
| H3 | **Alto** | A3 autz horizontal | "Autorizado" = "registrado", no "miembro". Los GET no comprueban pertenencia: `/rooms/{id}`, `/rooms/{id}/members` (devuelve `sign_pub`+`kex_pub` de todos), `/members/{endpoint}/rooms`, y `/rooms/{id}/key?endpoint=X` (¡devuelve la `sealed_key` de otro!). | `TestAudit_HorizontalMetadataLeak`: bob (registrado, NO miembro) → `GET /rooms/{id}` `200 {"subject":"secret.subject.payroll"…}`, `/members` `200 [{…sign_pub…kex_pub…}]`, `/members/alice-ep/rooms` `200 […]`, `/rooms/{id}/key?endpoint=alice-ep` `200 {"sealed_key":"…"}`. | Autorización por pertenencia en cada handler de room (consultar `members`); `/rooms/{id}/key` solo para `endpoint == signer`; no exponer member list completa a no-miembros. |
|
||
| H4 | **Alto** | A3 sin ACL NATS | El authenticator nkey solo decide "registrado sí/no"; no hay permisos por subject (un solo account, sin `Permissions`). Cualquier registrado se suscribe/publica en cualquier subject. Rooms `ModeNATS` (cleartext) quedan totalmente expuestas entre usuarios; en las E2E se filtra metadata de tráfico. | `TestAudit_NoSubjectACL`: eve (registrada, nunca invitada) se suscribe a la room cleartext de alice y **recibe** `"internal: salary numbers"`. | NATS accounts/permissions por identidad, o subjects derivados+impredecibles + verificación de pertenencia server-side, o cifrar siempre (prohibir `ModeNATS` en deploy público). |
|
||
| H5 | **Alto** (público) | A2 MITM | Control plane HTTP `:8470` firmado pero **sin TLS**. La firma da integridad/auth, no confidencialidad: un observador de red lee toda la metadata (subjects, endpoints, claves públicas, sealed keys, hashes de blobs, grafo social) y puede *dropear* requests. El cliente usa `http://` y no fuerza TLS. | Lectura de `client.newSignedRequest` (`ctrlURL+path`, esquema `http`) y `membership.Server` (sin `tls`). El report 0001 lo reconoce como gap "fuera de alcance". | TLS en el control plane (mismo CA propio) o reverse-proxy TLS delante; el cliente debe exigir `https` cuando hay CA. |
|
||
| H6 | **Medio** | A3 spoof | `handleCreateRoom` no liga `Owner.SignPub`/`Owner.Endpoint` del body al firmante del control plane (`X-Unibus-Pub`). Un registrado crea rooms a nombre de otra identidad. | `TestAudit_OwnerSpoof`: bob firma; body declara owner=victim → `201`; en DB `owner_endpoint="victim-endpoint-id"`. (Mitiga el daño: invite/rekey exigen firma del owner real, que el spoofer no posee.) | Exigir `Owner.Endpoint == frame.EndpointID(X-Unibus-Pub)` y `Owner.SignPub == pub` en create. |
|
||
| H7 | **Medio** | A4 / A10 | **Nonce-cache poisoning pre-auth.** En `authenticate`, `rememberOrReject` corre **antes** de `IsAuthorized`. Cualquiera con un par Ed25519 propio (no registrado) firma válido y puebla el `nonceCache`; las claves son gratis e infinitas. Además la poda es O(n) sobre todo el mapa en cada request bajo un mutex global → amplificación CPU/contención. | `TestAudit_NonceCachePoisonPreAuth`: identidad NO registrada → 1ª req `identity not authorized` (llegó a IsAuthorized), 2ª req mismo nonce → `replayed nonce` ⇒ el nonce del no-autorizado **fue cacheado**. | Mover `IsAuthorized` **antes** de tocar el cache (no recordar nonces de no-autorizados); poda por heap/expiry-bucket en vez de O(n); cap de tamaño del cache; rate-limit. |
|
||
| H8 | **Medio** (op) | A6 secretos | Limpio en git: solo `ca.crt` versionado; `*.key`/`*.csr`/`server.crt` gitignored; sin claves privadas en la historia. PERO el `ca.key` de la CA cuyo `ca.crt` está **commiteado** vive en el working tree de este PC; si esa CA es la de producción, comprometer el PC = poder firmar certs y MITM del data plane. | `git ls-files deploy/tls/` → `.gitignore, README.md, ca.crt, generate-certs.sh`. `git log --all` sin `*.key`/`*.crt` salvo `ca.crt`. Working tree tiene `ca.key`/`server.key` (perms 600, gitignored). | Generar la CA **en om** (no en el PC), custodiar `ca.key` offline/en `pass`; tratar el `ca.crt` commiteado como CA de dev y no reusarlo en prod (el README ya lo sugiere). |
|
||
| H9 | **Bajo** | A2/A10 disco | `handlePutBlob`: cualquier registrado sube blobs arbitrarios; sin cuota, sin GC, sin ligar el blob a una room/membresía. Exhaustión de disco lenta. `handleGetBlob`: cualquiera con el hash baja el blob (ciphertext, pero filtra tamaño/existencia). | Lectura de `server.go` + `blobstore` (sin límites). El filtro de traversal `ContainsAny(hash,"/\\.")` sí cubre `..`. | Cuota por identidad, GC (encaja con issue 0002), ligar blob→room, límite de tamaño. |
|
||
| H10 | **Bajo** | A7 cripto | `cs.SealAEAD` usa `chacha20poly1305` con nonce **aleatorio de 12 bytes** y un único K por epoch. Riesgo de colisión de nonce solo a ~2³² mensajes/epoch (birthday) — irrelevante para chat humano, relevante para rooms de muy alto volumen o agentes. | `functions/cybersecurity/seal_aead.go` (NonceSize 12, `rand.Reader`). | Para volúmenes altos: XChaCha20-Poly1305 (nonce 24 B) o rekey por volumen de mensajes. No urgente. |
|
||
| H11 | **Bajo** | A4 replay | La firma de owner del payload (`req.Sig` en invite/rekey) **no incluye nonce/ts**: es replayable a nivel payload. Bajo `enforce` la envuelve el anti-replay del control plane; bajo `soft`/`off` (o si se mueve a otro nodo, ver §descentralización) es replayable. | Lectura de `signRequest`/`verifyOwnerSig` (canonical = JSON con sig=nil, sin nonce). | Incluir nonce+ts en el canonical de la firma de owner, o depender explícitamente del envelope enforce (documentado). |
|
||
| H12 | **Bajo** | info-leak | Varios handlers devuelven `err.Error()` interno al cliente (`writeErr(w, …, err.Error())`), filtrando rutas/mensajes SQL. | `handleCreateRoom`/`handleInvite`/… retornan errores crudos. | Mensajes genéricos al cliente; detalle solo al log. |
|
||
|
||
---
|
||
|
||
## Confirmaciones (lo que SÍ resiste — verificado, para no dar falsa alarma)
|
||
|
||
| Vector | Prueba | Resultado |
|
||
|---|---|---|
|
||
| A1 — sin credenciales | `curl` sin firma a `/rooms` (POST), `/rooms/abc`, `/members/x/rooms`, `/blobs`, `/admin` | **401** en todos; `GET /healthz` → **200** (único exento, correcto). |
|
||
| A4 — firma no portable | `TestAudit_SignatureNotPortable`: firmar `GET /rooms/AAA`, reusar headers en `GET /rooms/BBB` y en `POST /rooms/AAA` | **401 invalid signature** en ambos. El canonical liga method+path+ts+nonce+sha256(body). |
|
||
| A4 — replay | Test existente `TestAuthReplayRejected` + reverificado | mismo nonce reenviado → **401 replayed nonce**. |
|
||
| A4 — clock skew | `TestAuthClockSkewRejected` (ts −120 s) | **401 timestamp out of range**. Ventana ±30 s; `nonceTTL=60s=2·skew` ⇒ un replay nunca sobrevive a su memoria (análisis confirmado). |
|
||
| A4 — tamper body | `TestAuthTamperedBodyRejected` | body alterado tras firmar → **401**. |
|
||
| A5 — revocación viva | `TestAudit_RevocationLive` (control plane) + `TestNatsNkeyAuth` (data plane) | tras `RevokeUser` **sin reiniciar**: control plane → **401**; data plane → la próxima conexión nkey rechazada. `IsAuthorized` fail-closed ante error de query. |
|
||
| A7 — Ed25519→nkey | `TestNkeyRoundTrip` + lectura `busauth/nkey.go` | nkey deriva de la misma seed Ed25519 (no debilita la clave); `nkey→hex == hex(SignPub)`; round-trip correcto. |
|
||
| A7 — TLS pin CA | `TestNatsTLS` + `TestAudit_NatsNkeyEnforced` | cliente con la CA → handshake OK; cliente sin la CA → **falla**; SAN del cert incluye `127.0.0.1` (verificado con openssl). `MinVersion` TLS 1.2. |
|
||
| A8 — nkey enforce | `TestAudit_NatsNkeyEnforced` | registrado+nkey → conecta; **nkey no registrado → `Authorization Violation`**; **sin nkey bajo enforce → `Authorization Violation`**. `AlwaysEnableNonce=true` correcto; `Check` fail-closed ante cualquier input malformado. |
|
||
| A6 — git secretos | `git ls-files` + `git log --all` | solo `ca.crt` público versionado; sin claves privadas en working tree trackeado ni en la historia; `.gitignore` correcto. |
|
||
| SQLi | Lectura de `store.go` | todas las queries parametrizadas (`?`); sin concatenación. |
|
||
| Path traversal blobs | `handleGetBlob` | rechaza `/`, `\`, `.` (cubre `..`). |
|
||
|
||
---
|
||
|
||
## Recomendaciones priorizadas antes del deploy 0001f
|
||
|
||
**Bloqueantes para exposición pública (corregir SÍ o SÍ):**
|
||
|
||
1. **H1 — Límite de cuerpo + rate-limit (Crítico).** `http.MaxBytesReader` en el middleware antes de `io.ReadAll`; límite específico y `Content-Length`-aware para `/blobs`; rate-limit por IP. Sin esto, el bus público se tumba con un `curl`.
|
||
2. **H2 — Cerrar el fail-open (Alto).** Hacer que `--bind` no-loopback exija `--bus-auth enforce` (y que `--tls-cert` sin `enforce` sea error). Que el arranque inseguro sea imposible o, como mínimo, ruidoso y rechazado.
|
||
3. **H3 + H4 — Autorización por pertenencia (Alto).** Comprobar membresía en los handlers de room y, en el data plane, permisos por subject (o prohibir `ModeNATS` en público). Hoy un solo usuario registrado mapea todo el sistema.
|
||
4. **H5 — TLS en el control plane (Alto, público).** No exponer `:8470` en claro a internet; TLS propio o proxy. La firma no oculta la metadata.
|
||
|
||
**Recomendado antes o justo después (no bloquean WireGuard-only):**
|
||
|
||
5. **H6** ligar owner del create al firmante. **H7** mover `IsAuthorized` antes del nonce-cache + poda eficiente. **H9** cuota/GC de blobs. **H12** no filtrar errores internos.
|
||
|
||
**Operacional:**
|
||
|
||
6. **H8** generar la CA en om, no en el PC; custodiar `ca.key`. Usar `Restart=always` en systemd (ya advertido en el report 0001 — un SIGTERM limpio es exit 0 y `on-failure` no reinicia).
|
||
|
||
**Si el deploy se mantiene solo-WireGuard:** H1 y H2 siguen siendo importantes (un peer interno comprometido o un error de bind), pero H3/H4/H5 bajan de prioridad porque la red ya restringe el acceso. La decisión del issue 0001 fue *pública*, así que aplican todos.
|
||
|
||
---
|
||
|
||
## Implicaciones para la descentralización (issue 0003)
|
||
|
||
El issue 0003 lleva unibus a 3 nodos en cluster con control plane *stateless* sobre JetStream KV y failover de cliente. Eso **amplifica** varios hallazgos de esta auditoría; conviene resolverlos en 0001/0003 conjuntamente (0003 ya declara `depends_on: 0001`):
|
||
|
||
- **El anti-replay se ROMPE en multi-nodo (consecuencia directa de H7).** El `nonceCache` es **en memoria por proceso**. Con failover/balanceo entre 3 nodos, un atacante captura una request firmada y la **replaya a otro nodo** cuyo cache no tiene ese nonce → aceptada. El report 0001 ya anota "un despliegue multi-membershipd necesitaría un store compartido"; 0003 lo hace obligatorio. **El nonce-cache debe pasar a un KV replicado con TTL (o un esquema de nonce emitido por server) ANTES de 0003f.** Sin esto, el anti-replay del control plane es nulo en cluster.
|
||
|
||
- **Auth de las routes del cluster (nueva superficie).** 0003a abre puertos de cluster (routes) entre nodos. El authenticator nkey actual (`busauth`) autentica **clientes**, no routes. Si las routes quedan sin auth/TLS mutuo, cualquiera que alcance el puerto de cluster inyecta mensajes en todo el bus o se une como nodo falso. Hay que configurar `Cluster.Authorization` + TLS de routes con credenciales **propias de nodo** (no las de cliente), reusando la CA del 0001. No reutilizar el client authenticator para routes.
|
||
|
||
- **El fail-open (H2) se multiplica por 3.** Cada nodo arrancado sin `enforce` es un punto público abierto. El propio spec 0003 avisa: "desplegar descentralizado sin auth sería abrir varios puntos públicos sin protección". El guard "bind público ⇒ enforce" de H2 protege también aquí.
|
||
|
||
- **`IsAuthorized` sobre KV debe seguir fail-closed.** Al mover `users` a JetStream KV (R3), una pérdida de quorum o timeout del KV debe **denegar** (como hoy con SQLite: error → false), nunca permitir. Verificar explícitamente en `jetstreamStore`.
|
||
|
||
- **Custodia de la CA más crítica (H8).** El mismo `ca.key` firmará los certs de los 3 nodos (cada uno con su IP pública en SAN). Su compromiso permite MITM de todo el cluster. Generar y custodiar fuera de los hosts de aplicación.
|
||
|
||
- **H3/H4 escalan con el nº de usuarios.** Más nodos suele implicar más usuarios registrados; la fuga de metadata horizontal y la ausencia de ACL por subject convierten a cada usuario en un observador global del grafo. La autorización por pertenencia es aún más necesaria en un despliegue grande.
|
||
|
||
---
|
||
|
||
## Gaps / pendientes de esta auditoría (honestidad)
|
||
|
||
- **No se probó MITM real con un proxy en la red** (H5 se argumenta por lectura de código: control plane `http://` sin TLS). El razonamiento es directo pero no hay captura de tráfico adjunta.
|
||
- **No se midió el DoS bajo concurrencia** (N conexiones simultáneas de varios GB) — se demostró la amplificación de memoria con una sola request (400 MB → 898 MB RSS), suficiente para evidenciar el vector; el OOM bajo carga concurrente es la extrapolación.
|
||
- **No se auditaron los clientes no migrados** (`playground/` gateway, unibots en `agents_and_robots`, app Android `mobile/`) más allá de constatar que el report los declara pendientes (`dev/0001e-remaining-clients.md`). Un cliente que conecte en claro/sin nkey reabre superficie.
|
||
- **No se ejecutó análisis estático de dependencias** (`govulncheck`) sobre `nats-server v2.10.22` / `nats.go v1.37.0` / `modernc.org/sqlite` — recomendable como paso aparte.
|
||
- La verificación activa usó tests Go white-box (requieren firmas Ed25519 reales) además de `curl`; son requests HTTP/NATS reales contra un server real, equivalentes a un atacante externo, pero ejecutados in-proceso. Los artefactos de prueba fueron **eliminados** y el sub-repo quedó en baseline.
|