From 4994ea1483f9cafd2c2d4d93aa159af5a59e9ac3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 8 Jun 2026 21:21:50 +0200 Subject: [PATCH] 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) --- web/package.json | 3 + web/pnpm-lock.yaml | 36 +++++ web/src/App.tsx | 141 ++++++++++++++--- web/src/AuthShell.tsx | 47 ++++++ web/src/Join.tsx | 322 ++++++++++++++++++++++++++++++++++++++ web/src/Recover.tsx | 175 +++++++++++++++++++++ web/src/WalletLogin.tsx | 77 +++++++++ web/src/Welcome.tsx | 70 +++++++++ web/src/api.ts | 44 +++++- web/src/types.ts | 12 +- web/src/wallet/account.ts | 60 +++++++ web/src/wallet/bip39.ts | 55 +++++++ web/src/wallet/crypto.ts | 124 +++++++++++++++ web/src/wallet/derive.ts | 69 ++++++++ web/src/wallet/store.ts | 95 +++++++++++ web/vite.config.ts | 2 +- 16 files changed, 1303 insertions(+), 29 deletions(-) create mode 100644 web/src/AuthShell.tsx create mode 100644 web/src/Join.tsx create mode 100644 web/src/Recover.tsx create mode 100644 web/src/WalletLogin.tsx create mode 100644 web/src/Welcome.tsx create mode 100644 web/src/wallet/account.ts create mode 100644 web/src/wallet/bip39.ts create mode 100644 web/src/wallet/crypto.ts create mode 100644 web/src/wallet/derive.ts create mode 100644 web/src/wallet/store.ts diff --git a/web/package.json b/web/package.json index 552fb9f4..79b69781 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,9 @@ "dependencies": { "@mantine/core": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7fa163af..0685fcf9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.7) + '@noble/curves': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 + '@scure/bip39': + specifier: ^2.2.0 + version: 2.2.0 '@tabler/icons-react': specifier: ^3.36.0 version: 3.44.0(react@19.2.7) @@ -339,6 +348,14 @@ packages: peerDependencies: react: ^19.2.0 + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -480,6 +497,12 @@ packages: cpu: [x64] os: [win32] + '@scure/base@2.2.0': + resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==} + + '@scure/bip39@2.2.0': + resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} + '@tabler/icons-react@3.44.0': resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} peerDependencies: @@ -1086,6 +1109,12 @@ snapshots: dependencies: react: 19.2.7 + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@2.2.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.61.1': @@ -1163,6 +1192,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.1': optional: true + '@scure/base@2.2.0': {} + + '@scure/bip39@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@scure/base': 2.2.0 + '@tabler/icons-react@3.44.0(react@19.2.7)': dependencies: '@tabler/icons': 3.44.0 diff --git a/web/src/App.tsx b/web/src/App.tsx index 996d6e9d..5c8e7996 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,44 +1,139 @@ import { useEffect, useState } from "react"; import { Center, Loader } from "@mantine/core"; -import { Login } from "./Login"; import { ChatShell } from "./ChatShell"; +import { Join } from "./Join"; +import { Recover } from "./Recover"; +import { WalletLogin } from "./WalletLogin"; +import { Welcome } from "./Welcome"; import { api } from "./api"; +import { localIdentity } from "./wallet/account"; import type { User } from "./types"; -// shortEndpoint hace legible el endpoint id del operador para mostrarlo como -// handle por defecto cuando no se escribió uno en el login. -function shortEndpoint(ep: string) { - return ep.slice(0, 8); +type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat"; + +// readJoinToken returns the invite token if the current URL is /join?token=XXX. +function readJoinToken(): string | null { + if (window.location.pathname !== "/join") return null; + return new URLSearchParams(window.location.search).get("token"); +} + +// clearUrl drops any /join?token from the address bar once consumed, so a refresh +// or a shared screenshot does not replay the (single-use) token. +function clearUrl() { + if (window.location.pathname !== "/") { + window.history.replaceState(null, "", "/"); + } } export function App() { + const [route, setRoute] = useState("loading"); const [user, setUser] = useState(null); - const [checking, setChecking] = useState(true); + const [token, setToken] = useState(""); + const [storedHandle, setStoredHandle] = useState(""); - // Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la - // hay, entra directo; si no (401), muestra el login. + // Decide the entry screen on mount: an invite link goes straight to join; a live + // gateway session resumes the chat; a device with a stored identity shows the + // password unlock; an empty device shows the welcome chooser. useEffect(() => { - api - .me() - .then((me) => - setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }), - ) - .catch(() => {}) - .finally(() => setChecking(false)); + const t = readJoinToken(); + if (t) { + setToken(t); + setRoute("join"); + return; + } + let cancelled = false; + (async () => { + try { + const me = await api.me(); + if (cancelled) return; + setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) }); + setRoute("chat"); + return; + } catch { + // no live session — fall through + } + const stored = await localIdentity(); + if (cancelled) return; + if (stored) { + setStoredHandle(stored.handle); + setRoute("login"); + } else { + setRoute("welcome"); + } + })(); + return () => { + cancelled = true; + }; }, []); + const enterChat = (u: User) => { + setUser(u); + setRoute("chat"); + clearUrl(); + }; + const logout = () => { void api.logout().catch(() => {}); setUser(null); + // Keep the encrypted identity on the device: logging out returns to the + // password unlock, not a full reset. + void localIdentity().then((stored) => { + if (stored) { + setStoredHandle(stored.handle); + setRoute("login"); + } else { + setRoute("welcome"); + } + }); }; - if (checking) { - return ( -
- -
- ); + switch (route) { + case "loading": + return ( +
+ +
+ ); + case "join": + return ( + setRoute("recover")} + /> + ); + case "welcome": + return ( + { + setToken(t); + setRoute("join"); + }} + onRecover={() => setRoute("recover")} + /> + ); + case "login": + return ( + setRoute("recover")} + /> + ); + case "recover": + return ( + setRoute(storedHandle ? "login" : "welcome")} + /> + ); + case "chat": + return user ? ( + + ) : ( +
+ +
+ ); } - if (!user) return ; - return ; } diff --git a/web/src/AuthShell.tsx b/web/src/AuthShell.tsx new file mode 100644 index 00000000..6f772fb5 --- /dev/null +++ b/web/src/AuthShell.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { Card, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; + +// AuthCard is the shared centered card used by every pre-chat screen (welcome, +// join, recover, wallet login) so they all look like one flow. +export function AuthCard({ + width = 460, + children, +}: { + width?: number; + children: ReactNode; +}) { + return ( +
+ + {children} + +
+ ); +} + +// AuthHeader is the icon + title + subtitle block at the top of an auth card. +export function AuthHeader({ + icon, + title, + subtitle, +}: { + icon: ReactNode; + title: string; + subtitle?: string; +}) { + return ( + + + {icon} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + ); +} diff --git a/web/src/Join.tsx b/web/src/Join.tsx new file mode 100644 index 00000000..cab85eda --- /dev/null +++ b/web/src/Join.tsx @@ -0,0 +1,322 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Card, + Center, + Checkbox, + CopyButton, + Group, + Loader, + PasswordInput, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconCheck, + IconCopy, + IconKey, + IconShieldLock, +} from "@tabler/icons-react"; +import { api, ApiError } from "./api"; +import { AuthCard, AuthHeader } from "./AuthShell"; +import type { User } from "./types"; +import { newMnemonic, mnemonicWords } from "./wallet/bip39"; +import { deriveIdentity, type WalletIdentity } from "./wallet/derive"; +import { saveAndOpen } from "./wallet/account"; + +type Step = "generating" | "show-seed" | "confirm-seed" | "password" | "joining"; + +// pickPositions chooses `count` distinct word positions (0-based) to ask the user +// to confirm. This is a UI choice, not key material, so Math.random is fine. +function pickPositions(total: number, count: number): number[] { + const all = Array.from({ length: total }, (_, i) => i); + for (let i = all.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [all[i], all[j]] = [all[j], all[i]]; + } + return all.slice(0, count).sort((a, b) => a - b); +} + +// Join is the onboarding page reached from an invite link (/join?token=XXX). It +// generates a brand-new BIP39 seed, derives the identity, shows the seed exactly +// once with a confirmation gate, takes a local password, registers the PUBLIC key +// with the bus using the token, and enters the chat. The seed is never persisted +// and never sent to the server. +export function Join({ + token, + onJoined, + onRecover, +}: { + token: string; + onJoined: (u: User) => void; + onRecover: () => void; +}) { + const [step, setStep] = useState("generating"); + const [mnemonic, setMnemonic] = useState(""); + const [identity, setIdentity] = useState(null); + const [error, setError] = useState(null); + + // Generate the seed + identity once on mount. Deriving is fast and pure. + useEffect(() => { + if (!token) { + setError("Enlace de invitación inválido: falta el token."); + return; + } + try { + const m = newMnemonic(); + setMnemonic(m); + setIdentity(deriveIdentity(m)); + setStep("show-seed"); + } catch { + setError("No se pudo generar la identidad en este navegador."); + } + }, [token]); + + const words = useMemo(() => mnemonicWords(mnemonic), [mnemonic]); + + if (error && step === "generating") { + return ( + + } title="Error"> + {error} + + + + ); + } + + if (step === "generating" || !identity) { + return ( +
+ +
+ ); + } + + if (step === "show-seed") { + return ( + setStep("confirm-seed")} /> + ); + } + + if (step === "confirm-seed") { + return ( + setStep("show-seed")} + onConfirmed={() => setStep("password")} + /> + ); + } + + // step === "password" | "joining" + return ( + { + setStep("joining"); + setError(null); + try { + // Register the PUBLIC identity with the bus (token authorizes), then + // encrypt the private key locally and open the per-user session. + const res = await api.register(token, identity.signPub, identity.kexPub); + const user = await saveAndOpen(identity, res.handle, password); + onJoined(user); + } catch (e) { + setError( + e instanceof ApiError ? e.message : "No se pudo completar el alta.", + ); + setStep("password"); + } + }} + /> + ); +} + +// ---- sub-screens ---------------------------------------------------------- + +function ShowSeed({ + words, + onContinue, +}: { + words: string[]; + onContinue: () => void; +}) { + const [acknowledged, setAcknowledged] = useState(false); + const phrase = words.join(" "); + return ( + + } + title="Guarda tu frase de recuperación" + subtitle="Estas 12 palabras son tu ÚNICA forma de recuperar tu cuenta si olvidas la contraseña o cambias de dispositivo. No las compartas con nadie." + /> + + + {words.map((w, i) => ( + + + {i + 1} + + + {w} + + + ))} + + + + + {({ copied, copy }) => ( + + )} + + + }> + unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo + el administrador podrá darte de alta de nuevo. + + setAcknowledged(e.currentTarget.checked)} + label="He guardado mi frase de recuperación en un lugar seguro" + /> + + + ); +} + +function ConfirmSeed({ + words, + onBack, + onConfirmed, +}: { + words: string[]; + onBack: () => void; + onConfirmed: () => void; +}) { + // Ask the user to re-type 3 random words from their phrase. This proves they + // actually wrote the seed down rather than clicking through. + const positions = useMemo(() => pickPositions(words.length, 3), [words.length]); + const [inputs, setInputs] = useState>({}); + const allCorrect = positions.every( + (p) => (inputs[p] ?? "").trim().toLowerCase() === words[p], + ); + const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0); + return ( + + } + title="Confirma tu frase" + subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien." + /> + + {positions.map((p) => ( + 0 && + (inputs[p] ?? "").trim().toLowerCase() !== words[p] + ? "No coincide" + : undefined + } + onChange={(e) => { + // Capture the value synchronously: React nulls e.currentTarget + // after dispatch, so reading it inside the state updater (which runs + // later) would throw "Cannot read properties of null". + const v = e.currentTarget.value; + setInputs((prev) => ({ ...prev, [p]: v })); + }} + autoComplete="off" + spellCheck={false} + /> + ))} + + {!allCorrect && anyTyped && ( + + Revisa el orden y la ortografía de las palabras. + + )} + + + + + + ); +} + +function SetPassword({ + busy, + error, + onSubmit, +}: { + busy: boolean; + error: string | null; + onSubmit: (password: string) => void; +}) { + const [pw, setPw] = useState(""); + const [pw2, setPw2] = useState(""); + const tooShort = pw.length > 0 && pw.length < 8; + const mismatch = pw2.length > 0 && pw !== pw2; + const ready = pw.length >= 8 && pw === pw2 && !busy; + return ( + + } + title="Protege tu identidad" + subtitle="Elige una contraseña para cifrar tu clave en ESTE dispositivo. No se guarda ni se envía a ningún servidor; solo desbloquea tu clave local." + /> + } + value={pw} + error={tooShort ? "Demasiado corta" : undefined} + onChange={(e) => setPw(e.currentTarget.value)} + data-autofocus + /> + } + value={pw2} + error={mismatch ? "No coincide" : undefined} + onChange={(e) => setPw2(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)} + /> + {error && ( + + {error} + + )} + + + ); +} diff --git a/web/src/Recover.tsx b/web/src/Recover.tsx new file mode 100644 index 00000000..991de7d5 --- /dev/null +++ b/web/src/Recover.tsx @@ -0,0 +1,175 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Anchor, + Button, + Code, + Group, + PasswordInput, + Stack, + Text, + Textarea, + TextInput, +} from "@mantine/core"; +import { IconKey, IconRotateClockwise } from "@tabler/icons-react"; +import { AuthCard, AuthHeader } from "./AuthShell"; +import { ApiError } from "./api"; +import type { User } from "./types"; +import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39"; +import { deriveIdentity } from "./wallet/derive"; +import { saveAndOpen } from "./wallet/account"; + +type Step = "phrase" | "password"; + +// Recover re-creates an existing identity from its 12-word seed — no admin needed. +// Validating the BIP39 phrase and re-deriving yields the SAME keypair (same +// sign_pub) the bus already authorizes, so the user lands back in the allowlist +// with their place intact. A new local password then re-encrypts the key on this +// device. Only if the user loses BOTH the password AND the seed must the admin +// re-provision them. +export function Recover({ + onRecovered, + onBack, +}: { + onRecovered: (u: User) => void; + onBack: () => void; +}) { + const [step, setStep] = useState("phrase"); + const [phrase, setPhrase] = useState(""); + const [handle, setHandle] = useState(""); + const [pw, setPw] = useState(""); + const [pw2, setPw2] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const normalized = normalizeMnemonic(phrase); + const wordCount = mnemonicWords(phrase).length; + const valid = isValidMnemonic(phrase); + + // Re-derive as soon as the phrase is valid, so we can show the user which + // identity (sign_pub) it maps to before they commit a new password. + const identity = useMemo( + () => (valid ? deriveIdentity(normalized) : null), + [valid, normalized], + ); + + if (step === "phrase") { + return ( + + } + title="Recuperar con tu frase" + subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor." + /> +