feat: initial scaffold of uniweb — unibus web frontend (SPA + gateway)

Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user
BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus
peer for the browser. Consumes unibus as a Go module via replace => ../unibus,
keeping its own replace fn-registry for the cybersecurity primitives.

go build/vet/test and pnpm build green in the new location.
This commit is contained in:
agent
2026-06-13 21:23:10 +02:00
commit e8e37d77fe
42 changed files with 5439 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { Center, Loader } from "@mantine/core";
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";
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 [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState("");
// 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(() => {
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");
}
});
};
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>
);
}
}
+47
View File
@@ -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>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
Box,
Center,
Divider,
Group,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconSend,
IconLock,
IconHash,
IconDotsVertical,
IconPaperclip,
} from "@tabler/icons-react";
import { api, streamRoom } from "./api";
import type { Message, Room } from "./types";
function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
function timeShort(ts: number) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
function MessageRow({ msg }: { msg: Message }) {
return (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
{initials(msg.sender)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
{msg.sender}
</Text>
<Text size="xs" c="dimmed">
{timeShort(msg.ts)}
</Text>
</Group>
<Text size="sm" style={{ wordBreak: "break-word" }}>
{msg.body}
</Text>
</Box>
</Group>
);
}
export function ChatPanel({ room }: { room: Room | undefined }) {
const [draft, setDraft] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [sendError, setSendError] = useState<string | null>(null);
const viewport = useRef<HTMLDivElement>(null);
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
useEffect(() => {
setMessages([]);
setSendError(null);
if (!room) return;
const close = streamRoom(room.id, (m) => {
setMessages((prev) =>
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
);
});
return close;
}, [room?.id]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [room?.id, messages.length]);
if (!room) {
return (
<Center h="100%">
<Text c="dimmed">Selecciona una conversación</Text>
</Center>
);
}
const send = async () => {
const body = draft.trim();
if (!body) return;
setDraft("");
setSendError(null);
try {
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
// evitando duplicados.
await api.send(room.id, body);
} catch (e) {
setDraft(body); // restaura el borrador si el envío falló
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
}
};
return (
<Stack h="100vh" gap={0}>
<Group justify="space-between" px="md" py="xs" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar radius="md" size={38} color="brand">
{initials(room.name)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={650} truncate>
{room.name}
</Text>
{room.encrypted ? (
<Tooltip label="Cifrada de extremo a extremo">
<IconLock size={14} style={{ opacity: 0.6 }} />
</Tooltip>
) : (
<IconHash size={14} style={{ opacity: 0.6 }} />
)}
</Group>
<Text size="xs" c="dimmed">
{room.encrypted ? "cifrada · E2E" : "abierta · cleartext"}
</Text>
</Box>
</Group>
<ActionIcon variant="subtle" color="gray">
<IconDotsVertical size={18} />
</ActionIcon>
</Group>
<Divider color="dark.4" />
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
<Stack gap="lg" p="md">
{messages.map((m) => (
<MessageRow key={m.id} msg={m} />
))}
</Stack>
</ScrollArea>
<Divider color="dark.4" />
{sendError && (
<Text c="red" size="xs" px="sm" pt={4}>
{sendError}
</Text>
)}
<Group p="sm" gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" size="lg">
<IconPaperclip size={18} />
</ActionIcon>
<TextInput
style={{ flex: 1 }}
radius="xl"
placeholder={`Mensaje a ${room.name}`}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void send()}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
color="brand"
onClick={() => void send()}
disabled={!draft.trim()}
>
<IconSend size={18} />
</ActionIcon>
</Group>
</Stack>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from "react";
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
import { Sidebar } from "./Sidebar";
import { ChatPanel } from "./ChatPanel";
import { api } from "./api";
import type { Room, User } from "./types";
export function ChatShell({
user,
onLogout,
}: {
user: User;
onLogout: () => void;
}) {
const [rooms, setRooms] = useState<Room[]>([]);
const [activeId, setActiveId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
api
.listRooms()
.then((rs) => {
setRooms(rs);
setActiveId((cur) => cur || rs[0]?.id || "");
setError(null);
})
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const active = rooms.find((r) => r.id === activeId);
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
let panel = <ChatPanel room={active} />;
if (loading && rooms.length === 0) {
panel = (
<Center h="100%">
<Loader color="brand" />
</Center>
);
} else if (error) {
panel = (
<Center h="100%">
<Stack align="center" gap="sm">
<Text c="red" size="sm">
{error}
</Text>
<Button variant="light" color="brand" onClick={load}>
Reintentar
</Button>
</Stack>
</Center>
);
} else if (rooms.length === 0) {
panel = (
<Center h="100%">
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
</Center>
);
}
return (
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
<Box
w={320}
h="100%"
bg="dark.8"
style={{
borderRight: "1px solid var(--mantine-color-dark-4)",
flexShrink: 0,
}}
>
<Sidebar
user={user}
rooms={rooms}
activeId={activeId}
onSelect={setActiveId}
onLogout={onLogout}
/>
</Box>
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
{panel}
</Box>
</Flex>
);
}
+322
View File
@@ -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>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react";
import {
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconShieldLock, IconKey } from "@tabler/icons-react";
import { api, ApiError } from "./api";
import type { User } from "./types";
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = handle.trim().length > 0 && password.length > 0;
const connect = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
const me = await api.login(password);
const h = handle.trim() || me.endpoint.slice(0, 8);
onLogin({ id: me.endpoint, handle: h });
} catch (e) {
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
setBusy(false);
}
};
return (
<Center h="100vh" bg="dark.9">
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
<IconShieldLock size={32} />
</ThemeIcon>
<Stack gap={2} align="center">
<Title order={2}>unibus</Title>
<Text c="dimmed" size="sm">
Mensajería cifrada de extremo a extremo
</Text>
</Stack>
<TextInput
w="100%"
label="Identidad"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
data-autofocus
/>
<PasswordInput
w="100%"
label="Contraseña"
description="Desbloquea tu identidad cifrada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void connect()}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button
w="100%"
size="md"
onClick={() => void connect()}
disabled={!ready}
loading={busy}
>
Conectar
</Button>
</Stack>
</Card>
</Center>
);
}
+175
View File
@@ -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>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useState } from "react";
import {
Avatar,
Badge,
Box,
Divider,
Group,
Menu,
ScrollArea,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import {
IconSearch,
IconLogout,
IconDots,
IconLock,
IconHash,
} from "@tabler/icons-react";
import type { Room, User } from "./types";
function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
function timeShort(ts: number) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
function RoomItem({
room,
active,
onClick,
}: {
room: Room;
active: boolean;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="xs"
style={{
borderRadius: "var(--mantine-radius-md)",
backgroundColor: active
? "var(--mantine-color-dark-6)"
: "transparent",
}}
>
<Group gap="sm" wrap="nowrap">
<Avatar radius="md" size={42} color={active ? "brand" : "gray"}>
{initials(room.name)}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{room.encrypted ? (
<IconLock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
) : (
<IconHash size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
)}
<Text size="sm" fw={600} truncate>
{room.name}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeShort(room.lastTs)}
</Text>
</Group>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{room.lastMessage}
</Text>
{room.unread > 0 && (
<Badge size="sm" circle variant="filled" color="brand">
{room.unread}
</Badge>
)}
</Group>
</Box>
</Group>
</UnstyledButton>
);
}
export function Sidebar({
user,
rooms,
activeId,
onSelect,
onLogout,
}: {
user: User;
rooms: Room[];
activeId: string;
onSelect: (id: string) => void;
onLogout: () => void;
}) {
const [q, setQ] = useState("");
const query = q.trim().toLowerCase();
const filtered = query
? rooms.filter(
(r) =>
r.name.toLowerCase().includes(query) ||
r.messages.some((m) => m.body.toLowerCase().includes(query)),
)
: rooms;
return (
<Stack h="100%" gap={0}>
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar radius="xl" size={34} color="brand">
{initials(user.handle)}
</Avatar>
<Text fw={600} size="sm" truncate>
{user.handle}
</Text>
</Group>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton c="dimmed">
<IconDots size={18} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLogout size={15} />}
onClick={onLogout}
>
Desconectar
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
<Box px="sm" pb="sm">
<TextInput
value={q}
onChange={(e) => setQ(e.currentTarget.value)}
placeholder="Buscar rooms, usuarios, mensajes…"
leftSection={<IconSearch size={16} />}
radius="md"
size="sm"
/>
</Box>
<Divider color="dark.4" />
<ScrollArea style={{ flex: 1 }} type="scroll">
<Stack gap={2} p={6}>
{filtered.map((room) => (
<RoomItem
key={room.id}
room={room}
active={room.id === activeId}
onClick={() => onSelect(room.id)}
/>
))}
{filtered.length === 0 && (
<Text c="dimmed" size="sm" ta="center" mt="md">
Sin resultados
</Text>
)}
</Stack>
</ScrollArea>
</Stack>
);
}
+77
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+167
View File
@@ -0,0 +1,167 @@
// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
// 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,
RegisterResult,
Room,
RoomWire,
} from "./types";
import type { WalletIdentity } from "./wallet/derive";
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
// same-origin envía la cookie de sesión automáticamente (también detrás del
// proxy de vite en dev).
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
...init,
});
const text = await res.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw new ApiError(msg, res.status);
}
return body as T;
}
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
// alimentará del stream en una iteración futura).
export function roomFromWire(r: RoomWire): Room {
return {
id: r.id,
name: r.name || r.subject,
encrypted: r.encrypt,
lastMessage: "",
lastTs: 0,
unread: 0,
messages: [],
};
}
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
export function messageFromWire(m: MsgWire): Message {
return {
id: m.id,
sender: m.sender,
body: m.body,
ts: m.ts,
mine: m.mine,
};
}
export const api = {
// ---- 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",
body: JSON.stringify({ passphrase }),
}),
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
me: () => req<MeInfo>("/api/me"),
// ---- rooms --------------------------------------------------------------
listRooms: async (): Promise<Room[]> => {
const wire = await req<RoomWire[]>("/api/rooms");
return wire.map(roomFromWire);
},
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
// Matrix-like (cifrada + persistida + firmada) por defecto.
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
const r = await req<RoomWire>("/api/rooms", {
method: "POST",
body: JSON.stringify({ subject, encrypted }),
});
return roomFromWire(r);
},
join: (roomID: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/join`,
{ method: "POST" },
),
send: (roomID: string, body: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/send`,
{ method: "POST", body: JSON.stringify({ body }) },
),
};
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
// reflejar el estado.
export function streamRoom(
roomID: string,
onMessage: (m: Message) => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
);
es.onmessage = (ev) => {
try {
const wire = JSON.parse(ev.data) as MsgWire;
onMessage(messageFromWire(wire));
} catch {
// frame malformado: se ignora, el stream sigue.
}
};
if (onError) es.onerror = onError;
return () => es.close();
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import { theme } from "./theme";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider theme={theme} forceColorScheme="dark">
<App />
</MantineProvider>
</StrictMode>,
);
+59
View File
@@ -0,0 +1,59 @@
import type { Room } from "./types";
// Datos de muestra para iterar el diseño sin el bus conectado.
const now = 1749300000000;
const m = (n: number) => now - n * 60_000;
export const MOCK_ROOMS: Room[] = [
{
id: "general",
name: "general",
encrypted: true,
lastMessage: "¿Lo desplegamos hoy?",
lastTs: m(2),
unread: 3,
messages: [
{ id: "1", sender: "ana", body: "Buenas, ¿cómo va el cluster?", ts: m(40) },
{ id: "2", sender: "lucas", body: "Los 3 nodos en R3, quorum verde", ts: m(38), mine: true },
{ id: "3", sender: "ana", body: "Brutal. ¿Y el frontend?", ts: m(30) },
{ id: "4", sender: "leo", body: "Primera iteración lista, estilo Element", ts: m(6) },
{ id: "5", sender: "ana", body: "¿Lo desplegamos hoy?", ts: m(2) },
],
},
{
id: "board",
name: "board · privado",
encrypted: true,
lastMessage: "Os paso el acta cifrada",
lastTs: m(95),
unread: 0,
messages: [
{ id: "1", sender: "ceo", body: "Reunión a las 18:00", ts: m(120) },
{ id: "2", sender: "lucas", body: "Anotado", ts: m(96), mine: true },
{ id: "3", sender: "ceo", body: "Os paso el acta cifrada", ts: m(95) },
],
},
{
id: "bots",
name: "bots",
encrypted: false,
lastMessage: "echo: ping",
lastTs: m(210),
unread: 0,
messages: [
{ id: "1", sender: "lucas", body: "!ping", ts: m(212), mine: true },
{ id: "2", sender: "echobot", body: "echo: ping", ts: m(210) },
],
},
{
id: "infra",
name: "infra",
encrypted: true,
lastMessage: "magnus + homer + datardos OK",
lastTs: m(330),
unread: 1,
messages: [
{ id: "1", sender: "leo", body: "magnus + homer + datardos OK", ts: m(330) },
],
},
];
+24
View File
@@ -0,0 +1,24 @@
import { createTheme, type MantineColorsTuple } from "@mantine/core";
// Acento de marca de unibus — un violeta-índigo moderno.
const brand: MantineColorsTuple = [
"#f1edff",
"#dcd3ff",
"#b5a3f5",
"#8d70ed",
"#6c47e6",
"#5a2fe2",
"#5023e0",
"#4119c7",
"#3915b3",
"#2f0f9e",
];
export const theme = createTheme({
primaryColor: "brand",
colors: { brand },
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
defaultRadius: "md",
headings: { fontWeight: "650" },
});
+65
View File
@@ -0,0 +1,65 @@
// Tipos de dominio de la UI. Los datos vienen del gateway Go (REST/SSE), que es
// un peer autenticado del bus. El navegador nunca firma ni habla NATS.
export interface User {
id: string;
handle: string;
}
export interface Message {
id: string;
sender: string; // endpoint id del remitente (handle legible es fase 2)
body: string;
ts: number; // epoch ms
mine?: boolean;
}
export interface Room {
id: string;
name: string;
encrypted: boolean;
lastMessage: string;
lastTs: number;
unread: number;
messages: Message[];
}
// ---- formas de la API del gateway (wire) ---------------------------------
// 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
// mensajes: estos llegan por SSE (GET /api/rooms/{id}/stream).
export interface RoomWire {
id: string;
subject: string;
name: string;
epoch: number;
encrypt: boolean;
persist: boolean;
sign_msgs: boolean;
role: string;
}
// MsgWire es un mensaje ya descifrado que el gateway empuja por SSE.
export interface MsgWire {
id: string;
sender: string;
body: string;
ts: number;
mine: boolean;
}
+60
View File
@@ -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";
}
}
+55
View File
@@ -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("");
}
+124
View File
@@ -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;
}
+69
View File
@@ -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),
};
}
+95
View File
@@ -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();
}
}