Kez/kez-chat/web/src/lib/identity-store.ts
Jason Tudisco ea139641e3 feat(kez-chat/web): biometric / passkey unlock via WebAuthn PRF
Touch ID / Face ID / Windows Hello / Android fingerprint / YubiKey
(PRF-capable) can now unlock the local seed without typing the
passphrase. Fully client-side — the server has zero visibility into
the credential or the derived key.

How it works (WebAuthn PRF extension):
  1. Setup (Dashboard → "Quick unlock" → "Set up biometric unlock"):
     • Register a platform credential with prf:{} in extensions.
     • If the authenticator returns prf.enabled, immediately
       getAssertion() with a random 32-byte salt to retrieve a
       deterministic 32-byte secret (the "PRF output").
     • AES-GCM(seed) under that secret → store the blob, salt, nonce,
       and credentialId in a separate IDB entry from the passphrase
       blob.

  2. Unlock (Unlock page → big "Unlock with Touch ID" button):
     • getAssertion() with the stored credentialId + salt → same
       32-byte secret → AES-GCM decrypt → seed.
     • unlockWithSeed() (new helper in identity-store) merges the
       seed with handle/server/primary metadata to rebuild the
       UnlockedIdentity session shape.

Trust properties (intentional):
  • Passphrase blob stays in place as the authoritative backup.
    Biometric is purely additive — wipe your browser profile or lose
    the device, passphrase still works on any device where you
    re-import the seed.
  • PRF output never leaves the browser. The authenticator is the
    only thing that can produce it, and only with the matching salt
    + credentialId we stored.
  • Disable → just deletes the IDB entry; the registered credential
    on the device still exists but is unused. (User can also clear
    it from their OS / passkey manager.)

Browser support gating:
  • Dashboard panel renders "no platform authenticator detected" if
    isUserVerifyingPlatformAuthenticatorAvailable() returns false.
  • Setup fails with a clear error if PRF isn't supported by the
    authenticator (older YubiKeys, some password managers).
  • Unlock page falls back to passphrase form automatically if
    biometric fails (cancelled, sensor error, etc.).

Live at https://kez.lat (asset index-Df_F5lEP.js).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:25:15 -06:00

179 lines
5.1 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)
// 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<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;
}
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;
}): 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(),
};
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`,
);
}
return {
handle: stored.handle,
server: stored.server,
primary: stored.primary,
seed: plaintext,
};
}
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,
};
}