# Report 0019 — unibus: onboarding wallet en la SPA (seed BIP39, join/login/recover)

- **Fecha:** 08/06/2026
- **Autor:** agente (Claude Opus 4.8) + operador
- **Ámbito:** `projects/message_bus/apps/unibus` — onboarding wallet del navegador (`web/src/wallet` + pantallas pre-chat) y backend del gateway (`cmd/webgw`: `/api/register`, `/api/session`, sesiones por usuario).
- **Rama:** `quick/web-join` (sub-repo `dataforge/unibus`), base `quick/web-wire`. 2 commits, pusheada. **NO mergeada** (la integra el orquestador en el rollout 0.12.0).
- **Estado:** done. Flujo verificado VISUALMENTE end-to-end en navegador (join + login + recover), determinismo de la derivación demostrado (sign_pub recuperado == original, y cross-language contra el vector del test Go). `pnpm build` y `go build ./...` verdes; tests del gateway verdes.

## Resumen

La iteración anterior (report 0015) dejó la SPA cableada al bus a través de un gateway que actuaba con **una sola identidad de operador** desbloqueada por passphrase. Esta iteración añade el modelo **wallet por usuario**: cada usuario tiene su propia identidad criptográfica, derivada de forma **determinista** de una frase semilla BIP39 de 12 palabras, que vive cifrada en su dispositivo y nunca viaja al servidor en claro. Tres caminos de entrada:

1. **Join** (desde un enlace de invitación `/join?token=XXX`): genera una seed nueva, la muestra una vez con una compuerta de confirmación, toma una contraseña local, registra la clave **pública** en el allowlist del bus usando el token, y abre sesión.
2. **Login wallet** (dispositivo con identidad guardada): la contraseña descifra la clave privada local y abre sesión.
3. **Recover** (dispositivo nuevo, o tras olvidar la contraseña): el usuario pega sus 12 palabras, se re-deriva la **misma** identidad (mismo `sign_pub`), una contraseña nueva re-cifra la clave en este dispositivo y abre sesión — sin intervención del administrador.

La propiedad central es el **determinismo de la derivación**: la misma mnemónica produce siempre el mismo par de claves, así que la identidad re-derivada en recover es byte por byte la que el bus ya autoriza. Esa es la prueba de oro de este trabajo y se verificó visualmente.

## Esquema de derivación (`web/src/wallet/derive.ts`)

La identidad NO es un par de claves aleatorio suelto: se deriva de forma reproducible de una mnemónica BIP39 de 12 palabras (128 bits de entropía). El esquema debe ser idéntico al crear y al recuperar:

```
1. mnemonic   = 12 palabras BIP39 (128 bits entropía + 4 bits checksum)
2. seed       = BIP39_seed(mnemonic)
              = PBKDF2(HMAC-SHA512, NFKD(mnemonic), salt="mnemonic", 2048 iters, 64 bytes)
3. signSeed   = HKDF-SHA256(ikm=seed, salt="", info="unibus-sign-v1", L=32)
4. Ed25519:  sign_pub  = Ed25519.publicKey(signSeed)         (32 bytes)
             sign_priv = signSeed || sign_pub                (64 bytes; layout de Go: seed||pub)
5. kexSeed    = HKDF-SHA256(ikm=seed, salt="", info="unibus-kex-v1",  L=32)
6. X25519:   kex_priv  = kexSeed                             (32 bytes; X25519 clampea internamente)
             kex_pub   = X25519.publicKey(kexSeed)           (32 bytes)
```

Los dos `info` distintos de HKDF separan por dominio la clave de firma de la de intercambio de claves para que no puedan colisionar. Las cuatro mitades calzan exactamente con `cs.Identity` del lado Go (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32), de modo que el gateway puede actuar como peer del usuario con las claves derivadas.

**Bibliotecas:** `@scure/bip39` (wordlist inglesa + checksum, no rodamos el nuestro), `@noble/curves` (Ed25519/X25519), `@noble/hashes` (HKDF/SHA). Cero WASM; todo JS auditado.

### Capa wallet (`web/src/wallet/`)

| Archivo | Rol |
|---|---|
| `derive.ts` | Derivación determinista mnemónica → identidad (el esquema de arriba). Pura. |
| `bip39.ts` | Wrappers sobre `@scure/bip39`: `newMnemonic` (CSPRNG), `normalizeMnemonic`, `isValidMnemonic` (cuenta + wordlist + checksum). |
| `crypto.ts` | Cifrado at-rest de la clave privada **solo con WebCrypto**: PBKDF2-SHA256 210k iters → AES-256-GCM. La contraseña nunca se guarda ni se envía. `WrongPasswordError` mapea el fallo de auth GCM. |
| `store.ts` | Persistencia en IndexedDB (`unibus-wallet`). Solo se guarda la clave privada **cifrada** + las mitades públicas + el handle en claro (para mostrar quién eres sin desbloquear). Una identidad activa por dispositivo (MVP). |
| `account.ts` | Operaciones de alto nivel: `saveAndOpen` (cifra + guarda + abre sesión), `unlockAndOpen` (descifra + abre sesión), `localIdentity`. |

## Flujo de pantallas (`web/src`)

- `App.tsx` — router por estado: un enlace de invitación va directo a join; una sesión viva del gateway (`/api/me` 200) reanuda el chat; un dispositivo con identidad guardada muestra el unlock por contraseña; un dispositivo vacío muestra Welcome. `clearUrl()` borra el `?token` de la barra tras consumirlo (el token es de un solo uso).
- `Welcome.tsx` — chooser del dispositivo vacío: pegar enlace de invitación o recuperar con seed. `extractToken()` acepta el enlace completo, `token=XXX` o el token pelado.
- `Join.tsx` — onboarding. Estados: `generating → show-seed → confirm-seed → password → joining`.
  - **ShowSeed:** muestra las 12 palabras una vez tras una compuerta de "He guardado mi frase" (checkbox). Botón de copiar y alerta de que unibus NO guarda la frase.
  - **ConfirmSeed:** pide re-escribir 3 palabras al azar de la frase para probar que la guardaste.
  - **SetPassword:** contraseña local (mín. 8, doble entrada). Al enviar: `POST /api/register` (token autoriza) → `saveAndOpen` (cifra local + `POST /api/session`) → entra.
- `Recover.tsx` — pega las 12 palabras; en cuanto la frase es válida muestra "Frase válida ✓" y la **identidad reconstruida** (el `sign_pub`) antes de comprometer una contraseña nueva. Nota: **no** hace register (la identidad ya está en el allowlist).
- `WalletLogin.tsx` — unlock del dispositivo con identidad guardada. Contraseña incorrecta → "Contraseña incorrecta." (sin llamar al gateway).
- `AuthShell.tsx` — card/header compartidos por todas las pantallas pre-chat.

## El gateway (`cmd/webgw`)

Dos rutas nuevas, sin sesión (las autoriza el token o la propia identidad), y un modelo de sesión **por usuario** en vez de un único operador compartido.

| Método y ruta | Acción | Notas |
|---|---|---|
| `POST /api/register` | onboarding wallet | `{token, sign_pub, kex_pub}`. Valida las dos claves **públicas** (64 hex c/u), consume un token de invitación de un solo uso. El handle/rol los fija el invite, **nunca** el cliente. |
| `POST /api/session` | sesión por usuario | `{handle, sign_pub, sign_priv, kex_pub, kex_priv}`. Construye la identidad de bus del usuario y arranca un cliente de bus **dedicado** que actúa COMO ese usuario. La privada vive solo en memoria del proceso mientras dure la sesión; se descarta en logout/shutdown. |
| `POST /api/login` | operador (legacy) | La passphrase del operador, ligada al gateway operador compartido. Se mantiene por compatibilidad con el MVP de operador único. |

Archivos nuevos/cambiados:
- `session.go` — `sessionStore` (mapa cookie→`*session`), `identityFromHex` (impone los tamaños exactos 32/64/32/32), `handleSession`. Cada `session` recuerda si su gateway es `owned` (creado para ella, hay que `Close()` en logout) o el operador compartido.
- `register.go` — `registrar` con dos backends: tokens mock de un solo uso (`--mock-tokens "tok=handle:role"`, solo dev/test local) y proxy a `POST /register` del bus (`--register-url`, bus ≥ 0.12.0). Validación estricta de las claves públicas defiende ambos caminos. Un token mock reusado → 409.
- `server.go` — las sesiones pasan de `map[token]time` a un `sessionStore` de `*session` por usuario; `auth()` resuelve la sesión y pasa **su** gateway a cada handler (`handleListRooms`, `handleSend`, `handleStream`, … todos reciben `*session`). El logout de una sesión `owned` cierra su cliente de bus para que la privada y la conexión NATS no sobrevivan a la sesión.
- `main.go` — construye un `busTemplate` que cada sesión wallet clona sobreescribiendo `Identity` con el keypair del usuario; cablea `--register-url` (deriva de `--ctrl-url` si está vacío) y `--mock-tokens`.
- `webgw_test.go` — tests unitarios: tamaños de identidad, validación de clave hex, parseo de tokens mock, register de un solo uso (201 y luego 409), todo usando un vector de wallet fijo derivado en el navegador.

## Evidencia VISUAL

Verificado con el browser MCP (Chrome aislado :9333) contra el SPA real en `:5183`, con la crypto del wallet ejecutándose de verdad en el navegador. Como el bus vivo lo levanta el orquestador en 0.12.0 (fuera del aislamiento de esta rama), la parte de gateway+bus se ejerció contra un **stub desechable** que implementa el contrato `/api/*` (register/session/me/rooms/logout/stream) sin bus — el stub vive fuera del repo y nunca se commitea; sirve solo para conducir el flujo hasta el chat. La derivación, el cifrado, el determinismo y todas las pantallas son el código real.

### Join (Welcome → seed → confirm → password → chat)

- **ShowSeed** generó la mnemónica (`page_perceive`): `word cherry myself improve total more accident bunker credit repair inject photo`, mostrada en grid de 12 con índices, checkbox de confirmación y alerta "unibus NO guarda esta frase".
- **ConfirmSeed** pidió las palabras #1/#8/#10 → `word`/`bunker`/`repair`; el botón Confirmar se habilitó al acertar las tres.
- **SetPassword** aceptó la contraseña (doble entrada) y al pulsar "Crear cuenta y entrar" la secuencia de red fue exactamente: `POST /api/register → 201` (consume token `demo`) → `POST /api/session → 200` (cookie `unibus_session` HttpOnly) → `GET /api/rooms` → `GET /api/rooms/general/stream` (SSE). Aterrizó en el ChatShell con el header mostrando el handle `demo` del invite.

`sign_pub` ORIGINAL de esa seed (capturado en el navegador con `deriveIdentity`):
```
aaf8e3195800cd086dd49707d69bd4ebec7f5eb07db1eef917605348c2d6c5c5
```

### Login wallet (logout → contraseña → descifra → chat)

- Logout (menú → "Desconectar") → `POST /api/logout`. La identidad cifrada se conserva en IndexedDB; el router fue a **WalletLogin** ("Desbloquea la identidad de demo").
- Contraseña **incorrecta** → "Contraseña incorrecta." (fallo de auth GCM mapeado a `WrongPasswordError`, **sin** llamar a `/api/session`).
- Contraseña **correcta** → descifró la clave de IndexedDB → `POST /api/session → 200` → reentró al chat como `demo`. (Login no hace register ni me; va directo a session vía `unlockAndOpen`.)

### Recover (dispositivo nuevo → seed → sign_pub reconstruido → chat) — prueba de oro

- Se simuló un dispositivo nuevo: `fetch('/api/logout')` + borrado de la IndexedDB `unibus-wallet`, recarga → **Welcome** (sin identidad).
- Welcome → "Recuperar con mi seed" → se pegó la mnemónica original en el `Textarea`. La pantalla mostró **"Frase válida ✓"** y la alerta **"Identidad reconstruida"** con:
```
sign_pub: aaf8e3195800cd086dd49707d69bd4ebec7f5eb07db1eef917605348c2d6c5c5
```
  **== EXACTO al `sign_pub` original** capturado en el Join, en un dispositivo con el almacén local borrado. Determinismo demostrado visualmente.
- Contraseña **nueva** distinta + handle `recovered-demo` → "Recuperar y entrar" → la secuencia de red fue `POST /api/session → 200` **sin** `POST /api/register` (correcto: la identidad ya está en el allowlist) → chat.
- Confirmación final a nivel de sesión: `GET /api/me` de la sesión recuperada devolvió `sign_pub == aaf8e319…d6c5c5` y `endpoint == ep_aaf8e3195800cd08` (derivado de ese sign_pub). La identidad que abrió la sesión del gateway es byte por byte la original.

### Cross-language (navegador == Go)

El vector del test del gateway (`webgw_test.go`):
```
mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow"
```
derivado en el navegador produjo:
```
sign_pub = 3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db
kex_pub  = f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257
```
que son **exactamente** los hex hardcodeados en el test Go. La derivación del navegador y la representación que consume el lado Go coinciden byte por byte, en dos lenguajes independientes. Determinismo a tres niveles: (1) `deriveIdentity` dos veces idéntico, (2) recover visual == original, (3) sesión del gateway actúa con el sign_pub original.

## Modelo de confianza

- **El navegador nunca firma ni habla NATS.** Deriva y cifra localmente; entrega su keypair completo al gateway **solo sobre TLS** para abrir sesión. El gateway arranca un cliente de bus dedicado que actúa como ese usuario; la privada vive en memoria del proceso mientras dure la sesión y se descarta al cerrarla. Es la misma confianza que un cliente nativo de escritorio de ese usuario.
- **Frontera E2E actual: hasta el gateway.** El plaintext cruza el canal SSE autenticado (cookie de sesión) sobre loopback (o TLS fronted en deploy), y el gateway ve el plaintext porque es el peer del usuario. La diferencia con 0015 es que ya **no** es el operador único: cada sesión es per-usuario, así que el gateway nunca ve más rooms que las del usuario logueado.
- **Futuro (fase WASM/WebCrypto).** El sellado/apertura AEAD y la firma podrían moverse al navegador (las claves ya se derivan ahí), dejando al gateway como simple relay de frames cifrados que nunca ve plaintext. El esquema de derivación ya está diseñado para ello: las cuatro mitades calzan con `cs.Identity`, así que el mismo material de clave sirve para firmar/abrir en el navegador sin cambiar el contrato del bus.

## Seguridad (posture mantenida)

- La **seed se muestra una vez**, tras una compuerta de confirmación, y nunca se persiste ni viaja al servidor. Solo se guarda (en IndexedDB) la clave privada **cifrada** + las mitades públicas + el handle.
- La **contraseña** solo cifra la clave local (PBKDF2-SHA256 210k → AES-256-GCM); nunca se guarda ni se envía. No es parte de la identidad: la fuente de verdad es la seed.
- El **token de invitación es de un solo uso** (consumido en register; reuso → 409). El handle/rol los fija el invite, no el cliente (sin escalada de privilegios).
- `register` valida estrictamente que las claves sean 32 bytes hex antes de tocar mock o proxy.

## Verificación

- `pnpm build` (tsc -b && vite build) — verde. (Advertencia informativa de tamaño de chunk > 500 kB; no es error.)
- `go build ./...` — verde (exit 0).
- `go test ./cmd/webgw/` — verde (identidad, validación hex, tokens mock, register 201/409).
- Flujo visual join + login + recover — verde, evidencia arriba.

## Gaps / pendientes

- **`/register` real del bus.** El gateway cablea contra el contrato `POST /register {token, sign_pub, kex_pub} → 201/4xx` (`--register-url`, deriva de `--ctrl-url`). Hoy se ejerció con tokens mock; falta la verificación contra el endpoint vivo cuando el bus 0.12.0 lo exponga en el cluster. El stub usado para la evidencia visual NO es el gateway real (este dial­a el bus); la verificación E2E contra bus+gateway reales queda para el rollout.
- **Una identidad por dispositivo (MVP).** `store.ts` guarda una identidad activa (id fijo `active`). Multi-cuenta en un mismo dispositivo es un gap documentado.
- **Handle legible en mensajes.** `MsgWire.sender` sigue siendo el endpoint id; el mapeo endpoint→handle legible es fase posterior (igual que en 0015).
- **Rotación/expiración de sesión del gateway.** Las sesiones por usuario no expiran por tiempo; se cierran en logout/shutdown. Un TTL/refresh es trabajo futuro.
- **Sidebar (lastMessage/unread).** Se rellenan neutros; aún se alimentarán del stream en una iteración futura (heredado de 0015).
```
