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>
This commit is contained in:
Jason Tudisco 2026-06-05 18:14:52 -06:00
parent b0cc1a74a0
commit 3fdbdc9fcf
8 changed files with 383 additions and 91 deletions

View File

@ -12,6 +12,7 @@
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
@ -3164,22 +3165,22 @@
} }
}, },
"node_modules/@scure/bip39": { "node_modules/@scure/bip39": {
"version": "2.0.1", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/hashes": "2.0.1", "@noble/hashes": "2.2.0",
"@scure/base": "2.0.0" "@scure/base": "2.2.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39/node_modules/@noble/hashes": { "node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.0.1", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
@ -3189,9 +3190,9 @@
} }
}, },
"node_modules/@scure/bip39/node_modules/@scure/base": { "node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "2.0.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
@ -6051,6 +6052,19 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": { "node_modules/nostr-wasm": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",

View File

@ -14,6 +14,7 @@
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",
"emoji-picker-element": "^1.29.1", "emoji-picker-element": "^1.29.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",

View File

@ -33,6 +33,12 @@ interface StoredIdentity {
salt: string; // hex, 16 bytes salt: string; // hex, 16 bytes
nonce: string; // hex, 12 bytes nonce: string; // hex, 12 bytes
ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase) ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase)
// Optional: encrypted 12-word recovery phrase (added in the mnemonic
// rollout). New accounts have both; pre-mnemonic accounts have only
// the seed. Encrypted under the SAME PBKDF2 key as the seed; uses its
// own nonce because AES-GCM reuse is unsafe.
phrase_nonce?: string; // hex, 12 bytes
phrase_ciphertext?: string; // hex; AES-GCM(utf8(phrase))
// Metadata: // Metadata:
created_at: string; // RFC3339 created_at: string; // RFC3339
} }
@ -42,6 +48,8 @@ export interface UnlockedIdentity {
server: string; server: string;
primary: Identity; primary: Identity;
seed: Uint8Array; seed: Uint8Array;
/** 12-word recovery phrase. Absent for pre-mnemonic accounts. */
phrase?: string;
} }
const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance
@ -76,6 +84,15 @@ export async function hasStoredIdentity(): Promise<boolean> {
return !!stored; return !!stored;
} }
/** True iff the stored identity carries an encrypted recovery phrase
* (created via the 12-word-mnemonic flow). Used to distinguish a
* truly-legacy hex-only account from a phrase account that just isn't
* available in the current session (e.g. biometric unlock didn't decrypt it). */
export async function hasStoredPhrase(): Promise<boolean> {
const stored = await get<StoredIdentity>(IDB_KEY);
return !!(stored?.phrase_ciphertext && stored?.phrase_nonce);
}
export async function loadStoredIdentityMeta(): Promise< export async function loadStoredIdentityMeta(): Promise<
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | null
> { > {
@ -91,6 +108,11 @@ export async function saveIdentity(opts: {
primary: Identity; primary: Identity;
seed: Uint8Array; seed: Uint8Array;
passphrase: string; passphrase: string;
/** Optional 12-word phrase stored encrypted so the user can re-display
* it later. The seed is derived from the phrase one-way (SHA-256 with
* domain tag); we can't recover the phrase from the seed, hence
* storing it explicitly. */
phrase?: string;
}): Promise<void> { }): Promise<void> {
const salt = crypto.getRandomValues(new Uint8Array(16)); const salt = crypto.getRandomValues(new Uint8Array(16));
const nonce = crypto.getRandomValues(new Uint8Array(12)); const nonce = crypto.getRandomValues(new Uint8Array(12));
@ -113,6 +135,21 @@ export async function saveIdentity(opts: {
ciphertext: bytesToHex(ciphertext), ciphertext: bytesToHex(ciphertext),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
if (opts.phrase) {
// Fresh nonce — AES-GCM nonce reuse under the same key is fatal.
const phraseNonce = crypto.getRandomValues(new Uint8Array(12));
const phraseCt = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(phraseNonce) },
key,
asBuffer(new TextEncoder().encode(opts.phrase)),
),
);
record.phrase_nonce = bytesToHex(phraseNonce);
record.phrase_ciphertext = bytesToHex(phraseCt);
}
await set(IDB_KEY, record); await set(IDB_KEY, record);
} }
@ -144,11 +181,34 @@ export async function unlockIdentity(
`unlocked seed is ${plaintext.length} bytes, expected 32`, `unlocked seed is ${plaintext.length} bytes, expected 32`,
); );
} }
let phrase: string | undefined;
if (stored.phrase_nonce && stored.phrase_ciphertext) {
try {
const pn = hexToBytes(stored.phrase_nonce);
const pc = hexToBytes(stored.phrase_ciphertext);
const pBytes = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(pn) },
key,
asBuffer(pc),
),
);
phrase = new TextDecoder().decode(pBytes);
} catch {
// The seed unlocked fine, so the passphrase is right; a phrase
// decrypt failure here would point at IDB corruption. Surface it
// by logging, but don't block unlock — the user can still chat.
console.error("identity-store: phrase decrypt failed (seed OK)");
}
}
return { return {
handle: stored.handle, handle: stored.handle,
server: stored.server, server: stored.server,
primary: stored.primary, primary: stored.primary,
seed: plaintext, seed: plaintext,
phrase,
}; };
} }

View File

@ -0,0 +1,111 @@
// Browser-native KEZ mnemonic helpers — mirrors:
// • rust/crates/kez-core/src/mnemonic.rs
// • nodejs/packages/kez-core/src/mnemonic.ts
// • python/kez/mnemonic.py
//
// We use 12-word phrases as the user-facing backup form in the chat app
// (shorter to write down than the 64-char hex seed). The seed itself is
// derived deterministically from the phrase, so the phrase IS the
// canonical backup.
//
// Semantics (must match the other implementations byte-for-byte):
// • 12 words → 16 bytes of BIP-39 entropy → seed = SHA-256(DOMAIN_TAG ||
// entropy) where DOMAIN_TAG = "kez-bip39-12-v1" (15 ASCII bytes).
// The derivation is one-way: you can't recover the phrase from the
// seed. That's why we ALSO store the phrase (encrypted at rest) so
// the user can re-display it later via Settings → Reveal phrase.
// • 24 words → entropy IS the 32-byte seed (bijection). Accepted on
// restore for parity with the CLI, but the chat app generates 12-word
// phrases by default.
//
// NB: We deliberately do NOT use BIP-39's PBKDF2 to_seed function. That
// produces a 64-byte BIP-32 wallet seed, which is the wrong primitive
// for KEZ's single-identity-per-phrase model.
import {
entropyToMnemonic,
generateMnemonic as bip39Generate,
mnemonicToEntropy,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { sha256 } from "@noble/hashes/sha2";
import { identityFromSeed, type Ed25519Identity } from "./kez.js";
const DOMAIN_TAG_12 = new TextEncoder().encode("kez-bip39-12-v1");
/** Generate a fresh 12-word phrase. */
export function generateMnemonic12(): string {
// BIP-39 strength: 128 bits → 12 words.
return bip39Generate(wordlist, 128);
}
/**
* Decode a 12- or 24-word phrase into a 32-byte Ed25519 seed. Auto-detects
* length. Whitespace-tolerant (trims, collapses runs of spaces).
*/
export function seedFromMnemonic(phrase: string): Uint8Array {
const trimmed = phrase.trim().replace(/\s+/g, " ");
let entropy: Uint8Array;
try {
entropy = mnemonicToEntropy(trimmed, wordlist);
} catch (e) {
throw new Error(`invalid recovery phrase: ${(e as Error).message}`);
}
if (entropy.length === 32) {
// 24-word: entropy is the seed.
return new Uint8Array(entropy);
}
if (entropy.length === 16) {
// 12-word: SHA-256 of (DOMAIN_TAG || entropy).
const buf = new Uint8Array(DOMAIN_TAG_12.length + entropy.length);
buf.set(DOMAIN_TAG_12, 0);
buf.set(entropy, DOMAIN_TAG_12.length);
return sha256(buf);
}
throw new Error(
`mnemonic must decode to 16 or 32 bytes of entropy, got ${entropy.length}`,
);
}
/** Inverse for the 24-word case ONLY. Throws on any other length. */
export function mnemonicFromSeed24(seed: Uint8Array): string {
if (seed.length !== 32) {
throw new Error(
`mnemonicFromSeed24: seed must be 32 bytes, got ${seed.length}`,
);
}
return entropyToMnemonic(seed, wordlist);
}
/** Construct a KEZ Ed25519 identity from a phrase. */
export function ed25519FromMnemonic(phrase: string): Ed25519Identity {
return identityFromSeed(seedFromMnemonic(phrase));
}
/**
* Generate a fresh identity AND the phrase that derives it. The phrase
* is the canonical user-facing backup; the identity carries the seed
* for crypto ops.
*/
export function generateIdentityWithMnemonic(): {
identity: Ed25519Identity;
phrase: string;
} {
const phrase = generateMnemonic12();
const identity = ed25519FromMnemonic(phrase);
return { identity, phrase };
}
/**
* Cheap input check (does the typed text look like a valid phrase?) so
* the restore form can give live feedback. Returns true only if the
* phrase parses + checksum-validates.
*/
export function isValidMnemonic(phrase: string): boolean {
try {
seedFromMnemonic(phrase);
return true;
} catch {
return false;
}
}

View File

@ -1,17 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { import {
generateIdentity,
signRegistration, signRegistration,
type Ed25519Identity, type Ed25519Identity,
} from "../lib/kez.js"; } from "../lib/kez.js";
import { generateIdentityWithMnemonic } from "../lib/mnemonic.js";
import { register, healthz, ApiError } from "../lib/api.js"; import { register, healthz, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js"; import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle"); let step = $state<"handle" | "phrase" | "confirm" | "submitting" | "done">("handle");
let handle = $state(""); let handle = $state("");
let passphrase = $state(""); let passphrase = $state("");
@ -19,8 +18,8 @@
let serverInfo = $state<{ server: string; version: string } | null>(null); let serverInfo = $state<{ server: string; version: string } | null>(null);
let id = $state<Ed25519Identity | null>(null); let id = $state<Ed25519Identity | null>(null);
let seedHex = $state(""); let phrase = $state("");
let seedAck = $state(false); let phraseAck = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let working = $state(false); let working = $state(false);
@ -48,9 +47,10 @@
if (v) { error = v; return; } if (v) { error = v; return; }
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; } if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; } if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
id = generateIdentity(); const gen = generateIdentityWithMnemonic();
seedHex = bytesToHex(id.seed); id = gen.identity;
step = "seed"; phrase = gen.phrase;
step = "phrase";
} }
async function submitRegistration() { async function submitRegistration() {
@ -67,6 +67,7 @@
primary: id.identity, primary: id.identity,
seed: id.seed, seed: id.seed,
passphrase, passphrase,
phrase,
}); });
session.setUnlocked({ session.setUnlocked({
handle: resp.handle, handle: resp.handle,
@ -105,7 +106,7 @@
<ol class="flex gap-2 text-xs text-text-muted"> <ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li> <li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
<li></li> <li></li>
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li> <li class={step === "phrase" ? "font-semibold text-text" : ""}>2. Back up phrase</li>
<li></li> <li></li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li> <li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
<li></li> <li></li>
@ -181,32 +182,38 @@
</form> </form>
{/if} {/if}
{#if step === "seed" && id} {#if step === "phrase" && id}
<div class="space-y-4"> <div class="space-y-4">
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3"> <div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
<p class="font-semibold text-warning">⚠️ Back up your seed now</p> <p class="font-semibold text-warning">⚠️ Back up your recovery phrase</p>
<p class="text-sm text-warning"> <p class="text-sm text-warning">
This is the only way to recover your account on another device This 12-word phrase is the only way to recover your account on
(or after clearing this browser). The server doesn't have it. another device (or after clearing this browser). The server
Write it down or paste into a password manager. doesn't have it. Write it down on paper — don't just rely on
this browser.
</p> </p>
<div class="mt-3 p-3 bg-surface border border-warning/40 rounded font-mono text-sm break-all select-all"> <ol class="mt-3 p-3 bg-surface border border-warning/40 rounded grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 font-mono text-sm">
{seedHex} {#each phrase.split(" ") as word, i}
</div> <li class="flex items-baseline gap-2">
<div class="flex gap-2"> <span class="text-text-muted text-xs w-5 text-right">{i + 1}.</span>
<span class="text-text select-all">{word}</span>
</li>
{/each}
</ol>
<div class="flex gap-2 flex-wrap">
<button <button
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20" class="text-xs px-3 py-1 border border-warning/40 text-warning rounded hover:bg-warning/20"
onclick={() => copyToClipboard(seedHex)} onclick={() => copyToClipboard(phrase)}
> >
Copy seed Copy all 12 words
</button> </button>
</div> </div>
</div> </div>
<label class="flex items-start gap-2 text-sm text-text-secondary"> <label class="flex items-start gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={seedAck} class="mt-1" /> <input type="checkbox" bind:checked={phraseAck} class="mt-1" />
I've saved this seed somewhere safe. I understand losing it means I've written down these 12 words in order. I understand losing them
losing my account permanently. means losing my account permanently.
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
@ -218,7 +225,7 @@
</button> </button>
<button <button
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50" class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!seedAck} disabled={!phraseAck}
onclick={() => { step = "confirm"; }} onclick={() => { step = "confirm"; }}
> >
I've saved it — continue I've saved it — continue
@ -238,7 +245,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated" class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "seed"; }} onclick={() => { step = "phrase"; }}
> >
Back Back
</button> </button>

View File

@ -1,13 +1,18 @@
<script lang="ts"> <script lang="ts">
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import { identityFromSeed } from "../lib/kez.js"; import { identityFromSeed, signRegistration } from "../lib/kez.js";
import { lookup, healthz, ApiError } from "../lib/api.js"; import {
seedFromMnemonic,
isValidMnemonic,
} from "../lib/mnemonic.js";
import { healthz, lookupByPrimary, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js"; import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { onMount } from "svelte"; import { onMount } from "svelte";
let seedHex = $state(""); /** User pastes either a 12-word phrase OR a 64-char hex seed. */
let secretInput = $state("");
let passphrase = $state(""); let passphrase = $state("");
let passphrase2 = $state(""); let passphrase2 = $state("");
let serverDomain = $state<string | null>(null); let serverDomain = $state<string | null>(null);
@ -23,38 +28,78 @@
} }
}); });
/** 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() { async function submit() {
error = null; error = null;
working = true; working = true;
try { try {
const cleaned = seedHex.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(cleaned)) {
throw new Error("Seed must be 64 lowercase hex characters (32 bytes).");
}
if (passphrase.length < 8) { if (passphrase.length < 8) {
throw new Error("Passphrase must be at least 8 characters."); throw new Error("Passphrase must be at least 8 characters.");
} }
if (passphrase !== passphrase2) { if (passphrase !== passphrase2) {
throw new Error("Passphrases don't match."); throw new Error("Passphrases don't match.");
} }
const seed = hexToBytes(cleaned);
const id = identityFromSeed(seed);
if (!serverDomain) { if (!serverDomain) {
throw new Error("Server unreachable; refresh and try again."); throw new Error("Server unreachable; refresh and try again.");
} }
// Look up the primary on the server to find the associated handle.
// We try a couple of common handles? No — the registry is keyed by const { seed, phrase } = parseSecret(secretInput);
// handle, not primary. So we ask the user to type their handle. const id = identityFromSeed(seed);
throw new Error(
"Sorry — to restore, please use a device that has your handle saved. " + // Look up the handle this primary is registered to.
"(v0.2 will let you look up your handle by primary key.)", let record;
); try {
// TODO when chat-server has GET /v1/by-primary/<id>: implement this. record = await lookupByPrimary(id.identity);
// For now restoring a seed-only is incomplete because we don't know } catch (e) {
// the handle. Workaround: regenerate identity via /create with same if (e instanceof ApiError && e.status === 404) {
// handle (server will reject as taken; not useful) OR ask the user. 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) { } catch (e) {
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message; error =
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
} finally { } finally {
working = false; working = false;
} }
@ -62,13 +107,13 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-text">Restore from seed</h1> <h1 class="text-2xl font-bold text-text">Restore account</h1>
<p class="text-sm text-text-secondary bg-warning/10 border border-warning/40 rounded p-3"> <p class="text-sm text-text-secondary">
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which Paste your 12-word recovery phrase. (If you wrote down a 64-character
handle to restore. For now this flow doesn't work end-to-end — we'll hex seed from an older version of kez-chat, that works too.) We'll
add <code>GET /v1/by-primary/&lt;id&gt;</code> on the server in v0.2 look up your handle on <span class="font-mono">{serverDomain ?? "the server"}</span>
so the SPA can look up the handle from the public key. and unlock the account on this device.
</p> </p>
<form <form
@ -76,14 +121,17 @@
onsubmit={(e) => { e.preventDefault(); submit(); }} onsubmit={(e) => { e.preventDefault(); submit(); }}
> >
<div> <div>
<label class="block text-sm font-medium text-text-secondary" for="seed"> <label class="block text-sm font-medium text-text-secondary" for="secret">
Seed (64 hex characters) Recovery phrase or hex seed
</label> </label>
<textarea <textarea
id="seed" id="secret"
bind:value={seedHex} bind:value={secretInput}
rows="3" 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" class="mt-1 w-full px-3 py-2 border border-border rounded-md font-mono text-sm"
autocomplete="off"
spellcheck="false"
></textarea> ></textarea>
</div> </div>

View File

@ -3,6 +3,7 @@
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { hasStoredPhrase } from "../lib/identity-store.js";
import { import {
hasStoredBiometric, hasStoredBiometric,
getStoredBiometricMeta, getStoredBiometricMeta,
@ -92,10 +93,36 @@
setTimeout(() => (testNotifResult = null), 5_000); setTimeout(() => (testNotifResult = null), 5_000);
} }
function showSeed() { async function showSeed() {
if (!session.unlocked) return; if (!session.unlocked) return;
const phrase = session.unlocked.phrase;
if (phrase) {
alert(
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
`Write these 12 words down in order — they're the ONLY way to ` +
`recover this account on another device.`,
);
return;
}
// Phrase not in this session — distinguish two cases:
// 1. Account HAS a stored phrase but this session unlocked via
// biometric (PRF key doesn't decrypt the passphrase-keyed blob).
// 2. Genuinely pre-mnemonic legacy account — show hex.
if (await hasStoredPhrase()) {
alert(
`Your recovery phrase isn't available in this session.\n\n` +
`Biometric unlock doesn't decrypt the phrase. Lock and unlock ` +
`again with your passphrase to reveal it.`,
);
return;
}
const hex = bytesToHex(session.unlocked.seed); const hex = bytesToHex(session.unlocked.seed);
alert(`Your recovery seed (KEEP SECRET):\n\n${hex}\n\nWrite this down somewhere safe. It's the ONLY way to recover this account.`); alert(
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
`This account was created before 12-word phrases were supported. ` +
`The 64-character hex above is still your full recovery — write ` +
`it down somewhere safe.`,
);
} }
function lock() { function lock() {
@ -166,12 +193,13 @@
<!-- Recovery phrase --> <!-- Recovery phrase -->
<div> <div>
<p class="text-sm font-medium text-text">Recovery seed</p> <p class="text-sm font-medium text-text">Recovery phrase</p>
<p class="text-sm text-text-secondary mt-0.5"> <p class="text-sm text-text-secondary mt-0.5">
The only thing that can recover this account. Write it down offline. 12 words that recover this account anywhere. Write them down on
paper — losing them means losing the account.
</p> </p>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}> <button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
Reveal seed Reveal phrase
</button> </button>
</div> </div>
</section> </section>

View File

@ -23,9 +23,10 @@
let biometricAvailable = $state(false); let biometricAvailable = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default"); let notifPerm = $state<NotificationPermission | "unsupported">("default");
let seedRevealed = $state(false); let backupRevealed = $state(false);
let seedHex = $state(""); let backupText = $state(""); // 12-word phrase if available, else hex seed
let seedCopied = $state(false); let backupKind = $state<"phrase" | "seed">("phrase");
let backupCopied = $state(false);
let busy = $state(false); let busy = $state(false);
onMount(async () => { onMount(async () => {
@ -42,13 +43,20 @@
function revealSeed() { function revealSeed() {
if (!session.unlocked) return; if (!session.unlocked) return;
seedHex = bytesToHex(session.unlocked.seed); if (session.unlocked.phrase) {
seedRevealed = true; backupText = session.unlocked.phrase;
backupKind = "phrase";
} else {
// Legacy account (pre-mnemonic) — fall back to the hex seed.
backupText = bytesToHex(session.unlocked.seed);
backupKind = "seed";
}
backupRevealed = true;
} }
async function copySeed() { async function copySeed() {
await navigator.clipboard.writeText(seedHex); await navigator.clipboard.writeText(backupText);
seedCopied = true; backupCopied = true;
setTimeout(() => (seedCopied = false), 1500); setTimeout(() => (backupCopied = false), 1500);
} }
async function enableBiometric() { async function enableBiometric() {
@ -109,25 +117,40 @@
</div> </div>
</li> </li>
<!-- 2. Back up seed (critical, skippable) --> <!-- 2. Back up phrase (critical, skippable) -->
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}> <li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span> <span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Back up your recovery seed</p> <p class="text-sm font-semibold text-text">Back up your recovery phrase</p>
<p class="text-xs text-text-secondary"> <p class="text-xs text-text-secondary">
This 32-byte seed is the <strong>only</strong> way to recover your These 12 words are the <strong>only</strong> way to recover
account. Lose it and it's gone forever — there's no reset. Write it your account. Lose them and it's gone forever — there's no
down offline. reset. Write them on paper.
</p> </p>
{#if !seedRevealed && !onboarding.seedAcked} {#if !backupRevealed && !onboarding.seedAcked}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal seed</button> <button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal phrase</button>
{/if} {/if}
{#if seedRevealed} {#if backupRevealed}
<div class="mt-2 p-3 bg-elevated border border-border rounded-md"> <div class="mt-2 p-3 bg-elevated border border-border rounded-md">
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p> {#if backupKind === "phrase"}
<ol class="grid grid-cols-2 sm:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-xs">
{#each backupText.split(" ") as word, i}
<li class="flex items-baseline gap-1.5">
<span class="text-text-muted w-4 text-right">{i + 1}.</span>
<span class="text-text select-all">{word}</span>
</li>
{/each}
</ol>
{:else}
<p class="font-mono text-xs text-text break-all select-all">{backupText}</p>
<p class="mt-1 text-[10px] text-text-muted">
Legacy 64-char hex — accounts created from now on get a
12-word phrase instead.
</p>
{/if}
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{seedCopied ? "✓ copied" : "Copy"}</button> <button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{backupCopied ? "✓ copied" : "Copy"}</button>
{#if !onboarding.seedAcked} {#if !onboarding.seedAcked}
<button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button> <button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button>
{/if} {/if}