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:
parent
46cb58307c
commit
ea139641e3
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
287
kez-chat/web/src/lib/webauthn.ts
Normal file
287
kez-chat/web/src/lib/webauthn.ts
Normal 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";
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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,9 +89,33 @@
|
||||
Unlocking <span class="font-mono font-semibold">{meta.handle}@{meta.server}</span>
|
||||
</p>
|
||||
|
||||
{#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 showPassphrase}
|
||||
<form
|
||||
class="space-y-3"
|
||||
onsubmit={(e) => { e.preventDefault(); submit(); }}
|
||||
onsubmit={(e) => { e.preventDefault(); submitPassphrase(); }}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
@ -71,6 +139,11 @@
|
||||
{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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user