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:
+118
-23
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user