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:
@@ -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"
|
||||
|
||||
Generated
+36
@@ -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
|
||||
|
||||
+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} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Center h="100vh" bg="dark.9" p="md">
|
||||
<Card w={width} p="xl" radius="lg" withBorder bg="dark.7">
|
||||
<Stack gap="lg">{children}</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={56} radius="xl" variant="light" color="brand">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center">
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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<Step>("generating");
|
||||
const [mnemonic, setMnemonic] = useState("");
|
||||
const [identity, setIdentity] = useState<WalletIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AuthCard>
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button variant="light" mt="md" onClick={onRecover}>
|
||||
Recuperar con mi seed
|
||||
</Button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "generating" || !identity) {
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Loader color="brand" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "show-seed") {
|
||||
return (
|
||||
<ShowSeed words={words} onContinue={() => setStep("confirm-seed")} />
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "confirm-seed") {
|
||||
return (
|
||||
<ConfirmSeed
|
||||
words={words}
|
||||
onBack={() => setStep("show-seed")}
|
||||
onConfirmed={() => setStep("password")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// step === "password" | "joining"
|
||||
return (
|
||||
<SetPassword
|
||||
busy={step === "joining"}
|
||||
error={error}
|
||||
onSubmit={async (password) => {
|
||||
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 (
|
||||
<AuthCard>
|
||||
<AuthHeader
|
||||
icon={<IconShieldLock size={30} />}
|
||||
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."
|
||||
/>
|
||||
<Card bg="dark.8" radius="md" p="md" withBorder>
|
||||
<SimpleGrid cols={3} spacing="xs" verticalSpacing="xs">
|
||||
{words.map((w, i) => (
|
||||
<Group gap={6} wrap="nowrap" key={i}>
|
||||
<Text size="xs" c="dimmed" w={18} ta="right">
|
||||
{i + 1}
|
||||
</Text>
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{w}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
<Group justify="space-between">
|
||||
<CopyButton value={phrase}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color={copied ? "teal" : "gray"}
|
||||
leftSection={
|
||||
copied ? <IconCheck size={14} /> : <IconCopy size={14} />
|
||||
}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? "Copiada" : "Copiar"}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo
|
||||
el administrador podrá darte de alta de nuevo.
|
||||
</Alert>
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
|
||||
label="He guardado mi frase de recuperación en un lugar seguro"
|
||||
/>
|
||||
<Button disabled={!acknowledged} onClick={onContinue}>
|
||||
Continuar
|
||||
</Button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<number, string>>({});
|
||||
const allCorrect = positions.every(
|
||||
(p) => (inputs[p] ?? "").trim().toLowerCase() === words[p],
|
||||
);
|
||||
const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0);
|
||||
return (
|
||||
<AuthCard>
|
||||
<AuthHeader
|
||||
icon={<IconCheck size={30} />}
|
||||
title="Confirma tu frase"
|
||||
subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien."
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
{positions.map((p) => (
|
||||
<TextInput
|
||||
key={p}
|
||||
label={`Palabra #${p + 1}`}
|
||||
placeholder={`palabra ${p + 1}`}
|
||||
value={inputs[p] ?? ""}
|
||||
error={
|
||||
(inputs[p] ?? "").length > 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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{!allCorrect && anyTyped && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Revisa el orden y la ortografía de las palabras.
|
||||
</Text>
|
||||
)}
|
||||
<Group grow>
|
||||
<Button variant="default" onClick={onBack}>
|
||||
Volver a ver
|
||||
</Button>
|
||||
<Button disabled={!allCorrect} onClick={onConfirmed}>
|
||||
Confirmar
|
||||
</Button>
|
||||
</Group>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AuthCard>
|
||||
<AuthHeader
|
||||
icon={<IconKey size={30} />}
|
||||
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."
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Contraseña"
|
||||
description="Mínimo 8 caracteres"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={pw}
|
||||
error={tooShort ? "Demasiado corta" : undefined}
|
||||
onChange={(e) => setPw(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Repite la contraseña"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={pw2}
|
||||
error={mismatch ? "No coincide" : undefined}
|
||||
onChange={(e) => setPw2(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)}
|
||||
/>
|
||||
{error && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button disabled={!ready} loading={busy} onClick={() => onSubmit(pw)}>
|
||||
Crear cuenta y entrar
|
||||
</Button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -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<Step>("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<string | null>(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 (
|
||||
<AuthCard>
|
||||
<AuthHeader
|
||||
icon={<IconRotateClockwise size={30} />}
|
||||
title="Recuperar con tu frase"
|
||||
subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor."
|
||||
/>
|
||||
<Textarea
|
||||
label="Frase de recuperación (12 palabras)"
|
||||
placeholder="palabra1 palabra2 palabra3 …"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={phrase}
|
||||
onChange={(e) => setPhrase(e.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Text size="xs" c={valid ? "teal" : "dimmed"}>
|
||||
{wordCount > 0
|
||||
? valid
|
||||
? "Frase válida ✓"
|
||||
: `${wordCount}/12 palabras — frase aún no válida`
|
||||
: "Separadas por espacios."}
|
||||
</Text>
|
||||
{identity && (
|
||||
<Alert color="brand" variant="light" title="Identidad reconstruida">
|
||||
<Text size="xs">Tu clave pública de firma (sign_pub):</Text>
|
||||
<Code block>{identity.signPub}</Code>
|
||||
</Alert>
|
||||
)}
|
||||
<Group grow>
|
||||
<Button variant="default" onClick={onBack}>
|
||||
Volver
|
||||
</Button>
|
||||
<Button disabled={!valid} onClick={() => setStep("password")}>
|
||||
Continuar
|
||||
</Button>
|
||||
</Group>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
// step === "password"
|
||||
const tooShort = pw.length > 0 && pw.length < 8;
|
||||
const mismatch = pw2.length > 0 && pw !== pw2;
|
||||
const ready = pw.length >= 8 && pw === pw2 && !busy && identity !== null;
|
||||
|
||||
const finish = async () => {
|
||||
if (!ready || !identity) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// No register here: the identity is already in the allowlist. Just re-encrypt
|
||||
// locally and open the session as the recovered user.
|
||||
const user = await saveAndOpen(identity, handle.trim(), pw);
|
||||
onRecovered(user);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof ApiError
|
||||
? e.message
|
||||
: "No se pudo abrir la sesión con la identidad recuperada.",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
<AuthHeader
|
||||
icon={<IconKey size={30} />}
|
||||
title="Nueva contraseña"
|
||||
subtitle="Elige una contraseña para cifrar tu clave recuperada en este dispositivo."
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Nombre a mostrar (opcional)"
|
||||
placeholder="tu-handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.currentTarget.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Contraseña"
|
||||
description="Mínimo 8 caracteres"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={pw}
|
||||
error={tooShort ? "Demasiado corta" : undefined}
|
||||
onChange={(e) => setPw(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Repite la contraseña"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={pw2}
|
||||
error={mismatch ? "No coincide" : undefined}
|
||||
onChange={(e) => setPw2(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void finish()}
|
||||
/>
|
||||
</Stack>
|
||||
{error && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Group grow>
|
||||
<Button variant="default" onClick={() => setStep("phrase")}>
|
||||
Volver
|
||||
</Button>
|
||||
<Button disabled={!ready} loading={busy} onClick={() => void finish()}>
|
||||
Recuperar y entrar
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="center">
|
||||
<Anchor size="xs" c="dimmed" onClick={onBack}>
|
||||
Cancelar
|
||||
</Anchor>
|
||||
</Group>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
|
||||
import { IconKey, IconWallet } from "@tabler/icons-react";
|
||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||
import { ApiError } from "./api";
|
||||
import type { User } from "./types";
|
||||
import { unlockAndOpen } from "./wallet/account";
|
||||
import { WrongPasswordError } from "./wallet/crypto";
|
||||
|
||||
// WalletLogin is shown when this device already holds an encrypted identity. The
|
||||
// password decrypts the local private key and opens a per-user gateway session.
|
||||
// The password is never stored and never sent to the server.
|
||||
export function WalletLogin({
|
||||
handle,
|
||||
onLoggedIn,
|
||||
onRecover,
|
||||
}: {
|
||||
handle: string;
|
||||
onLoggedIn: (u: User) => void;
|
||||
onRecover: () => void;
|
||||
}) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const unlock = async () => {
|
||||
if (!password || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await unlockAndOpen(password);
|
||||
onLoggedIn(user);
|
||||
} catch (e) {
|
||||
if (e instanceof WrongPasswordError) {
|
||||
setError("Contraseña incorrecta.");
|
||||
} else if (e instanceof ApiError) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError("No se pudo abrir tu identidad.");
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCard width={400}>
|
||||
<AuthHeader
|
||||
icon={<IconWallet size={30} />}
|
||||
title="unibus"
|
||||
subtitle={`Desbloquea la identidad de ${handle || "este dispositivo"}`}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Contraseña"
|
||||
description="Descifra tu clave guardada en este dispositivo"
|
||||
placeholder="••••••••"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void unlock()}
|
||||
data-autofocus
|
||||
/>
|
||||
{error && (
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button fullWidth onClick={() => void unlock()} disabled={!password} loading={busy}>
|
||||
Entrar
|
||||
</Button>
|
||||
<Group justify="center">
|
||||
<Anchor size="xs" c="dimmed" onClick={onRecover}>
|
||||
¿Olvidaste la contraseña? Recupera con tu frase de 12 palabras
|
||||
</Anchor>
|
||||
</Group>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Divider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconLink, IconRotateClockwise, IconShieldLock } from "@tabler/icons-react";
|
||||
import { AuthCard, AuthHeader } from "./AuthShell";
|
||||
|
||||
// extractToken pulls the invite token out of whatever the user pastes: a full
|
||||
// link (.../join?token=XXX), a bare "token=XXX", or just the token itself.
|
||||
function extractToken(input: string): string {
|
||||
const s = input.trim();
|
||||
if (!s) return "";
|
||||
const m = s.match(/[?&]token=([^&\s]+)/);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
if (s.startsWith("token=")) return s.slice("token=".length);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Welcome is the entry screen on a device with no local identity. It offers the
|
||||
// two ways in: open an invite link (new account) or recover an existing account
|
||||
// from its 12-word seed.
|
||||
export function Welcome({
|
||||
onJoinToken,
|
||||
onRecover,
|
||||
}: {
|
||||
onJoinToken: (token: string) => void;
|
||||
onRecover: () => void;
|
||||
}) {
|
||||
const [link, setLink] = useState("");
|
||||
const token = extractToken(link);
|
||||
|
||||
return (
|
||||
<AuthCard width={420}>
|
||||
<AuthHeader
|
||||
icon={<IconShieldLock size={30} />}
|
||||
title="unibus"
|
||||
subtitle="Mensajería cifrada de extremo a extremo. Tu identidad vive en tu dispositivo."
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Tengo un enlace de invitación
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="Pega aquí tu enlace /join?token=…"
|
||||
leftSection={<IconLink size={16} />}
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && token && onJoinToken(token)}
|
||||
/>
|
||||
<Button disabled={!token} onClick={() => onJoinToken(token)}>
|
||||
Crear mi cuenta
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider label="o" labelPosition="center" color="dark.4" />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Ya tengo una cuenta
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRotateClockwise size={16} />}
|
||||
onClick={onRecover}
|
||||
>
|
||||
Recuperar con mi seed (12 palabras)
|
||||
</Button>
|
||||
</Stack>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
+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",
|
||||
|
||||
+11
-1
@@ -26,10 +26,20 @@ export interface Room {
|
||||
|
||||
// ---- formas de la API del gateway (wire) ---------------------------------
|
||||
|
||||
// MeInfo es la identidad del operador que el gateway encarna (GET /api/me).
|
||||
// MeInfo es la identidad que el gateway encarna en la sesión actual (GET /api/me,
|
||||
// POST /api/session, POST /api/login). En el modelo wallet es la identidad del
|
||||
// USUARIO logueado; `handle` es su nombre a mostrar.
|
||||
export interface MeInfo {
|
||||
endpoint: string;
|
||||
sign_pub: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
// RegisterResult es la respuesta de POST /api/register: el handle y rol que el
|
||||
// invite (token) fijó para el nuevo usuario.
|
||||
export interface RegisterResult {
|
||||
handle: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// High-level wallet account operations shared by the join, recover and login
|
||||
// flows. These compose the low-level primitives (derive / crypto / store) with
|
||||
// the gateway API so the page components stay thin.
|
||||
|
||||
import { api } from "../api";
|
||||
import type { MeInfo, User } from "../types";
|
||||
import { decryptJSON, encryptJSON } from "./crypto";
|
||||
import type { WalletIdentity } from "./derive";
|
||||
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
|
||||
|
||||
function toUser(me: MeInfo): User {
|
||||
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) };
|
||||
}
|
||||
|
||||
// saveAndOpen encrypts the identity under `password`, stores it on this device,
|
||||
// and opens a gateway session as that user. Used by join (new identity) and
|
||||
// recover (re-derived identity): both end with a locally-encrypted key plus a
|
||||
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
|
||||
// keypair is persisted (encrypted).
|
||||
export async function saveAndOpen(
|
||||
identity: WalletIdentity,
|
||||
handle: string,
|
||||
password: string,
|
||||
): Promise<User> {
|
||||
const enc = await encryptJSON(identity, password);
|
||||
await putIdentity({
|
||||
handle,
|
||||
signPub: identity.signPub,
|
||||
kexPub: identity.kexPub,
|
||||
enc,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const me = await api.session(identity, handle);
|
||||
return toUser(me);
|
||||
}
|
||||
|
||||
// unlockAndOpen reads this device's stored identity, decrypts the private key with
|
||||
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad
|
||||
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
|
||||
export async function unlockAndOpen(password: string): Promise<User> {
|
||||
const stored = await getIdentity();
|
||||
if (!stored) throw new NoLocalIdentityError();
|
||||
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
|
||||
const me = await api.session(identity, stored.handle);
|
||||
return toUser(me);
|
||||
}
|
||||
|
||||
// localIdentity returns the device's stored identity record (or null), for the
|
||||
// router to decide between the password-unlock screen and the welcome screen, and
|
||||
// to greet the user by handle before unlocking.
|
||||
export async function localIdentity(): Promise<StoredIdentity | null> {
|
||||
return getIdentity();
|
||||
}
|
||||
|
||||
export class NoLocalIdentityError extends Error {
|
||||
constructor() {
|
||||
super("no local identity on this device");
|
||||
this.name = "NoLocalIdentityError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Thin wrappers over @scure/bip39 (a small, audited BIP39 implementation that
|
||||
// ships the English wordlist and the mnemonic<->entropy conversions). We do not
|
||||
// roll our own checksum logic — getting the BIP39 checksum wrong silently is a
|
||||
// classic footgun, so the conversion stays in the library.
|
||||
|
||||
import {
|
||||
generateMnemonic,
|
||||
validateMnemonic,
|
||||
mnemonicToEntropy,
|
||||
} from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||
|
||||
// MNEMONIC_STRENGTH_BITS = 128 bits of entropy => exactly 12 words.
|
||||
export const MNEMONIC_STRENGTH_BITS = 128;
|
||||
export const MNEMONIC_WORD_COUNT = 12;
|
||||
|
||||
// newMnemonic returns a fresh 12-word mnemonic from a CSPRNG (crypto.getRandomValues
|
||||
// inside @scure). The caller must show it to the user once and never persist it.
|
||||
export function newMnemonic(): string {
|
||||
return generateMnemonic(wordlist, MNEMONIC_STRENGTH_BITS);
|
||||
}
|
||||
|
||||
// normalizeMnemonic lowercases, trims and collapses whitespace so a phrase the
|
||||
// user typed (extra spaces, trailing newline, mixed case) validates the same way
|
||||
// it would have been generated.
|
||||
export function normalizeMnemonic(input: string): string {
|
||||
return input.trim().toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// mnemonicWords splits a phrase into its individual words (normalized).
|
||||
export function mnemonicWords(input: string): string[] {
|
||||
const n = normalizeMnemonic(input);
|
||||
return n ? n.split(" ") : [];
|
||||
}
|
||||
|
||||
// isValidMnemonic checks word count, that every word is in the wordlist, and the
|
||||
// BIP39 checksum. A phrase that fails this must not be used to derive an identity.
|
||||
export function isValidMnemonic(input: string): boolean {
|
||||
const n = normalizeMnemonic(input);
|
||||
if (mnemonicWords(n).length !== MNEMONIC_WORD_COUNT) return false;
|
||||
try {
|
||||
return validateMnemonic(n, wordlist);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// entropyHex returns the underlying entropy (hex) of a valid mnemonic. Used only
|
||||
// for diagnostics / tests, never sent anywhere.
|
||||
export function entropyHex(input: string): string {
|
||||
const bytes = mnemonicToEntropy(normalizeMnemonic(input), wordlist);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Local at-rest encryption of the wallet's private key, using only the platform
|
||||
// WebCrypto (crypto.subtle) — no extra dependency, no WASM. The password derives
|
||||
// an AES-GCM key via PBKDF2; the password itself is never stored, never sent to
|
||||
// the server, and is not part of the identity (it only protects the local copy
|
||||
// of the private key). The identity's source of truth is the BIP39 seed.
|
||||
|
||||
// PBKDF2 work factor. 210k SHA-256 iterations is the OWASP 2023 floor for
|
||||
// PBKDF2-HMAC-SHA256; stored alongside the blob so a future bump stays readable.
|
||||
const PBKDF2_ITERS = 210_000;
|
||||
|
||||
// EncryptedBlob is the at-rest form of a secret: AES-256-GCM ciphertext plus the
|
||||
// public KDF parameters needed to re-derive the key from the password. None of
|
||||
// these fields is secret on its own — only the password (never stored) unlocks it.
|
||||
export interface EncryptedBlob {
|
||||
kdf: "PBKDF2-SHA256";
|
||||
iters: number;
|
||||
salt: string; // hex, 16 random bytes (PBKDF2 salt)
|
||||
iv: string; // hex, 12 random bytes (AES-GCM nonce)
|
||||
ciphertext: string; // hex (includes the GCM auth tag)
|
||||
}
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const x of b) s += x.toString(16).padStart(2, "0");
|
||||
return s;
|
||||
}
|
||||
|
||||
function fromHex(h: string): Uint8Array {
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveAesKey(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
iters: number,
|
||||
): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt: salt as BufferSource, iterations: iters, hash: "SHA-256" },
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
// encryptSecret seals `plaintext` under `password` with a fresh random salt+iv.
|
||||
export async function encryptSecret(
|
||||
plaintext: Uint8Array,
|
||||
password: string,
|
||||
): Promise<EncryptedBlob> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveAesKey(password, salt, PBKDF2_ITERS);
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
||||
key,
|
||||
plaintext as BufferSource,
|
||||
);
|
||||
return {
|
||||
kdf: "PBKDF2-SHA256",
|
||||
iters: PBKDF2_ITERS,
|
||||
salt: toHex(salt),
|
||||
iv: toHex(iv),
|
||||
ciphertext: toHex(new Uint8Array(ct)),
|
||||
};
|
||||
}
|
||||
|
||||
// WrongPasswordError is thrown when GCM authentication fails on decrypt — almost
|
||||
// always a wrong password (or a corrupted blob). Callers map it to a friendly
|
||||
// "contraseña incorrecta" message.
|
||||
export class WrongPasswordError extends Error {
|
||||
constructor() {
|
||||
super("wrong password");
|
||||
this.name = "WrongPasswordError";
|
||||
}
|
||||
}
|
||||
|
||||
// decryptSecret re-derives the key from `password` and opens the blob. A wrong
|
||||
// password makes GCM verification fail, surfaced as WrongPasswordError.
|
||||
export async function decryptSecret(
|
||||
blob: EncryptedBlob,
|
||||
password: string,
|
||||
): Promise<Uint8Array> {
|
||||
const key = await deriveAesKey(password, fromHex(blob.salt), blob.iters);
|
||||
try {
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromHex(blob.iv) as BufferSource },
|
||||
key,
|
||||
fromHex(blob.ciphertext) as BufferSource,
|
||||
);
|
||||
return new Uint8Array(pt);
|
||||
} catch {
|
||||
throw new WrongPasswordError();
|
||||
}
|
||||
}
|
||||
|
||||
// JSON convenience: encrypt/decrypt a JS value as UTF-8 JSON. We use this to seal
|
||||
// the whole WalletIdentity object (the private halves) under the password.
|
||||
export async function encryptJSON(
|
||||
value: unknown,
|
||||
password: string,
|
||||
): Promise<EncryptedBlob> {
|
||||
return encryptSecret(new TextEncoder().encode(JSON.stringify(value)), password);
|
||||
}
|
||||
|
||||
export async function decryptJSON<T>(
|
||||
blob: EncryptedBlob,
|
||||
password: string,
|
||||
): Promise<T> {
|
||||
const bytes = await decryptSecret(blob, password);
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as T;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Deterministic identity derivation from a BIP39 mnemonic.
|
||||
//
|
||||
// The identity is NOT a loose random keypair: it is derived deterministically
|
||||
// and reproducibly from a 12-word BIP39 mnemonic (128 bits of entropy). The
|
||||
// SAME mnemonic always yields the SAME keypair (same sign_pub), which is what
|
||||
// lets a user recover their account on a new device — or after forgetting the
|
||||
// local password — without admin intervention: the re-derived identity is byte
|
||||
// for byte the one already in the bus allowlist.
|
||||
//
|
||||
// SCHEME (must be identical at create time and at recovery time):
|
||||
//
|
||||
// 1. mnemonic 12 BIP39 words (128-bit entropy + 4-bit checksum)
|
||||
// 2. seed = BIP39_seed(mnemonic)
|
||||
// = PBKDF2(HMAC-SHA512, password = NFKD(mnemonic),
|
||||
// salt = "mnemonic", iterations = 2048, dkLen = 64)
|
||||
// (the standard BIP39 seed; no extra passphrase)
|
||||
// 3. signSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-sign-v1", L = 32)
|
||||
// 4. Ed25519 signing key from signSeed:
|
||||
// sign_pub = Ed25519.publicKey(signSeed) (32 bytes)
|
||||
// sign_priv = signSeed || sign_pub (64 bytes; Go's
|
||||
// ed25519.PrivateKey layout = seed||pub, what the gateway expects)
|
||||
// 5. kexSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-kex-v1", L = 32)
|
||||
// 6. X25519 key-exchange key from kexSeed:
|
||||
// kex_priv = kexSeed (32 bytes; X25519 clamps internally)
|
||||
// kex_pub = X25519.publicKey(kexSeed) (32 bytes)
|
||||
//
|
||||
// The two distinct HKDF `info` labels domain-separate the signing key from the
|
||||
// key-exchange key so they can never collide. All four halves match cs.Identity
|
||||
// on the Go side exactly (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32),
|
||||
// so the gateway can act as the user's peer with the derived keys.
|
||||
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
||||
import { hkdf } from "@noble/hashes/hkdf.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { bytesToHex, concatBytes } from "@noble/hashes/utils.js";
|
||||
import { mnemonicToSeedSync } from "@scure/bip39";
|
||||
|
||||
export const INFO_SIGN = "unibus-sign-v1";
|
||||
export const INFO_KEX = "unibus-kex-v1";
|
||||
|
||||
// WalletIdentity holds the four keypair halves, each lowercase hex. This is the
|
||||
// shape the gateway's POST /api/session consumes (and a subset — the two public
|
||||
// halves — is what POST /api/register sends to the bus).
|
||||
export interface WalletIdentity {
|
||||
signPub: string; // 64 hex (32-byte Ed25519 public key)
|
||||
signPriv: string; // 128 hex (64-byte Ed25519 private key, seed||pub)
|
||||
kexPub: string; // 64 hex (32-byte X25519 public key)
|
||||
kexPriv: string; // 64 hex (32-byte X25519 private key)
|
||||
}
|
||||
|
||||
// deriveIdentity turns a validated BIP39 mnemonic into the deterministic
|
||||
// keypair. Pure: the same mnemonic in always produces the same identity out.
|
||||
export function deriveIdentity(mnemonic: string): WalletIdentity {
|
||||
const seed = mnemonicToSeedSync(mnemonic.normalize("NFKD")); // 64 bytes
|
||||
const info = new TextEncoder();
|
||||
const signSeed = hkdf(sha256, seed, undefined, info.encode(INFO_SIGN), 32);
|
||||
const kexSeed = hkdf(sha256, seed, undefined, info.encode(INFO_KEX), 32);
|
||||
|
||||
const signPub = ed25519.getPublicKey(signSeed);
|
||||
const signPriv = concatBytes(signSeed, signPub); // Go ed25519.PrivateKey = seed||pub
|
||||
const kexPub = x25519.getPublicKey(kexSeed);
|
||||
|
||||
return {
|
||||
signPub: bytesToHex(signPub),
|
||||
signPriv: bytesToHex(signPriv),
|
||||
kexPub: bytesToHex(kexPub),
|
||||
kexPriv: bytesToHex(kexSeed),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// IndexedDB persistence of the device-local wallet. Only the encrypted private
|
||||
// key plus the public halves and the display handle are stored — never the
|
||||
// password, never the BIP39 seed. The private key never leaves the device except
|
||||
// over TLS to the gateway to open a session (see api.session).
|
||||
//
|
||||
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
|
||||
// single device is a documented gap.
|
||||
|
||||
import type { EncryptedBlob } from "./crypto";
|
||||
|
||||
const DB_NAME = "unibus-wallet";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "identity";
|
||||
const ACTIVE_ID = "active";
|
||||
|
||||
// StoredIdentity is one row in IndexedDB. `enc` is the encrypted WalletIdentity
|
||||
// (all four hex halves); signPub/kexPub are kept in the clear for display and so
|
||||
// the UI can show who you are without unlocking.
|
||||
export interface StoredIdentity {
|
||||
id: string; // always ACTIVE_ID for the single-identity MVP
|
||||
handle: string;
|
||||
signPub: string; // 64 hex (public, safe to store in the clear)
|
||||
kexPub: string; // 64 hex (public)
|
||||
enc: EncryptedBlob; // encrypted private identity (the secret material)
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function tx<T>(
|
||||
db: IDBDatabase,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(STORE, mode);
|
||||
const req = fn(t.objectStore(STORE));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// getIdentity returns the device's active identity, or null if this device has
|
||||
// no wallet yet (first visit, or a fresh device awaiting recovery/invite).
|
||||
export async function getIdentity(): Promise<StoredIdentity | null> {
|
||||
const db = await openDB();
|
||||
try {
|
||||
const row = await tx<StoredIdentity | undefined>(db, "readonly", (s) =>
|
||||
s.get(ACTIVE_ID),
|
||||
);
|
||||
return row ?? null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// hasIdentity is a cheap check for the router: does this device hold a wallet?
|
||||
export async function hasIdentity(): Promise<boolean> {
|
||||
return (await getIdentity()) !== null;
|
||||
}
|
||||
|
||||
// putIdentity stores (or replaces) the active identity. Used by both join (new)
|
||||
// and recover (re-derived): both end with an encrypted private key on the device.
|
||||
export async function putIdentity(
|
||||
rec: Omit<StoredIdentity, "id">,
|
||||
): Promise<void> {
|
||||
const db = await openDB();
|
||||
try {
|
||||
await tx(db, "readwrite", (s) => s.put({ id: ACTIVE_ID, ...rec }));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// clearIdentity removes the wallet from this device (e.g. "forget this device").
|
||||
export async function clearIdentity(): Promise<void> {
|
||||
const db = await openDB();
|
||||
try {
|
||||
await tx(db, "readwrite", (s) => s.delete(ACTIVE_ID));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
// través de él. En producción el gateway sirve el dist embebido y no hay proxy.
|
||||
server: {
|
||||
host: true,
|
||||
port: 5181,
|
||||
port: 5183,
|
||||
proxy: { "/api": "http://127.0.0.1:8481" },
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user