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>
179 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|