From ea139641e373b986b027c19e0919223bfcf9da92 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Tue, 26 May 2026 23:25:15 -0600 Subject: [PATCH] feat(kez-chat/web): biometric / passkey unlock via WebAuthn PRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kez-chat/web/src/lib/identity-store.ts | 21 ++ kez-chat/web/src/lib/webauthn.ts | 287 +++++++++++++++++++++++ kez-chat/web/src/routes/Dashboard.svelte | 98 ++++++++ kez-chat/web/src/routes/Unlock.svelte | 127 +++++++--- 4 files changed, 506 insertions(+), 27 deletions(-) create mode 100644 kez-chat/web/src/lib/webauthn.ts diff --git a/kez-chat/web/src/lib/identity-store.ts b/kez-chat/web/src/lib/identity-store.ts index f672d5d..c12487a 100644 --- a/kez-chat/web/src/lib/identity-store.ts +++ b/kez-chat/web/src/lib/identity-store.ts @@ -155,3 +155,24 @@ export async function unlockIdentity( 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, + }; +} diff --git a/kez-chat/web/src/lib/webauthn.ts b/kez-chat/web/src/lib/webauthn.ts new file mode 100644 index 0000000..b7d2688 --- /dev/null +++ b/kez-chat/web/src/lib/webauthn.ts @@ -0,0 +1,287 @@ +// Biometric / passkey unlock via WebAuthn PRF extension. +// +// What the PRF extension does: when you register a credential with PRF +// enabled and later call getAssertion() with a salt, the authenticator +// (Touch ID, Face ID, Windows Hello, fingerprint, YubiKey, …) returns +// a deterministic 32-byte secret derived from { credential-private-key, +// salt }. We never see the private key itself, but the secret is stable +// across calls — perfect for use as an AES key. +// +// Trust model: +// • Same as the passphrase flow — the seed is encrypted at rest in +// IndexedDB; only a successful unlock unwraps it. +// • The passphrase blob remains the authoritative backup. Biometric +// is ADDITIVE: lose your device or wipe your browser profile and +// you can still unlock with the passphrase (assuming you wrote +// down your seed too — which we tell users to do). +// • Server has no knowledge of any of this. PRF is purely client- +// side; the only thing the browser sends to the authenticator is +// a 32-byte salt and a challenge. +// +// Browser support (as of mid-2026): +// • Chrome 113+ on macOS / Windows / Android / Linux with passkeys +// • Safari 17+ on macOS / iOS +// • Most modern hardware keys (YubiKey 5+ with PRF) +// • Not supported: pre-PRF authenticators (older YubiKeys, some +// password managers). hasBiometricUnlockSupport() returns false +// in that case and the UI hides the option. + +import { get, set, del } from "idb-keyval"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; + +const IDB_KEY = "kez-chat:webauthn"; + +interface StoredBiometric { + version: 1; + /** WebAuthn credential id (raw bytes), needed for allowCredentials. */ + credential_id: string; // hex + /** Salt fed to PRF eval. Same salt = same derived key. */ + prf_salt: string; // hex, 32 bytes + /** AES-GCM nonce for the encrypted seed blob. */ + nonce: string; // hex, 12 bytes + /** AES-GCM(seed) under the PRF-derived key. */ + ciphertext: string; // hex + /** When the user set this up — shown in the Dashboard panel. */ + created_at: string; + /** Display name of the authenticator at registration time. */ + label: string; +} + +function asBuffer(u: Uint8Array): ArrayBuffer { + return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) as ArrayBuffer; +} + +/** Cheap synchronous check — does the browser have the WebAuthn API at all? */ +export function hasBiometricApi(): boolean { + return ( + typeof window !== "undefined" && + typeof window.PublicKeyCredential !== "undefined" && + typeof navigator !== "undefined" && + !!navigator.credentials + ); +} + +/** + * Async probe: does this device have a platform authenticator (Touch ID, + * Face ID, Windows Hello, …) available? Used to gate the "Set up + * biometric" button on Dashboard. + */ +export async function isPlatformAuthenticatorAvailable(): Promise { + if (!hasBiometricApi()) return false; + try { + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + return false; + } +} + +/** True if the user already enrolled a biometric for this profile. */ +export async function hasStoredBiometric(): Promise { + return !!(await get(IDB_KEY)); +} + +/** Metadata for the Dashboard panel (no secrets). */ +export async function getStoredBiometricMeta(): Promise< + Pick | null +> { + const stored = await get(IDB_KEY); + if (!stored) return null; + return { created_at: stored.created_at, label: stored.label }; +} + +export async function removeStoredBiometric(): Promise { + await del(IDB_KEY); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Setup — register a credential, derive PRF key, encrypt seed +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Register a platform authenticator and tie it to this identity's seed. + * Called from the Dashboard after the user clicks "Set up biometric + * unlock" while their session is unlocked (so we have `seed` in hand). + * + * On supporting authenticators this prompts the user once for biometric + * (registration) and once again (assertion to retrieve the PRF key). + * Some browsers can fold these together; we don't depend on that. + */ +export async function setupBiometricUnlock(opts: { + handle: string; + primary: string; // ed25519: + seed: Uint8Array; + label?: string; +}): Promise { + if (!hasBiometricApi()) throw new Error("WebAuthn not available in this browser"); + + // 1. Register a new credential. PRF is requested as an extension — + // if the authenticator doesn't support it, registration succeeds + // but getClientExtensionResults().prf.enabled will be false and we + // bail. + const userId = new TextEncoder().encode(opts.primary); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const cred = (await navigator.credentials.create({ + publicKey: { + challenge: asBuffer(challenge), + rp: { name: "kez-chat", id: window.location.hostname }, + user: { + id: asBuffer(userId), + name: opts.handle, + displayName: opts.handle, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + { alg: -257, type: "public-key" }, // RS256 fallback + ], + authenticatorSelection: { + userVerification: "required", + residentKey: "preferred", + }, + extensions: { prf: {} } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null; + if (!cred) throw new Error("registration was cancelled"); + + const ext = cred.getClientExtensionResults() as { + prf?: { enabled?: boolean }; + }; + if (!ext.prf?.enabled) { + throw new Error( + "Your authenticator doesn't support the WebAuthn PRF extension. " + + "Try a different one (most platform authenticators on macOS/iOS/Android/Windows do).", + ); + } + + // 2. Get an assertion with PRF eval against a fresh salt to retrieve + // the 32-byte derived secret. Same salt later = same secret. + const prfSalt = crypto.getRandomValues(new Uint8Array(32)); + const prfKey = await assertAndDerivePrfKey( + new Uint8Array(cred.rawId), + prfSalt, + ); + + // 3. AES-GCM encrypt the seed under the PRF-derived key. + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const aesKey = await crypto.subtle.importKey( + "raw", + asBuffer(prfKey), + "AES-GCM", + false, + ["encrypt", "decrypt"], + ); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: asBuffer(nonce) }, + aesKey, + asBuffer(opts.seed), + ), + ); + + const record: StoredBiometric = { + version: 1, + credential_id: bytesToHex(new Uint8Array(cred.rawId)), + prf_salt: bytesToHex(prfSalt), + nonce: bytesToHex(nonce), + ciphertext: bytesToHex(ciphertext), + created_at: new Date().toISOString(), + label: opts.label ?? defaultLabel(), + }; + await set(IDB_KEY, record); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Unlock — get assertion, derive PRF key, decrypt seed +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Prompt for the authenticator (Touch ID etc.), retrieve the PRF-derived + * key, decrypt the stored seed, return it. Caller is responsible for + * resolving handle/server/primary metadata — we just hand back the seed. + */ +export async function unlockWithBiometric(): Promise { + const stored = await get(IDB_KEY); + if (!stored) throw new Error("no biometric enrolled"); + const credentialId = hexToBytes(stored.credential_id); + const prfSalt = hexToBytes(stored.prf_salt); + const nonce = hexToBytes(stored.nonce); + const ciphertext = hexToBytes(stored.ciphertext); + + const prfKey = await assertAndDerivePrfKey(credentialId, prfSalt); + const aesKey = await crypto.subtle.importKey( + "raw", + asBuffer(prfKey), + "AES-GCM", + false, + ["decrypt"], + ); + let plaintext: Uint8Array; + try { + plaintext = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: asBuffer(nonce) }, + aesKey, + asBuffer(ciphertext), + ), + ); + } catch { + // Either the PRF key changed (different credential), the ciphertext + // was tampered with, or — most likely — the user cancelled. + throw new Error("biometric unlock failed"); + } + if (plaintext.length !== 32) { + throw new Error(`unlocked seed is ${plaintext.length} bytes, expected 32`); + } + return plaintext; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internals +// ───────────────────────────────────────────────────────────────────────────── + +async function assertAndDerivePrfKey( + credentialId: Uint8Array, + prfSalt: Uint8Array, +): Promise { + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: asBuffer(challenge), + rpId: window.location.hostname, + allowCredentials: [ + { id: asBuffer(credentialId), type: "public-key" }, + ], + userVerification: "required", + extensions: { + prf: { eval: { first: asBuffer(prfSalt) } }, + } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null; + if (!assertion) throw new Error("assertion cancelled"); + + const ext = assertion.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer } }; + }; + const prfFirst = ext.prf?.results?.first; + if (!prfFirst) { + throw new Error( + "Authenticator returned no PRF result. " + + "It probably doesn't support the PRF extension on this credential.", + ); + } + const bytes = new Uint8Array(prfFirst); + if (bytes.length !== 32) { + throw new Error(`PRF returned ${bytes.length} bytes, expected 32`); + } + return bytes; +} + +function defaultLabel(): string { + // Cheap user-agent sniff for the panel display. Not security-relevant. + const ua = navigator.userAgent; + if (/iPhone|iPad|iPod/.test(ua)) return "Face ID / Touch ID"; + if (/Mac/.test(ua)) return "Touch ID / passkey"; + if (/Android/.test(ua)) return "Android biometric"; + if (/Windows/.test(ua)) return "Windows Hello"; + return "Biometric / passkey"; +} diff --git a/kez-chat/web/src/routes/Dashboard.svelte b/kez-chat/web/src/routes/Dashboard.svelte index 823ed46..7d11a4a 100644 --- a/kez-chat/web/src/routes/Dashboard.svelte +++ b/kez-chat/web/src/routes/Dashboard.svelte @@ -10,6 +10,13 @@ type StoredClaim, } from "../lib/claims-store.js"; import { verifyClaim } from "../lib/verify.js"; + import { + hasStoredBiometric, + getStoredBiometricMeta, + setupBiometricUnlock, + removeStoredBiometric, + isPlatformAuthenticatorAvailable, + } from "../lib/webauthn.js"; let registryRecord = $state(null); let loading = $state(true); @@ -18,6 +25,14 @@ let claims = $state([]); let verifyingAll = $state(false); + // Biometric / passkey unlock state + let biometricSupported = $state(false); + let biometricEnrolled = $state(false); + let biometricLabel = $state(null); + let biometricCreatedAt = $state(null); + let biometricBusy = $state(false); + let biometricError = $state(null); + // Derived buckets for the verified-claims section. const verifiedClaims = $derived( claims.filter((c) => c.last_verify?.status === "ok"), @@ -35,6 +50,7 @@ return; } claims = await listClaims(); + await refreshBiometricStatus(); try { registryRecord = await lookup(session.unlocked.handle); } catch (e) { @@ -48,6 +64,38 @@ } }); + async function refreshBiometricStatus() { + biometricSupported = await isPlatformAuthenticatorAvailable(); + biometricEnrolled = await hasStoredBiometric(); + const m = await getStoredBiometricMeta(); + biometricLabel = m?.label ?? null; + biometricCreatedAt = m?.created_at ?? null; + } + + async function enableBiometric() { + if (!session.unlocked) return; + biometricBusy = true; + biometricError = null; + try { + await setupBiometricUnlock({ + handle: session.unlocked.handle, + primary: session.unlocked.primary, + seed: session.unlocked.seed, + }); + await refreshBiometricStatus(); + } catch (e) { + biometricError = (e as Error).message; + } finally { + biometricBusy = false; + } + } + + async function disableBiometric() { + if (!confirm("Disable biometric unlock for this device? You'll need your passphrase to unlock next time.")) return; + await removeStoredBiometric(); + await refreshBiometricStatus(); + } + async function reverifyAll() { verifyingAll = true; try { @@ -203,6 +251,56 @@ {/if} +
+
+
+

Quick unlock

+

+ Use Touch ID, Face ID, Windows Hello, or another passkey-capable + authenticator to unlock this device without typing your passphrase. + The passphrase still works as a backup — biometric only saves + keystrokes on the devices you set it up on. +

+
+ {#if biometricEnrolled} + + {/if} +
+ + {#if !biometricSupported && !biometricEnrolled} +

+ No platform authenticator detected in this browser. Try a recent + Chrome / Safari on a device with Touch ID, Face ID, Windows Hello, + or fingerprint. +

+ {:else if biometricEnrolled} +

+ ✓ {biometricLabel} enrolled + {#if biometricCreatedAt} + on {new Date(biometricCreatedAt).toLocaleDateString()}. + {/if} + Next unlock skips the passphrase prompt. +

+ {:else} + + {#if biometricError} +

{biometricError}

+ {/if} + {/if} +
+

Backup

diff --git a/kez-chat/web/src/routes/Unlock.svelte b/kez-chat/web/src/routes/Unlock.svelte index 336267d..452093a 100644 --- a/kez-chat/web/src/routes/Unlock.svelte +++ b/kez-chat/web/src/routes/Unlock.svelte @@ -1,7 +1,17 @@