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:
2026-06-08 21:21:50 +02:00
parent 7d93d550d1
commit 4994ea1483
16 changed files with 1303 additions and 29 deletions
+118 -23
View File
@@ -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<Route>("loading");
const [user, setUser] = useState<User | null>(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 (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
switch (route) {
case "loading":
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
case "join":
return (
<Join
token={token}
onJoined={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "welcome":
return (
<Welcome
onJoinToken={(t) => {
setToken(t);
setRoute("join");
}}
onRecover={() => setRoute("recover")}
/>
);
case "login":
return (
<WalletLogin
handle={storedHandle}
onLoggedIn={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "recover":
return (
<Recover
onRecovered={enterChat}
onBack={() => setRoute(storedHandle ? "login" : "welcome")}
/>
);
case "chat":
return user ? (
<ChatShell user={user} onLogout={logout} />
) : (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (!user) return <Login onLogin={setUser} />;
return <ChatShell user={user} onLogout={logout} />;
}