4994ea1483
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>
56 lines
2.1 KiB
TypeScript
56 lines
2.1 KiB
TypeScript
// 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("");
|
|
}
|