Kez/kez-chat/web/src/lib/identity-store.ts
Jason Tudisco 3fdbdc9fcf 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>
2026-06-05 18:14:52 -06:00

239 lines
7.5 KiB
TypeScript

// Local persistence for the current user's identity.
//
// v0.1: stores the ed25519 seed in IndexedDB encrypted under a
// passphrase the user enters at unlock time. Standard pattern: derive
// a key from the passphrase via PBKDF2-SHA-256 → AES-GCM-256 wrap the
// seed → store the wrapped blob + salt + nonce. To unlock, the user
// types the passphrase again; if PBKDF2 produces the right key,
// AES-GCM unwraps the seed; if not, decryption fails.
//
// Caveats spelled out in the spec doc: browsers have no Keychain
// equivalent, so this is the best we can do client-side. The CLI and
// future native GUI use OS keychain and don't have this limitation.
import { get, set, del } from "idb-keyval";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import type { Identity } from "./kez.js";
const IDB_KEY = "kez-chat:identity";
// TS 5.6+ defaults Uint8Array's generic param to ArrayBufferLike, which
// isn't assignable to BufferSource (= ArrayBufferView<ArrayBuffer>) that
// WebCrypto expects. Copy to a plain ArrayBuffer so the types line up.
function asBuffer(u: Uint8Array): ArrayBuffer {
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer;
}
interface StoredIdentity {
version: 1;
handle: string;
server: string; // e.g. "kez.lat"
primary: Identity; // ed25519:<hex>
// Encrypted seed:
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
}
export interface UnlockedIdentity {
handle: string;
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
async function deriveKey(
passphrase: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const baseKey = await crypto.subtle.importKey(
"raw",
asBuffer(new TextEncoder().encode(passphrase)),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: asBuffer(salt),
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function hasStoredIdentity(): Promise<boolean> {
const stored = await get<StoredIdentity>(IDB_KEY);
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
> {
const stored = await get<StoredIdentity>(IDB_KEY);
if (!stored) return null;
const { handle, server, primary, created_at } = stored;
return { handle, server, primary, created_at };
}
export async function saveIdentity(opts: {
handle: string;
server: string;
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));
const key = await deriveKey(opts.passphrase, salt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
key,
asBuffer(opts.seed),
),
);
const record: StoredIdentity = {
version: 1,
handle: opts.handle,
server: opts.server,
primary: opts.primary,
salt: bytesToHex(salt),
nonce: bytesToHex(nonce),
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);
}
export async function unlockIdentity(
passphrase: string,
): Promise<UnlockedIdentity> {
const stored = await get<StoredIdentity>(IDB_KEY);
if (!stored) throw new Error("no stored identity");
const salt = hexToBytes(stored.salt);
const nonce = hexToBytes(stored.nonce);
const ciphertext = hexToBytes(stored.ciphertext);
const key = await deriveKey(passphrase, salt);
let plaintext: Uint8Array;
try {
plaintext = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(nonce) },
key,
asBuffer(ciphertext),
),
);
} catch {
throw new Error("wrong passphrase");
}
if (plaintext.length !== 32) {
throw new Error(
`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,
};
}
export async function deleteStoredIdentity(): Promise<void> {
await del(IDB_KEY);
}
/**
* Reconstruct an `UnlockedIdentity` from a seed + the stored metadata.
* Used by the biometric flow: WebAuthn hands us back the seed, but
* handle/server/primary live in the same record the passphrase flow
* writes — we just merge them. Throws if no stored identity exists or
* if the seed isn't 32 bytes.
*/
export async function unlockWithSeed(seed: Uint8Array): Promise<UnlockedIdentity> {
if (seed.length !== 32) {
throw new Error(`seed is ${seed.length} bytes, expected 32`);
}
const stored = await get<StoredIdentity>(IDB_KEY);
if (!stored) throw new Error("no stored identity");
return {
handle: stored.handle,
server: stored.server,
primary: stored.primary,
seed,
};
}