feat(web): wallet join/recover/login (BIP39 seed identity)
Add the device-local wallet onboarding to the SPA. The user's identity is derived deterministically from a 12-word BIP39 mnemonic and lives on the device; the browser never signs, never talks NATS, and never sends the seed to the server. Wallet layer (web/src/wallet/): - derive.ts: deterministic identity from a mnemonic. seed = BIP39 seed, then HKDF-SHA256 domain-separated into an Ed25519 signing key (info "unibus-sign-v1") and an X25519 key-exchange key (info "unibus-kex-v1"). The same mnemonic always yields the same sign_pub, which is what makes recovery possible without admin intervention. The four halves match cs.Identity on the Go side exactly. - bip39.ts: thin wrappers over @scure/bip39 (generate, validate, normalize) so the checksum logic stays in the audited library. - crypto.ts: at-rest encryption of the private key with WebCrypto only (PBKDF2-SHA256 210k iters -> AES-256-GCM). The password never leaves the device and only protects the local key copy. - store.ts: IndexedDB persistence of the encrypted identity (private key encrypted; public halves + handle in the clear for display). - account.ts: saveAndOpen / unlockAndOpen / localIdentity compose the primitives with the gateway session API. Screens: - Welcome: choose invite link or recover-with-seed on an empty device. - Join: generate seed, show it once behind an acknowledge gate, confirm 3 random words, set a local password, register the PUBLIC key with the bus via the invite token, then open the session. - Recover: paste the 12 words, validate, show the reconstructed sign_pub, set a new local password, open the session. No register (the identity is already in the allowlist). - WalletLogin: unlock the device's stored identity with the password. - AuthShell: shared card/header for all pre-chat screens. - App.tsx: route between join / welcome / login / recover / chat based on the invite link, a live gateway session, and any stored identity. api.ts/types.ts: add register() and session() against the gateway contract; vite dev server on :5183. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+40
-4
@@ -3,7 +3,15 @@
|
||||
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
|
||||
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
|
||||
// sesión opaca (HttpOnly) que el gateway emite tras el login.
|
||||
import type { MeInfo, Message, MsgWire, Room, RoomWire } from "./types";
|
||||
import type {
|
||||
MeInfo,
|
||||
Message,
|
||||
MsgWire,
|
||||
RegisterResult,
|
||||
Room,
|
||||
RoomWire,
|
||||
} from "./types";
|
||||
import type { WalletIdentity } from "./wallet/derive";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -68,9 +76,37 @@ export function messageFromWire(m: MsgWire): Message {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ---- sesión -------------------------------------------------------------
|
||||
// login desbloquea la sesión del gateway con la passphrase del operador. El
|
||||
// gateway responde con una cookie de sesión; me() comprueba si ya hay una.
|
||||
// ---- onboarding wallet --------------------------------------------------
|
||||
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
|
||||
// bus usando el token del enlace de invitación. NO requiere sesión: el token
|
||||
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
|
||||
// privada NUNCA se envía aquí.
|
||||
register: (token: string, signPub: string, kexPub: string) =>
|
||||
req<RegisterResult>("/api/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
|
||||
}),
|
||||
|
||||
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
|
||||
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
|
||||
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
|
||||
// mientras dure la sesión; no se persiste en el servidor.
|
||||
session: (id: WalletIdentity, handle: string) =>
|
||||
req<MeInfo>("/api/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
handle,
|
||||
sign_pub: id.signPub,
|
||||
sign_priv: id.signPriv,
|
||||
kex_pub: id.kexPub,
|
||||
kex_priv: id.kexPriv,
|
||||
}),
|
||||
}),
|
||||
|
||||
// ---- sesión (legacy operador) ------------------------------------------
|
||||
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
|
||||
// El camino principal ahora es el wallet (session); login se mantiene por
|
||||
// compatibilidad con el MVP de operador único.
|
||||
login: (passphrase: string) =>
|
||||
req<MeInfo>("/api/login", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user