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:
parent
b0cc1a74a0
commit
3fdbdc9fcf
36
kez-chat/web/package-lock.json
generated
36
kez-chat/web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
111
kez-chat/web/src/lib/mnemonic.ts
Normal file
111
kez-chat/web/src/lib/mnemonic.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
error = e instanceof ApiError ? `${e.code ?? "error"}: ${e.message}` : (e as Error).message;
|
||||
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;
|
||||
}
|
||||
@ -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/<id></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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user