Files
unibus/web/src/Welcome.tsx
T
egutierrez 4994ea1483 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>
2026-06-08 21:21:50 +02:00

71 lines
2.2 KiB
TypeScript

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>
);
}