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>
180 lines
5.3 KiB
Svelte
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>
|