1/**2// Package-owned identity and device-credential runtime.3 * Device credential auth for local-first apps.4 *5 * Local-first identity is device-local and cryptographic — there is no central6 * store to authenticate against. This module is lofi's small, honest WebAuthn +7 * PRF primitive: enroll a device passkey, authenticate with it, and derive a8 * credential-bound key (PRF) to encrypt data **at rest**.9 *10 * It is feature-detected and never faked: if the device cannot do WebAuthn or11 * PRF, {@link getAuthCapability} says so and the derive path throws rather than12 * inventing a key. It makes **no** recovery claim — a lost device means lost13 * access, the same custody the device-local identity already has.14 *15 * @module16 */1718import { getLofiApp } from "./app.ts";1920/** Whether the WebAuthn PRF extension can be used on this client. */21export type PrfSupport = "available" | "not-reported" | "unknown" | "unavailable";2223/**24 * How stable the current origin is for enrolling a passkey. A passkey is bound25 * to the origin hostname (its RP ID); enrolling on an origin that later changes26 * silently breaks the credential, so enrollment is gated on `stable`.27 */28export type CredentialOriginReport = {29 status: "stable" | "local-only" | "unverified" | "blocked";30 /** The relying-party id (origin hostname) a credential would bind to. */31 rpId: string;32 /** Human-readable next step for the current status. */33 action: string;34};3536/** What the current device/browser/origin can do for credential auth. */37export type AuthCapability = {38 webAuthn: boolean;39 prf: PrfSupport;40 origin: CredentialOriginReport;41};4243/** An enrolled or authenticated device credential. */44export type DeviceCredential = {45 /** The opaque, base64url-encoded credential id. Safe to persist and compare. */46 id: string;47 /** The relying-party id the credential is bound to. */48 rpId: string;49 /**50 * Whether the credential is backup-eligible — i.e. it syncs/roams across the51 * user's devices (a password-manager or platform-synced passkey) rather than52 * being bound to this one device. With "the key is the account", a portable53 * credential means the account travels with it; a device-bound one does not.54 */55 portable: boolean;56};5758/** Injected browser surfaces, so the flows are unit-testable without a device. */59export type AuthDependencies = {60 credentials?: CredentialsContainer;61 rpId?: string;62 trustedOrigins?: readonly string[];63};6465/** Options for {@link enrollDeviceCredential}. */66export type EnrollOptions = AuthDependencies & {67 /**68 * A human-readable nickname for the credential. It becomes the passkey's69 * name/displayName, so the user can identify which account it belongs to in70 * their password manager or OS passkey list. Defaults to the app name.71 */72 label?: string;73};7475/** A precise, non-leaking failure reason for a credential operation. */76export class AuthError extends Error {77 /** Stable error class name for diagnostics and error boundaries. */78 override readonly name = "AuthError";79 /** Actionable category that callers can map to user-facing guidance. */80 readonly code:81 | "cancelled"82 | "origin-rejected"83 | "unsupported"84 | "prf-unavailable"85 | "credential-missing"86 | "credential-mismatch"87 | "unknown";8889 /** Creates a device-credential error without including credential material. */90 constructor(code: AuthError["code"], message?: string) {91 super(message ?? `Device credential operation failed: ${code}.`);92 this.code = code;93 }94}9596// WebAuthn PRF extension types are not in the DOM lib yet; declare the minimum.97type PrfInputs = { eval?: { first: BufferSource; second?: BufferSource } };98type PrfResults = { results?: { first?: ArrayBuffer; second?: ArrayBuffer } };99type CreateExtensions = PublicKeyCredentialCreationOptions["extensions"] & {100 prf?: Record<never, never>;101};102type RequestExtensions = PublicKeyCredentialRequestOptions["extensions"] & { prf?: PrfInputs };103type ClientCapabilities = typeof PublicKeyCredential & {104 getClientCapabilities?: () => Promise<Record<string, boolean>>;105};106107function toBase64Url(bytes: Uint8Array): string {108 let binary = "";109 for (const byte of bytes) binary += String.fromCharCode(byte);110 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");111}112113function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {114 const base64 = value.replaceAll("-", "+").replaceAll("_", "/");115 try {116 const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));117 const bytes = new Uint8Array(binary.length);118 for (let index = 0; index < binary.length; index += 1) {119 bytes[index] = binary.charCodeAt(index);120 }121 return bytes;122 } catch {123 throw new AuthError("credential-mismatch", "The pinned credential id is unreadable.");124 }125}126127function randomBytes(length = 32): Uint8Array<ArrayBuffer> {128 return crypto.getRandomValues(new Uint8Array(length));129}130131function browserCredentials(): CredentialsContainer {132 if (typeof navigator === "undefined" || !navigator.credentials) {133 throw new AuthError("unsupported");134 }135 return navigator.credentials;136}137138function rawId(credential: Credential | null): Uint8Array {139 if (!credential || !("rawId" in credential) || !(credential.rawId instanceof ArrayBuffer)) {140 throw new AuthError("credential-missing");141 }142 return new Uint8Array(credential.rawId);143}144145// Reads the WebAuthn "backup eligible" (BE) flag from the authenticator data of146// a create() attestation or a get() assertion. BE=1 means the credential can147// roam/sync across the user's devices. Best-effort: false when unreadable.148function isPortable(credential: Credential | null): boolean {149 const response = (credential as150 | Credential & {151 response?: { getAuthenticatorData?: () => ArrayBuffer; authenticatorData?: ArrayBuffer };152 }153 | null)?.response;154 const authData = response?.getAuthenticatorData?.() ?? response?.authenticatorData;155 if (!authData) return false;156 const bytes = new Uint8Array(authData);157 return bytes.length >= 33 && (bytes[32] & 0x08) !== 0;158}159160function mapError(error: unknown): AuthError {161 if (error instanceof AuthError) return error;162 const name = typeof error === "object" && error !== null && "name" in error163 ? String((error as { name: unknown }).name)164 : "";165 if (name === "NotAllowedError") return new AuthError("cancelled");166 if (name === "SecurityError") return new AuthError("origin-rejected");167 if (name === "NotSupportedError") return new AuthError("unsupported");168 return new AuthError("unknown", error instanceof Error ? error.message : undefined);169}170171// --- Origin / RP-ID stability -------------------------------------------------172173function canonicalHostname(hostname: string): string {174 return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;175}176177function isLocalHostname(hostname: string): boolean {178 return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1";179}180181function isIpAddress(hostname: string): boolean {182 return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");183}184185function matchesTrustedOrigin(hostname: string, pattern: string): boolean {186 const normalized = canonicalHostname(pattern.toLowerCase());187 if (normalized.startsWith("*.")) {188 const suffix = normalized.slice(1);189 return hostname.length > suffix.length && hostname.endsWith(suffix);190 }191 return normalized.length > 0 && !normalized.includes("*") && hostname === normalized;192}193194/**195 * Classifies whether `url` is a safe origin to enroll a device credential on,196 * against the author's committed-stable hostnames (`app.ts` `credentialOrigins`,197 * exact hostnames or `*.` suffix patterns). Guidance is platform-agnostic — the198 * requirement is a stable HTTPS origin you control, however you serve it.199 */200export function classifyCredentialOrigin(201 url: URL,202 trustedOrigins: readonly string[] = getLofiApp().credentialOrigins ?? [],203): CredentialOriginReport {204 const rpId = url.hostname;205 const hostname = canonicalHostname(rpId);206 if (isLocalHostname(hostname)) {207 return {208 status: "local-only",209 rpId,210 action:211 "localhost credentials do not transfer; enroll on the stable HTTPS origin you will ship on",212 };213 }214 if (url.protocol !== "https:" || !hostname || isIpAddress(hostname)) {215 return {216 status: "blocked",217 rpId,218 action: "serve over a stable HTTPS origin before enrolling a device credential",219 };220 }221 if (trustedOrigins.some((pattern) => matchesTrustedOrigin(hostname, pattern))) {222 return {223 status: "stable",224 rpId,225 action: "keep this hostname for the lifetime of any device credential",226 };227 }228 return {229 status: "unverified",230 rpId,231 action:232 "add this hostname to credentialOrigins once you have committed to keeping it permanent",233 };234}235236function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginReport {237 if (typeof location === "undefined") {238 return { status: "blocked", rpId: "", action: "open the application in a browser" };239 }240 const url = new URL(location.href);241 const declared = trustedOrigins ?? getLofiApp().credentialOrigins ?? [];242 // No implicit trust: with an empty `credentialOrigins` allowlist, a deployed243 // HTTPS host classifies as `unverified` and enrollment refuses. Implicitly244 // trusting the served hostname made that refusal unreachable — a preview245 // host could mint credentials (and PRF-protected data) that strand the246 // moment the app moves to its production hostname. Development on247 // localhost stays `local-only` and keeps working without configuration.248 return classifyCredentialOrigin(url, declared);249}250251function requireStableRpId(dependencies: AuthDependencies): string {252 if (dependencies.rpId) return dependencies.rpId;253 const origin = currentOrigin(dependencies.trustedOrigins);254 // Enroll where you are actually served: a committed HTTPS host (`stable`) or255 // loopback during local development (`local-only`). Refuse insecure origins256 // and hosts that an explicit `credentialOrigins` allowlist excludes.257 if (origin.status !== "stable" && origin.status !== "local-only") {258 throw new AuthError("origin-rejected", origin.action);259 }260 return origin.rpId;261}262263// --- Capability ---------------------------------------------------------------264265async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {266 if (!webAuthn) return "unavailable";267 const publicKeyCredential = PublicKeyCredential as ClientCapabilities;268 if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";269 try {270 const capabilities = await publicKeyCredential.getClientCapabilities();271 return capabilities["extension:prf"] === true ? "available" : "not-reported";272 } catch {273 return "unknown";274 }275}276277/** Reports what the current device/browser/origin can do for credential auth. */278export async function getAuthCapability(279 dependencies: AuthDependencies = {},280): Promise<AuthCapability> {281 const webAuthn = typeof PublicKeyCredential === "function";282 return {283 webAuthn,284 prf: await readPrfSupport(webAuthn),285 origin: currentOrigin(dependencies.trustedOrigins),286 };287}288289// --- Enroll / authenticate ----------------------------------------------------290291/**292 * Enrolls a resident, user-verifying device passkey with the PRF extension293 * enabled. Refuses unless the current origin is `stable` (pass `rpId` to test).294 * The returned `portable` flag says whether the credential roams across devices;295 * `label` names it in the user's password manager. Returns the opaque id.296 */297export async function enrollDeviceCredential(298 options: EnrollOptions = {},299): Promise<DeviceCredential> {300 const app = getLofiApp();301 const rpId = requireStableRpId(options);302 const credentials = options.credentials ?? browserCredentials();303 const label = options.label ?? app.name;304 try {305 const credential = await credentials.create({306 publicKey: {307 rp: { id: rpId, name: app.name },308 user: { id: randomBytes(), name: label, displayName: label },309 challenge: randomBytes(),310 pubKeyCredParams: [311 { type: "public-key", alg: -7 },312 { type: "public-key", alg: -257 },313 ],314 authenticatorSelection: {315 residentKey: "required",316 requireResidentKey: true,317 userVerification: "required",318 },319 attestation: "none",320 timeout: 60_000,321 extensions: { prf: {} } as CreateExtensions,322 },323 });324 return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };325 } catch (error) {326 throw mapError(error);327 }328}329330/** Options for {@link authenticateDeviceCredential}. */331export type AuthenticateOptions = AuthDependencies & {332 /**333 * The base64url id of the one enrolled credential this ceremony must assert.334 * When set, the request pins `allowCredentials` to it and the returned335 * assertion is verified against it — any other credential for the same RP ID336 * fails with `credential-mismatch` instead of passing as the enrolled one.337 */338 credentialId?: string;339};340341/**342 * Authenticates with a device passkey (user-verifying). Pass `credentialId` to343 * pin the ceremony to one enrolled credential; without it the request is344 * discoverable and any resident credential for the RP ID can assert.345 */346export async function authenticateDeviceCredential(347 options: AuthenticateOptions = {},348): Promise<DeviceCredential> {349 const rpId = requireStableRpId(options);350 const credentials = options.credentials ?? browserCredentials();351 const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId352 ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]353 : undefined;354 try {355 const credential = await credentials.get({356 publicKey: {357 rpId,358 challenge: randomBytes(),359 userVerification: "required",360 timeout: 60_000,361 ...(allowCredentials ? { allowCredentials } : {}),362 },363 });364 const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };365 // Verify the assertion even though `allowCredentials` was sent: the browser366 // allow-list is a request hint, not a caller-side guarantee.367 if (options.credentialId && asserted.id !== options.credentialId) {368 throw new AuthError(369 "credential-mismatch",370 "The asserting passkey is not the enrolled credential.",371 );372 }373 return asserted;374 } catch (error) {375 throw mapError(error);376 }377}378379/**380 * Authenticates and derives the PRF secret in one user-verifying ceremony:381 * the PRF evaluation rides the assertion's extensions, so a flow that needs382 * both the asserted credential and a credential-bound key costs one prompt,383 * not two. Pass `credentialId` to pin the ceremony to one enrolled384 * credential. Throws `prf-unavailable` when the client or authenticator385 * returns no PRF result — the secret is never faked — and386 * `credential-mismatch` when a different credential asserts than the one387 * pinned.388 */389export async function authenticateAndDerivePrfSecret(390 salt: BufferSource,391 options: AuthenticateOptions = {},392): Promise<{ credential: DeviceCredential; secret: Uint8Array }> {393 const rpId = requireStableRpId(options);394 const credentials = options.credentials ?? browserCredentials();395 const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId396 ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]397 : undefined;398 try {399 const credential = await credentials.get({400 publicKey: {401 rpId,402 challenge: randomBytes(),403 userVerification: "required",404 timeout: 60_000,405 ...(allowCredentials ? { allowCredentials } : {}),406 extensions: { prf: { eval: { first: salt } } } as RequestExtensions,407 },408 });409 const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };410 // Verify the assertion even though `allowCredentials` was sent: the browser411 // allow-list is a request hint, not a caller-side guarantee.412 if (options.credentialId && asserted.id !== options.credentialId) {413 throw new AuthError(414 "credential-mismatch",415 "The asserting passkey is not the enrolled credential.",416 );417 }418 const results =419 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))420 .getClientExtensionResults?.().prf?.results;421 if (!results?.first) throw new AuthError("prf-unavailable");422 // A copy, not a view: callers zero the secret after deriving from it, and423 // that must never reach through to the extension-result buffer.424 return { credential: asserted, secret: new Uint8Array(results.first.slice(0)) };425 } catch (error) {426 throw mapError(error);427 }428}429430// --- PRF-derived at-rest key --------------------------------------------------431432/**433 * Derives a credential-bound secret from the passkey via the WebAuthn PRF434 * extension in one user-verifying `get()`. The same `salt` on the same435 * authenticator yields the same 32-byte secret; it never leaves the device.436 * Throws `prf-unavailable` if the client or authenticator does not return a437 * PRF result — the secret is never faked.438 */439export async function derivePrfSecret(440 salt: BufferSource,441 dependencies: AuthDependencies = {},442): Promise<Uint8Array> {443 const rpId = requireStableRpId(dependencies);444 const credentials = dependencies.credentials ?? browserCredentials();445 try {446 const credential = await credentials.get({447 publicKey: {448 rpId,449 challenge: randomBytes(),450 userVerification: "required",451 timeout: 60_000,452 extensions: { prf: { eval: { first: salt } } } as RequestExtensions,453 },454 });455 const results =456 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))457 .getClientExtensionResults?.().prf?.results;458 if (!results?.first) throw new AuthError("prf-unavailable");459 return new Uint8Array(results.first);460 } catch (error) {461 throw mapError(error);462 }463}464465/**466 * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.467 * Bind unrelated data to different keys by varying `info`.468 */469export async function deriveAtRestKey(470 prfSecret: Uint8Array,471 info: string,472 salt: Uint8Array = new Uint8Array(0),473): Promise<CryptoKey> {474 const material = await crypto.subtle.importKey(475 "raw",476 prfSecret as BufferSource,477 "HKDF",478 false,479 ["deriveKey"],480 );481 return await crypto.subtle.deriveKey(482 {483 name: "HKDF",484 hash: "SHA-256",485 salt: salt as BufferSource,486 info: new TextEncoder().encode(info),487 },488 material,489 { name: "AES-GCM", length: 256 },490 false,491 ["encrypt", "decrypt"],492 );493}494495/** Encrypts `plaintext` with an at-rest key, returning a self-contained blob. */496export async function encryptAtRest(497 key: CryptoKey,498 plaintext: Uint8Array,499): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {500 const iv = randomBytes(12);501 const ciphertext = await crypto.subtle.encrypt(502 { name: "AES-GCM", iv },503 key,504 plaintext as BufferSource,505 );506 return { iv, ciphertext: new Uint8Array(ciphertext) };507}508509/** Decrypts a blob produced by {@link encryptAtRest}. */510export async function decryptAtRest(511 key: CryptoKey,512 blob: { iv: Uint8Array; ciphertext: Uint8Array },513): Promise<Uint8Array> {514 const plaintext = await crypto.subtle.decrypt(515 { name: "AES-GCM", iv: blob.iv as BufferSource },516 key,517 blob.ciphertext as BufferSource,518 );519 return new Uint8Array(plaintext);520}521522// Account identity is not derived from a credential in lofi. The account is a523// local-first secret (see `session.ts`), backed up by a recovery phrase and,524// optionally, protected at rest by the PRF key above. Deriving the *account*525// from a passkey was removed: it cannot preserve a pre-existing local-only526// account's data, since a derived key is a different account. This module stays527// a device-credential + at-rest-key primitive, nothing more.