Files
uniweb/dev/issues/0001-browser-native-client.md
T
egutierrez bf0884527e chore: auto-commit (2 archivos)
- dev/
- registry.db

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 11:27:04 +02:00

9.8 KiB

issue, title, status, created, domain, scope
issue title status created domain scope
0001 uniweb como cliente browser-nativo del bus (eliminar el gateway Go, desacoplar de unibus) spec 2026-06-13 architecture uniweb (web/src/bus, web/src, eliminar cmd/webgw + go.mod) + unibus (pkg/embeddednats WebSocket, cmd/membershipd CORS, export de vectores de test cripto)

Objetivo

Convertir uniweb en un cliente del bus browser-nativo y autónomo, al mismo nivel que unibus_android: la SPA habla directamente con el data plane (NATS sobre WebSocket) y el control plane (HTTP), e implementa el protocolo del bus y su cifrado E2E en el propio navegador (TypeScript + @noble). Como consecuencia se elimina el gateway Go (cmd/webgw) y uniweb deja de depender del módulo Go de unibus: queda como una app de solo frontend (web/), sin go.mod, sin replace => ../unibus.

Por qué (estado actual auditado el 13/06/2026)

uniweb v0.1.0 nació partiendo unibus v0.13.0 en dos: la SPA (web/) y un gateway Go (cmd/webgw). El gateway importa pkg/{busauth,client,frame,room} de unibus y functions/cybersecurity del registry, así que uniweb solo compila con unibus presente en disco (replace github.com/enmanuel/unibus => ../unibus). Esa es la deuda que este issue salda.

Hay además un defecto de seguridad que este rediseño corrige de paso. Hoy el flujo wallet funciona así:

  • La SPA deriva la identidad del usuario (Ed25519 + X25519) de una mnemónica BIP39 y la cifra at-rest en el dispositivo.
  • Pero al abrir sesión, POST /api/session envía al gateway la identidad COMPLETA, incluida la clave privada (sign_priv 64 bytes + kex_priv 32 bytes, en hex). El gateway construye un cs.Identity con la privada y abre un cliente del bus que actúa como el usuario server-side (ver cmd/webgw/session.go, cmd/webgw/identity.go).

Es decir: la clave privada del usuario viaja al servidor y vive en la RAM del gateway. El cifrado de contenido sigue siendo E2E respecto al broker NATS, pero NO respecto al gateway — el gateway puede leer y firmar todo en nombre del usuario. El report 0019 prometía que "la clave privada nunca viaja al servidor"; eso solo será cierto cuando la cripto del bus ocurra en el navegador. Este issue lo hace cierto.

unibus_android ya demuestra que el patrón es viable: es un cliente Kotlin que habla directo con NATS (:4250) y el control plane (:8470) y hace toda la cripto E2E en el dispositivo, sin gateway y sin compartir su clave privada.

Diseño objetivo

        ┌─────────────────────────────────────────────┐
        │  unibus (el bus)                            │
        │   membershipd  :8470  control-plane HTTP    │
        │     + CORS (nuevo)    /register /session... │
        │   nats-server (embebido)                    │
        │     :4250  TCP  (peers Go, android)         │
        │     :4250+ WS   (navegador, nats.ws) (nuevo)│
        └─────────────────────────────────────────────┘
              ▲ TCP                    ▲ WebSocket
              │                        │
     ┌────────┴───────┐      ┌─────────┴──────────────┐
     │ peers Go /     │      │ uniweb (SPA, browser)  │
     │ android (Kt)   │      │  web/src/bus/  (TS SDK)│
     │ cripto nativa  │      │  cripto en @noble      │
     └────────────────┘      │  priv NUNCA sale       │
                             └────────────────────────┘

uniweb final = solo web/. Sin cmd/webgw, sin go.mod. Se sirve como estáticos (cualquier static server; en dev, vite). La identidad del usuario se desbloquea y se usa solo en el navegador.

Fases

Fase 0 — Preparar unibus (habilitar clientes browser)

Cambios en unibus (aditivos, no rompen peers Go/android existentes):

  1. WebSocket en el nats-server embebido (pkg/embeddednats): configurar opts.Websocket (puerto dedicado, NoTLS en loopback para dev; TLS en prod). Permite que nats.ws conecte desde el navegador. Flag/env para el puerto WS.
  2. CORS en el control plane (cmd/membershipd / pkg/membership): añadir cabeceras Access-Control-Allow-Origin/Methods/Headers y manejar preflight OPTIONS en los endpoints que el browser llamará directo (/register, /session o equivalente, listado de rooms, invite, etc.). Configurable (allowlist de orígenes), no * en prod.
  3. Exportar vectores de test cripto: un comando/test que vuelca vectores deterministas (derivación de identidad desde seed, envelope de frame seal/open, reparto sealed-box de la room key, firma por-mensaje) a JSON, para que los tests TS validen paridad byte a byte con la implementación Go de referencia.

Fase 1 — SDK del bus en TypeScript (web/src/bus/)

Port de la lógica que hoy vive en el gateway (pkg/client y dependencias). La cripto base ya está disponible (@noble/curves, @noble/hashes, @noble/ciphers); lo que se porta es el protocolo, no las primitivas.

  • frame.ts — port de pkg/frame (~100 LOC): estructura del frame, ULID ids, timestamp, threading (ThreadID, ReplyTo), tipos MSG/REACT.
  • room.ts — port de pkg/room (~42 LOC): Policy, tipos de clave de room, epochs.
  • busauth.ts — port de pkg/busauth (~331 LOC): autenticación nkey contra el control plane (nonce + firma Ed25519). Verificar cómo el browser obtiene/firma el nonce con la identidad del wallet (decisión: ¿la identidad del bus deriva del mismo seed BIP39?).
  • client.ts — port de pkg/client (~1368 LOC, el grueso): conexión nats.ws, Join, Publish/PublishReply/React, Subscribe, listado de rooms/users, CreateRoom, Invite, Kick; envelope E2E (ChaCha20-Poly1305 seal/open con la room key); reparto de la room key por sealed-box (X25519) a los invitados; rotación de epoch en LEAVE/KICK.

Fase 2 — Cablear la SPA al SDK y eliminar el gateway

  • Reemplazar web/src/api.ts (hoy llama /api/* del gateway) por llamadas al SDK del bus + al control plane directo.
  • La identidad del wallet (web/src/wallet/) se usa localmente para firmar/abrir; la privada nunca se serializa hacia la red.
  • Borrar cmd/webgw/, go.mod, go.sum. uniweb queda como app de solo frontend.
  • Servir la SPA: static server trivial (sin lógica de bus). Documentar en app.md.
  • Actualizar app.md: lang deja de ser go puro (o se marca framework: react, sin entry_point Go), quitar uses_functions Go, ajustar service/e2e_checks.

Fase 3 — Validación (DoD)

  • Tests TS de paridad cripto contra los vectores de la Fase 0.
  • E2E visual: join (invitación) / login (passphrase) / recover (mnemónica) + enviar y recibir mensajes cifrados browser ↔ browser y browser ↔ peer Go.
  • Auditoría de red (DevTools / proxy): confirmar que ninguna petición transporta la clave privada.

Riesgos / decisiones abiertas

  • nkey auth del navegador: el bus autentica peers con nkey (Ed25519). Decidir si la identidad nkey del bus deriva del mismo seed BIP39 del wallet o es separada. Afecta a busauth.ts y a cómo el control plane reconoce al usuario.
  • WebSocket + TLS en prod: el navegador exigirá wss:// salvo loopback. Encaja con el issue de TLS del bus (unibus 0001-bus-auth-and-tls).
  • CORS vs same-origin: alternativa a CORS = servir la SPA detrás de un reverse proxy que comparta origen con el control plane. Decidir en Fase 0/2.
  • Tamaño del bundle: la cripto + nats.ws añaden peso; medir y, si hace falta, code-split.
  • Paridad de protocolo: cualquier divergencia en el envelope o el reparto de claves rompe la interoperabilidad con peers Go/android. Los vectores de la Fase 0 son el contrato.

Definition of Done (3 capas)

Capa 1 — Mecánica

  • uniweb compila y se sirve sin unibus en disco y sin go.mod (es solo web/).
  • pnpm build verde; unibus build/vet/test verdes tras los cambios aditivos de Fase 0.

Capa 2 — Cobertura de comportamiento

Escenario Tipo Evidencia Esperado
Golden: browser ↔ peer Go intercambian mensaje cifrado e2e dos clientes en una room, mensaje round-trip plaintext idéntico en ambos extremos
Edge: recover en dispositivo nuevo re-deriva la misma identidad e2e pegar mnemónica → sign_pub reconstruido igual al original (vector Go)
Edge: rotación de epoch en KICK e2e KICK a un miembro → publicar el kicked ya no descifra; los demás sí
Error: passphrase incorrecta unit unlock con clave mala WrongPasswordError, sin tocar la red
Paridad: envelope seal/open TS vs Go unit vectores de Fase 0 bytes idénticos

Capa 3 — Vida útil

Métrica Umbral Dónde Ventana
Mensajes browser↔Go sin pérdida 100% uso real 7 días
Peticiones que filtran la priv 0 audit de red continuo
Errores de descifrado 0 consola/log SPA 7 días

Anti-criterios (invalidan la DoD)

  • La clave privada aparece en CUALQUIER petición de red.
  • uniweb necesita unibus en disco o como módulo Go para compilar/servir.
  • Mensajes browser↔Go ilegibles por divergencia de protocolo.
  • Queda algún cmd/webgw/go.mod en uniweb.

Notas

Onboarding: tras este issue, uniweb se desarrolla y despliega como cualquier SPA estática. No necesita el binario del bus para nada salvo apuntar nats.ws al puerto WebSocket del bus y el control plane HTTP a membershipd. El bus (unibus) y los demás clientes (unibus_android) no cambian de contrato: este issue solo añade el transporte WebSocket y CORS, ambos aditivos.