// 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) 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: // Encrypted seed: salt: string; // hex, 16 bytes nonce: string; // hex, 12 bytes ciphertext: string; // hex; AES-GCM(seed) under PBKDF2(passphrase) // Metadata: created_at: string; // RFC3339 } export interface UnlockedIdentity { handle: string; server: string; primary: Identity; seed: Uint8Array; } const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 SHA-256 guidance async function deriveKey( passphrase: string, salt: Uint8Array, ): Promise { 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 { const stored = await get(IDB_KEY); return !!stored; } export async function loadStoredIdentityMeta(): Promise< Pick | null > { const stored = await get(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; }): Promise { 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(), }; await set(IDB_KEY, record); } export async function unlockIdentity( passphrase: string, ): Promise { const stored = await get(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`, ); } return { handle: stored.handle, server: stored.server, primary: stored.primary, seed: plaintext, }; } export async function deleteStoredIdentity(): Promise { 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 { if (seed.length !== 32) { throw new Error(`seed is ${seed.length} bytes, expected 32`); } const stored = await get(IDB_KEY); if (!stored) throw new Error("no stored identity"); return { handle: stored.handle, server: stored.server, primary: stored.primary, seed, }; }