From 3fdbdc9fcfdeff5ee4d85757507731b8dcf68d47 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Fri, 5 Jun 2026 18:14:52 -0600 Subject: [PATCH] feat(kez-chat/web): 12-word recovery phrase replaces hex seed in account flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kez-chat/web/package-lock.json | 36 ++++-- kez-chat/web/package.json | 1 + kez-chat/web/src/lib/identity-store.ts | 60 ++++++++++ kez-chat/web/src/lib/mnemonic.ts | 111 +++++++++++++++++++ kez-chat/web/src/routes/CreateAccount.svelte | 59 +++++----- kez-chat/web/src/routes/Restore.svelte | 110 ++++++++++++------ kez-chat/web/src/routes/Settings.svelte | 38 ++++++- kez-chat/web/src/routes/Welcome.svelte | 59 +++++++--- 8 files changed, 383 insertions(+), 91 deletions(-) create mode 100644 kez-chat/web/src/lib/mnemonic.ts diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json index 3293800..55812e0 100644 --- a/kez-chat/web/package-lock.json +++ b/kez-chat/web/package-lock.json @@ -12,6 +12,7 @@ "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/base": "^1.1.9", + "@scure/bip39": "^2.2.0", "canonicalize": "^2.0.0", "emoji-picker-element": "^1.29.1", "idb-keyval": "^6.2.1", @@ -3164,22 +3165,22 @@ } }, "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==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -3189,9 +3190,9 @@ } }, "node_modules/@scure/bip39/node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -6051,6 +6052,19 @@ "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json index 0196881..913d2ca 100644 --- a/kez-chat/web/package.json +++ b/kez-chat/web/package.json @@ -14,6 +14,7 @@ "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/base": "^1.1.9", + "@scure/bip39": "^2.2.0", "canonicalize": "^2.0.0", "emoji-picker-element": "^1.29.1", "idb-keyval": "^6.2.1", diff --git a/kez-chat/web/src/lib/identity-store.ts b/kez-chat/web/src/lib/identity-store.ts index c12487a..0a29c93 100644 --- a/kez-chat/web/src/lib/identity-store.ts +++ b/kez-chat/web/src/lib/identity-store.ts @@ -33,6 +33,12 @@ interface StoredIdentity { salt: string; // hex, 16 bytes nonce: string; // hex, 12 bytes 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: created_at: string; // RFC3339 } @@ -42,6 +48,8 @@ export interface UnlockedIdentity { server: string; primary: Identity; seed: Uint8Array; + /** 12-word recovery phrase. Absent for pre-mnemonic accounts. */ + phrase?: string; } const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance @@ -76,6 +84,15 @@ export async function hasStoredIdentity(): Promise { 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 { + const stored = await get(IDB_KEY); + return !!(stored?.phrase_ciphertext && stored?.phrase_nonce); +} + export async function loadStoredIdentityMeta(): Promise< Pick | null > { @@ -91,6 +108,11 @@ export async function saveIdentity(opts: { primary: Identity; seed: Uint8Array; 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 { const salt = crypto.getRandomValues(new Uint8Array(16)); const nonce = crypto.getRandomValues(new Uint8Array(12)); @@ -113,6 +135,21 @@ export async function saveIdentity(opts: { ciphertext: bytesToHex(ciphertext), 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); } @@ -144,11 +181,34 @@ export async function unlockIdentity( `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 { handle: stored.handle, server: stored.server, primary: stored.primary, seed: plaintext, + phrase, }; } diff --git a/kez-chat/web/src/lib/mnemonic.ts b/kez-chat/web/src/lib/mnemonic.ts new file mode 100644 index 0000000..8349b25 --- /dev/null +++ b/kez-chat/web/src/lib/mnemonic.ts @@ -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; + } +} diff --git a/kez-chat/web/src/routes/CreateAccount.svelte b/kez-chat/web/src/routes/CreateAccount.svelte index 76014c0..72bba95 100644 --- a/kez-chat/web/src/routes/CreateAccount.svelte +++ b/kez-chat/web/src/routes/CreateAccount.svelte @@ -1,17 +1,16 @@
-

Restore from seed

+

Restore account

-

- v0.1 limitation: the seed alone doesn't tell us which - handle to restore. For now this flow doesn't work end-to-end — we'll - add GET /v1/by-primary/<id> on the server in v0.2 - so the SPA can look up the handle from the public key. +

+ 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 {serverDomain ?? "the server"} + and unlock the account on this device.

{ e.preventDefault(); submit(); }} >
-
diff --git a/kez-chat/web/src/routes/Settings.svelte b/kez-chat/web/src/routes/Settings.svelte index 17222ef..15ec8fa 100644 --- a/kez-chat/web/src/routes/Settings.svelte +++ b/kez-chat/web/src/routes/Settings.svelte @@ -3,6 +3,7 @@ import { push } from "svelte-spa-router"; import { bytesToHex } from "@noble/hashes/utils"; import { session } from "../lib/store.svelte.js"; + import { hasStoredPhrase } from "../lib/identity-store.js"; import { hasStoredBiometric, getStoredBiometricMeta, @@ -92,10 +93,36 @@ setTimeout(() => (testNotifResult = null), 5_000); } - function showSeed() { + async function showSeed() { 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); - 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() { @@ -166,12 +193,13 @@
-

Recovery seed

+

Recovery phrase

- 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.

diff --git a/kez-chat/web/src/routes/Welcome.svelte b/kez-chat/web/src/routes/Welcome.svelte index 43829eb..6a77b30 100644 --- a/kez-chat/web/src/routes/Welcome.svelte +++ b/kez-chat/web/src/routes/Welcome.svelte @@ -23,9 +23,10 @@ let biometricAvailable = $state(false); let notifPerm = $state("default"); - let seedRevealed = $state(false); - let seedHex = $state(""); - let seedCopied = $state(false); + let backupRevealed = $state(false); + let backupText = $state(""); // 12-word phrase if available, else hex seed + let backupKind = $state<"phrase" | "seed">("phrase"); + let backupCopied = $state(false); let busy = $state(false); onMount(async () => { @@ -42,13 +43,20 @@ function revealSeed() { if (!session.unlocked) return; - seedHex = bytesToHex(session.unlocked.seed); - seedRevealed = true; + if (session.unlocked.phrase) { + 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() { - await navigator.clipboard.writeText(seedHex); - seedCopied = true; - setTimeout(() => (seedCopied = false), 1500); + await navigator.clipboard.writeText(backupText); + backupCopied = true; + setTimeout(() => (backupCopied = false), 1500); } async function enableBiometric() { @@ -109,25 +117,40 @@
- +
  • {onboarding.seedAcked ? "✓" : "🔑"}
    -

    Back up your recovery seed

    +

    Back up your recovery phrase

    - This 32-byte seed is the only way to recover your - account. Lose it and it's gone forever — there's no reset. Write it - down offline. + These 12 words are the only way to recover + your account. Lose them and it's gone forever — there's no + reset. Write them on paper.

    - {#if !seedRevealed && !onboarding.seedAcked} - + {#if !backupRevealed && !onboarding.seedAcked} + {/if} - {#if seedRevealed} + {#if backupRevealed}
    -

    {seedHex}

    + {#if backupKind === "phrase"} +
      + {#each backupText.split(" ") as word, i} +
    1. + {i + 1}. + {word} +
    2. + {/each} +
    + {:else} +

    {backupText}

    +

    + Legacy 64-char hex — accounts created from now on get a + 12-word phrase instead. +

    + {/if}
    - + {#if !onboarding.seedAcked} {/if}