Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
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-repodataforge/unibus). Paquetesbusauth,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
membershipdefímero real enenforce+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 statussin 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:
- 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.
- Fail-open por configuración (ALTO): el binario arranca por defecto
--bus-auth off; el nkey de NATS solo se activa enenforcey el TLS es un flag independiente. Exponer público con TLS pero sinenforcedeja el bus completamente abierto con apariencia de seguro. No hay guard "bind público ⇒ enforce". - 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_keyajena — 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. - Control plane en claro (ALTO para exposición pública): el HTTP
:8470va 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Í):
- H1 — Límite de cuerpo + rate-limit (Crítico).
http.MaxBytesReaderen el middleware antes deio.ReadAll; límite específico yContent-Length-aware para/blobs; rate-limit por IP. Sin esto, el bus público se tumba con uncurl. - H2 — Cerrar el fail-open (Alto). Hacer que
--bindno-loopback exija--bus-auth enforce(y que--tls-certsinenforcesea error). Que el arranque inseguro sea imposible o, como mínimo, ruidoso y rechazado. - 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
ModeNATSen público). Hoy un solo usuario registrado mapea todo el sistema. - H5 — TLS en el control plane (Alto, público). No exponer
:8470en claro a internet; TLS propio o proxy. La firma no oculta la metadata.
Recomendado antes o justo después (no bloquean WireGuard-only):
- H6 ligar owner del create al firmante. H7 mover
IsAuthorizedantes del nonce-cache + poda eficiente. H9 cuota/GC de blobs. H12 no filtrar errores internos.
Operacional:
- H8 generar la CA en om, no en el PC; custodiar
ca.key. UsarRestart=alwaysen systemd (ya advertido en el report 0001 — un SIGTERM limpio es exit 0 yon-failureno 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
nonceCachees 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 configurarCluster.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
enforcees 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í. -
IsAuthorizedsobre KV debe seguir fail-closed. Al moverusersa 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 enjetstreamStore. -
Custodia de la CA más crítica (H8). El mismo
ca.keyfirmará 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 enagents_and_robots, app Androidmobile/) 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) sobrenats-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.