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>
This commit is contained in:
Jason Tudisco 2026-05-26 23:25:15 -06:00
parent 46cb58307c
commit ea139641e3
4 changed files with 506 additions and 27 deletions

View File

@ -155,3 +155,24 @@ export async function unlockIdentity(
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,
};
}

View File

@ -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<boolean> {
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<boolean> {
return !!(await get<StoredBiometric>(IDB_KEY));
}
/** Metadata for the Dashboard panel (no secrets). */
export async function getStoredBiometricMeta(): Promise<
Pick<StoredBiometric, "created_at" | "label"> | null
> {
const stored = await get<StoredBiometric>(IDB_KEY);
if (!stored) return null;
return { created_at: stored.created_at, label: stored.label };
}
export async function removeStoredBiometric(): Promise<void> {
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:<hex>
seed: Uint8Array;
label?: string;
}): Promise<void> {
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<Uint8Array> {
const stored = await get<StoredBiometric>(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<Uint8Array> {
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";
}

View File

@ -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<any | null>(null);
let loading = $state(true);
@ -18,6 +25,14 @@
let claims = $state<StoredClaim[]>([]);
let verifyingAll = $state(false);
// Biometric / passkey unlock state
let biometricSupported = $state(false);
let biometricEnrolled = $state(false);
let biometricLabel = $state<string | null>(null);
let biometricCreatedAt = $state<string | null>(null);
let biometricBusy = $state(false);
let biometricError = $state<string | null>(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}
</section>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs text-gray-500 uppercase tracking-wide">Quick unlock</p>
<p class="text-sm text-gray-700 mt-1">
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.
</p>
</div>
{#if biometricEnrolled}
<button
class="px-3 py-1.5 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-red-50 hover:border-red-300 shrink-0"
onclick={disableBiometric}
>
Disable
</button>
{/if}
</div>
{#if !biometricSupported && !biometricEnrolled}
<p class="mt-3 text-sm text-gray-500 italic">
No platform authenticator detected in this browser. Try a recent
Chrome / Safari on a device with Touch ID, Face ID, Windows Hello,
or fingerprint.
</p>
{:else if biometricEnrolled}
<p class="mt-3 text-sm text-green-800 bg-green-50 border border-green-200 rounded p-3">
<strong>{biometricLabel}</strong> enrolled
{#if biometricCreatedAt}
on {new Date(biometricCreatedAt).toLocaleDateString()}.
{/if}
Next unlock skips the passphrase prompt.
</p>
{:else}
<button
class="mt-3 px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 flex items-center gap-2"
onclick={enableBiometric}
disabled={biometricBusy}
>
<span>🔓</span>
{biometricBusy ? "Setting up…" : "Set up biometric unlock"}
</button>
{#if biometricError}
<p class="mt-2 text-xs text-red-700">{biometricError}</p>
{/if}
{/if}
</section>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Backup</p>
<p class="text-sm text-gray-700">

View File

@ -1,7 +1,17 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { loadStoredIdentityMeta, unlockIdentity, deleteStoredIdentity } from "../lib/identity-store.js";
import {
loadStoredIdentityMeta,
unlockIdentity,
unlockWithSeed,
deleteStoredIdentity,
} from "../lib/identity-store.js";
import {
hasStoredBiometric,
unlockWithBiometric,
getStoredBiometricMeta,
} from "../lib/webauthn.js";
import { session } from "../lib/store.svelte.js";
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
@ -9,12 +19,28 @@
let error = $state<string | null>(null);
let working = $state(false);
let biometricAvailable = $state(false);
let biometricLabel = $state<string | null>(null);
/** Show passphrase form even when biometric is set up. */
let showPassphrase = $state(false);
onMount(async () => {
meta = await loadStoredIdentityMeta();
if (!meta) push("/");
if (!meta) {
push("/");
return;
}
if (await hasStoredBiometric()) {
biometricAvailable = true;
const m = await getStoredBiometricMeta();
biometricLabel = m?.label ?? "Biometric / passkey";
} else {
// No biometric enrolled — passphrase is the only path.
showPassphrase = true;
}
});
async function submit() {
async function submitPassphrase() {
working = true;
error = null;
try {
@ -29,6 +55,24 @@
}
}
async function submitBiometric() {
working = true;
error = null;
try {
const seed = await unlockWithBiometric();
const id = await unlockWithSeed(seed);
session.setUnlocked(id);
push("/dashboard");
} catch (e) {
error = (e as Error).message;
// If biometric fails (user cancelled, sensor errored), fall back
// to passphrase rather than leaving the user stuck on the page.
showPassphrase = true;
} finally {
working = false;
}
}
async function forget() {
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
await deleteStoredIdentity();
@ -45,32 +89,61 @@
Unlocking <span class="font-mono font-semibold">{meta.handle}@{meta.server}</span>
</p>
<form
class="space-y-3"
onsubmit={(e) => { e.preventDefault(); submit(); }}
>
<input
type="password"
bind:value={passphrase}
placeholder="passphrase"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{#if biometricAvailable}
<div class="space-y-3">
<button
type="button"
class="w-full px-4 py-3 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 flex items-center justify-center gap-2"
onclick={submitBiometric}
disabled={working}
>
<span class="text-lg">🔓</span>
{working ? "Waiting for biometric…" : `Unlock with ${biometricLabel}`}
</button>
{#if !showPassphrase}
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-900 underline"
onclick={() => (showPassphrase = true)}
>
Use passphrase instead
</button>
{/if}
</div>
{/if}
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={working || passphrase.length === 0}
{#if showPassphrase}
<form
class="space-y-3"
onsubmit={(e) => { e.preventDefault(); submitPassphrase(); }}
>
{working ? "Unlocking…" : "Unlock"}
</button>
</form>
<input
type="password"
bind:value={passphrase}
placeholder="passphrase"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{#if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
<button
type="submit"
class="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
disabled={working || passphrase.length === 0}
>
{working ? "Unlocking…" : "Unlock"}
</button>
</form>
{:else if error}
<p class="text-sm text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</p>
{/if}
<button
class="text-xs text-gray-500 hover:text-red-700 underline"