Kez/kez-chat/web/src/routes/Restore.svelte
Jason Tudisco 3fdbdc9fcf feat(kez-chat/web): 12-word recovery phrase replaces hex seed in account flow
Brings the BIP-39 mnemonic surface (CLI + libs landed in 0058d9b /
b0cc1a7) into the chat app's user-facing account flow. Match the same
SHA-256 domain-tag derivation as Rust / Node / Python — a phrase
generated in the browser verifies against the spec vectors in
python/MNEMONIC-TEST-VECTORS.md byte-for-byte.

  • New lib/mnemonic.ts: browser-native helpers (generateMnemonic12,
    seedFromMnemonic, mnemonicFromSeed24, ed25519FromMnemonic,
    generateIdentityWithMnemonic, isValidMnemonic). Uses @scure/bip39
    (same lib as Node impl) + the same domain tag "kez-bip39-12-v1".
    12-word phrases by default; restore accepts 24-word too for parity
    with the CLI.

  • lib/identity-store.ts: StoredIdentity gains optional
    phrase_nonce + phrase_ciphertext, encrypted under the SAME
    PBKDF2-derived key as the seed (fresh nonce — AES-GCM reuse is
    fatal). unlockIdentity returns the phrase when present. New
    hasStoredPhrase() helper distinguishes "phrase exists but not
    accessible in this session" (biometric unlock) from "truly legacy
    hex-only account".

  • CreateAccount: generates via generateIdentityWithMnemonic. Step 2
    now shows the 12 words in a numbered grid with a "copy all" button
    and a real ack checkbox before continuing. Step indicator updated
    to "2. Back up phrase".

  • Restore: was previously a stub that always threw "v0.1 limitation".
    Now actually works — accepts either a 12/24-word phrase OR a
    legacy 64-char hex seed (auto-detected), looks up the handle via
    /v1/by-primary, derives the seed, saves identity, unlocks, routes
    to /welcome.

  • Settings: "Reveal seed" → "Reveal phrase". Three-state output:
        - phrase in session → show 12 words
        - phrase stored but biometric session → tell user to passphrase-
          unlock to reveal
        - truly legacy → show hex seed with explanation

  • Welcome (onboarding): "Back up your recovery seed" step renders the
    phrase as a numbered grid when available, falls back to the hex
    block with a "Legacy 64-char hex" caption for pre-mnemonic accounts.

Biometric unlock continues to surface only the seed (the phrase blob is
encrypted under the passphrase-derived key, not the PRF-derived key) —
documented in the Settings UX. Encrypting under PRF too is a v0.3
follow-up.

Backwards compatible: existing accounts (which have only the
seed-ciphertext) unlock fine; their phrase fields stay undefined; the
UI falls back to the hex flow throughout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:14:52 -06:00

180 lines
5.3 KiB
Svelte

<script lang="ts">
import { push } from "svelte-spa-router";
import { hexToBytes } from "@noble/hashes/utils";
import { identityFromSeed, signRegistration } from "../lib/kez.js";
import {
seedFromMnemonic,
isValidMnemonic,
} from "../lib/mnemonic.js";
import { healthz, lookupByPrimary, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
import { onMount } from "svelte";
/** User pastes either a 12-word phrase OR a 64-char hex seed. */
let secretInput = $state("");
let passphrase = $state("");
let passphrase2 = $state("");
let serverDomain = $state<string | null>(null);
let error = $state<string | null>(null);
let working = $state(false);
onMount(async () => {
try {
const h = await healthz();
serverDomain = h.server;
} catch {
/* will surface below */
}
});
/** Auto-detect: hex-seed if 64 hex chars, otherwise treat as mnemonic. */
function parseSecret(raw: string): { seed: Uint8Array; phrase?: string } {
const trimmed = raw.trim();
const hexShape = trimmed.replace(/\s+/g, "").toLowerCase();
if (/^[0-9a-f]{64}$/.test(hexShape)) {
return { seed: hexToBytes(hexShape) };
}
if (isValidMnemonic(trimmed)) {
return { seed: seedFromMnemonic(trimmed), phrase: trimmed.replace(/\s+/g, " ") };
}
throw new Error(
"Couldn't read that as either a 12/24-word recovery phrase or a 64-character hex seed.",
);
}
async function submit() {
error = null;
working = true;
try {
if (passphrase.length < 8) {
throw new Error("Passphrase must be at least 8 characters.");
}
if (passphrase !== passphrase2) {
throw new Error("Passphrases don't match.");
}
if (!serverDomain) {
throw new Error("Server unreachable; refresh and try again.");
}
const { seed, phrase } = parseSecret(secretInput);
const id = identityFromSeed(seed);
// Look up the handle this primary is registered to.
let record;
try {
record = await lookupByPrimary(id.identity);
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
throw new Error(
"No account registered with this recovery phrase on " +
`${serverDomain}. Did you mean to create a new one?`,
);
}
throw e;
}
// Re-sign a registration so it's on file fresh (idempotent on the
// server — same primary always matches the same handle).
// (We don't strictly need to re-register; storing locally is enough.)
void signRegistration;
await saveIdentity({
handle: record.handle,
server: serverDomain,
primary: id.identity,
seed: id.seed,
passphrase,
phrase,
});
session.setUnlocked({
handle: record.handle,
server: serverDomain,
primary: id.identity,
seed: id.seed,
phrase,
});
push("/welcome");
} catch (e) {
error =
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
} finally {
working = false;
}
}
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-text">Restore account</h1>
<p class="text-sm text-text-secondary">
Paste your 12-word recovery phrase. (If you wrote down a 64-character
hex seed from an older version of kez-chat, that works too.) We'll
look up your handle on <span class="font-mono">{serverDomain ?? "the server"}</span>
and unlock the account on this device.
</p>
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<div>
<label class="block text-sm font-medium text-text-secondary" for="secret">
Recovery phrase or hex seed
</label>
<textarea
id="secret"
bind:value={secretInput}
rows="3"
placeholder="abandon ability able about above absent academy accident account accuse achieve acid"
class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
autocomplete="off"
spellcheck="false"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-text-secondary" for="pw">
New passphrase for this device
</label>
<input
id="pw"
type="password"
bind:value={passphrase}
class="mt-1 w-full px-3 py-2 border border-border rounded-md"
/>
<input
type="password"
bind:value={passphrase2}
placeholder="confirm"
class="mt-2 w-full px-3 py-2 border border-border rounded-md"
/>
</div>
{#if error}
<p class="text-sm text-danger bg-danger/10 border border-danger/40 rounded p-3">
{error}
</p>
{/if}
<div class="flex gap-2">
<button
type="button"
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => push("/")}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={working}
>
{working ? "Restoring…" : "Restore"}
</button>
</div>
</form>
</div>