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/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",

View File

@ -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",

View File

@ -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<boolean> {
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<
Pick<StoredIdentity, "handle" | "server" | "primary" | "created_at"> | 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<void> {
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,
};
}

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

View File

@ -1,13 +1,18 @@
<script lang="ts">
import { push } from "svelte-spa-router";
import { hexToBytes } from "@noble/hashes/utils";
import { identityFromSeed } from "../lib/kez.js";
import { lookup, healthz, ApiError } from "../lib/api.js";
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";
let seedHex = $state("");
/** 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);
@ -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() {
error = null;
working = true;
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) {
throw new Error("Passphrase must be at least 8 characters.");
}
if (passphrase !== passphrase2) {
throw new Error("Passphrases don't match.");
}
const seed = hexToBytes(cleaned);
const id = identityFromSeed(seed);
if (!serverDomain) {
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
// handle, not primary. So we ask the user to type their handle.
throw new Error(
"Sorry — to restore, please use a device that has your handle saved. " +
"(v0.2 will let you look up your handle by primary key.)",
);
// TODO when chat-server has GET /v1/by-primary/<id>: implement this.
// For now restoring a seed-only is incomplete because we don't know
// the handle. Workaround: regenerate identity via /create with same
// handle (server will reject as taken; not useful) OR ask the user.
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;
error =
e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
} finally {
working = false;
}
@ -62,13 +107,13 @@
</script>
<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">
<strong>v0.1 limitation:</strong> the seed alone doesn't tell us which
handle to restore. For now this flow doesn't work end-to-end — we'll
add <code>GET /v1/by-primary/&lt;id&gt;</code> on the server in v0.2
so the SPA can look up the handle from the public key.
<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
@ -76,14 +121,17 @@
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<div>
<label class="block text-sm font-medium text-text-secondary" for="seed">
Seed (64 hex characters)
<label class="block text-sm font-medium text-text-secondary" for="secret">
Recovery phrase or hex seed
</label>
<textarea
id="seed"
bind:value={seedHex}
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>

View File

@ -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 phrase -->
<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">
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>
<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>
</div>
</section>

View File

@ -23,9 +23,10 @@
let biometricAvailable = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("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 @@
</div>
</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"}`}>
<div class="flex items-start gap-3">
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
<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">
This 32-byte seed is the <strong>only</strong> 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 <strong>only</strong> way to recover
your account. Lose them and it's gone forever — there's no
reset. Write them on paper.
</p>
{#if !seedRevealed && !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>
{#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 phrase</button>
{/if}
{#if seedRevealed}
{#if backupRevealed}
<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">
<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}
<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}