clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

main
lofi / package / runtime / auth.ts
auth.ts 20.1K
1/**2 * Device credential auth for local-first apps.3 *4 * Local-first identity is device-local and cryptographic — there is no central5 * store to authenticate against. This module is lofi's small, honest WebAuthn +6 * PRF primitive: enroll a device passkey, authenticate with it, and derive a7 * credential-bound key (PRF) to encrypt data **at rest**.8 *9 * It is feature-detected and never faked: if the device cannot do WebAuthn or10 * PRF, {@link getAuthCapability} says so and the derive path throws rather than11 * inventing a key. It makes **no** recovery claim — a lost device means lost12 * access, the same custody the device-local identity already has.13 *14 * @module15 */1617import { getLofiApp } from "./app.ts";1819/** Whether the WebAuthn PRF extension can be used on this client. */20export type PrfSupport = "available" | "not-reported" | "unknown" | "unavailable";2122/**23 * How stable the current origin is for enrolling a passkey. A passkey is bound24 * to the origin hostname (its RP ID); enrolling on an origin that later changes25 * silently breaks the credential, so enrollment is allowed only on `stable`26 * origins and on loopback hosts during development (`local-only`), and refused27 * on `unverified` and `blocked` origins.28 */29export type CredentialOriginReport = {30 status: "stable" | "local-only" | "unverified" | "blocked";31 /** The relying-party id (origin hostname) a credential would bind to. */32 rpId: string;33 /** Human-readable next step for the current status. */34 action: string;35};3637/** What the current device/browser/origin can do for credential auth. */38export type AuthCapability = {39 webAuthn: boolean;40 prf: PrfSupport;41 origin: CredentialOriginReport;42};4344/** An enrolled or authenticated device credential. */45export type DeviceCredential = {46 /** The opaque, base64url-encoded credential id. Safe to persist and compare. */47 id: string;48 /** The relying-party id the credential is bound to. */49 rpId: string;50 /**51 * Whether the credential is backup-eligible — i.e. it syncs/roams across the52 * user's devices (a password-manager or platform-synced passkey) rather than53 * being bound to this one device. With "the key is the account", a portable54 * credential means the account travels with it; a device-bound one does not.55 */56 portable: boolean;57};5859/** Injected browser surfaces, so the flows are unit-testable without a device. */60export type AuthDependencies = {61 credentials?: CredentialsContainer;62 rpId?: string;63 trustedOrigins?: readonly string[];64};6566/** Options for {@link enrollDeviceCredential}. */67export type EnrollOptions = AuthDependencies & {68 /**69 * A human-readable nickname for the credential. It becomes the passkey's70 * name/displayName, so the user can identify which account it belongs to in71 * their password manager or OS passkey list. Defaults to the app name.72 */73 label?: string;74};7576/** A precise, non-leaking failure reason for a credential operation. */77export class AuthError extends Error {78 /** Stable error class name for diagnostics and error boundaries. */79 override readonly name = "AuthError";80 /** Actionable category that callers can map to user-facing guidance. */81 readonly code:82 | "cancelled"83 | "origin-rejected"84 | "unsupported"85 | "prf-unavailable"86 | "credential-missing"87 | "credential-mismatch"88 | "unknown";8990 /** Creates a device-credential error without including credential material. */91 constructor(code: AuthError["code"], message?: string) {92 super(message ?? `Device credential operation failed: ${code}.`);93 this.code = code;94 }95}9697// WebAuthn PRF extension types are not in the DOM lib yet; declare the minimum.98type PrfInputs = { eval?: { first: BufferSource; second?: BufferSource } };99type PrfResults = { results?: { first?: ArrayBuffer; second?: ArrayBuffer } };100type CreateExtensions = PublicKeyCredentialCreationOptions["extensions"] & {101 prf?: Record<never, never>;102};103type RequestExtensions = PublicKeyCredentialRequestOptions["extensions"] & { prf?: PrfInputs };104type ClientCapabilities = typeof PublicKeyCredential & {105 getClientCapabilities?: () => Promise<Record<string, boolean>>;106};107108function toBase64Url(bytes: Uint8Array): string {109 let binary = "";110 for (const byte of bytes) binary += String.fromCharCode(byte);111 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");112}113114function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {115 const base64 = value.replaceAll("-", "+").replaceAll("_", "/");116 try {117 const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));118 const bytes = new Uint8Array(binary.length);119 for (let index = 0; index < binary.length; index += 1) {120 bytes[index] = binary.charCodeAt(index);121 }122 return bytes;123 } catch {124 throw new AuthError("credential-mismatch", "The pinned credential id is unreadable.");125 }126}127128function randomBytes(length = 32): Uint8Array<ArrayBuffer> {129 return crypto.getRandomValues(new Uint8Array(length));130}131132function browserCredentials(): CredentialsContainer {133 if (typeof navigator === "undefined" || !navigator.credentials) {134 throw new AuthError("unsupported");135 }136 return navigator.credentials;137}138139function rawId(credential: Credential | null): Uint8Array {140 if (!credential || !("rawId" in credential) || !(credential.rawId instanceof ArrayBuffer)) {141 throw new AuthError("credential-missing");142 }143 return new Uint8Array(credential.rawId);144}145146// Reads the WebAuthn "backup eligible" (BE) flag from the authenticator data of147// a create() attestation or a get() assertion. BE=1 means the credential can148// roam/sync across the user's devices. Best-effort: false when unreadable.149function isPortable(credential: Credential | null): boolean {150 const response = (credential as151 | Credential & {152 response?: { getAuthenticatorData?: () => ArrayBuffer; authenticatorData?: ArrayBuffer };153 }154 | null)?.response;155 const authData = response?.getAuthenticatorData?.() ?? response?.authenticatorData;156 if (!authData) return false;157 const bytes = new Uint8Array(authData);158 return bytes.length >= 33 && (bytes[32] & 0x08) !== 0;159}160161function mapError(error: unknown): AuthError {162 if (error instanceof AuthError) return error;163 const name = typeof error === "object" && error !== null && "name" in error164 ? String((error as { name: unknown }).name)165 : "";166 if (name === "NotAllowedError") return new AuthError("cancelled");167 if (name === "SecurityError") return new AuthError("origin-rejected");168 if (name === "NotSupportedError") return new AuthError("unsupported");169 return new AuthError("unknown", error instanceof Error ? error.message : undefined);170}171172// --- Origin / RP-ID stability -------------------------------------------------173174function canonicalHostname(hostname: string): string {175 return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;176}177178function isLocalHostname(hostname: string): boolean {179 return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1";180}181182function isIpAddress(hostname: string): boolean {183 return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");184}185186function matchesTrustedOrigin(hostname: string, pattern: string): boolean {187 const normalized = canonicalHostname(pattern.toLowerCase());188 if (normalized.startsWith("*.")) {189 const suffix = normalized.slice(1);190 return hostname.length > suffix.length && hostname.endsWith(suffix);191 }192 return normalized.length > 0 && !normalized.includes("*") && hostname === normalized;193}194195/**196 * Classifies whether `url` is a safe origin to enroll a device credential on,197 * against the author's committed-stable hostnames (`app.ts` `credentialOrigins`,198 * exact hostnames or `*.` suffix patterns). Guidance is platform-agnostic — the199 * requirement is a stable HTTPS origin you control, however you serve it.200 */201export function classifyCredentialOrigin(202 url: URL,203 trustedOrigins: readonly string[] = getLofiApp().credentialOrigins ?? [],204): CredentialOriginReport {205 const rpId = url.hostname;206 const hostname = canonicalHostname(rpId);207 if (isLocalHostname(hostname)) {208 return {209 status: "local-only",210 rpId,211 action:212 "localhost credentials do not transfer; enroll on the stable HTTPS origin you will ship on",213 };214 }215 if (url.protocol !== "https:" || !hostname || isIpAddress(hostname)) {216 return {217 status: "blocked",218 rpId,219 action: "serve over a stable HTTPS origin before enrolling a device credential",220 };221 }222 if (trustedOrigins.some((pattern) => matchesTrustedOrigin(hostname, pattern))) {223 return {224 status: "stable",225 rpId,226 action: "keep this hostname for the lifetime of any device credential",227 };228 }229 return {230 status: "unverified",231 rpId,232 action:233 "add this hostname to credentialOrigins once you have committed to keeping it permanent",234 };235}236237function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginReport {238 if (typeof location === "undefined") {239 return { status: "blocked", rpId: "", action: "open the application in a browser" };240 }241 const url = new URL(location.href);242 const declared = trustedOrigins ?? getLofiApp().credentialOrigins ?? [];243 // No implicit trust: with an empty `credentialOrigins` allowlist, a deployed244 // HTTPS host classifies as `unverified` and enrollment refuses. Implicitly245 // trusting the served hostname made that refusal unreachable — a preview246 // host could mint credentials (and PRF-protected data) that strand the247 // moment the app moves to its production hostname. Development on248 // localhost stays `local-only` and keeps working without configuration.249 return classifyCredentialOrigin(url, declared);250}251252function requireStableRpId(dependencies: AuthDependencies): string {253 if (dependencies.rpId) return dependencies.rpId;254 const origin = currentOrigin(dependencies.trustedOrigins);255 // Enroll where you are actually served: a committed HTTPS host (`stable`) or256 // loopback during local development (`local-only`). Refuse insecure origins257 // and hosts that an explicit `credentialOrigins` allowlist excludes.258 if (origin.status !== "stable" && origin.status !== "local-only") {259 throw new AuthError("origin-rejected", origin.action);260 }261 return origin.rpId;262}263264// --- Capability ---------------------------------------------------------------265266/**267 * Reads whether the WebAuthn PRF extension can be used on this client:268 * `unavailable` without WebAuthn, `unknown` when the client cannot report269 * capabilities, otherwise the client's own answer.270 */271export async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {272 if (!webAuthn) return "unavailable";273 const publicKeyCredential = PublicKeyCredential as ClientCapabilities;274 if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";275 try {276 const capabilities = await publicKeyCredential.getClientCapabilities();277 return capabilities["extension:prf"] === true ? "available" : "not-reported";278 } catch {279 return "unknown";280 }281}282283/** Reports what the current device/browser/origin can do for credential auth. */284export async function getAuthCapability(285 dependencies: AuthDependencies = {},286): Promise<AuthCapability> {287 const webAuthn = typeof PublicKeyCredential === "function";288 return {289 webAuthn,290 prf: await readPrfSupport(webAuthn),291 origin: currentOrigin(dependencies.trustedOrigins),292 };293}294295// --- Enroll / authenticate ----------------------------------------------------296297/**298 * Enrolls a resident, user-verifying device passkey with the PRF extension299 * enabled. Refuses with an `AuthError("origin-rejected")` unless the current300 * origin is `stable` or a loopback `local-only` host (pass `rpId` to test).301 * The returned `portable` flag says whether the credential roams across devices;302 * `label` names it in the user's password manager. Returns the opaque id.303 */304export async function enrollDeviceCredential(305 options: EnrollOptions = {},306): Promise<DeviceCredential> {307 const app = getLofiApp();308 const rpId = requireStableRpId(options);309 const credentials = options.credentials ?? browserCredentials();310 const label = options.label ?? app.name;311 try {312 const credential = await credentials.create({313 publicKey: {314 rp: { id: rpId, name: app.name },315 user: { id: randomBytes(), name: label, displayName: label },316 challenge: randomBytes(),317 pubKeyCredParams: [318 { type: "public-key", alg: -7 },319 { type: "public-key", alg: -257 },320 ],321 authenticatorSelection: {322 residentKey: "required",323 requireResidentKey: true,324 userVerification: "required",325 },326 attestation: "none",327 timeout: 60_000,328 extensions: { prf: {} } as CreateExtensions,329 },330 });331 return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };332 } catch (error) {333 throw mapError(error);334 }335}336337/** Options for {@link authenticateDeviceCredential}. */338export type AuthenticateOptions = AuthDependencies & {339 /**340 * The base64url id of the one enrolled credential this ceremony must assert.341 * When set, the request pins `allowCredentials` to it and the returned342 * assertion is verified against it — any other credential for the same RP ID343 * fails with `credential-mismatch` instead of passing as the enrolled one.344 */345 credentialId?: string;346};347348/**349 * Authenticates with a device passkey (user-verifying). Pass `credentialId` to350 * pin the ceremony to one enrolled credential; without it the request is351 * discoverable and any resident credential for the RP ID can assert.352 */353export async function authenticateDeviceCredential(354 options: AuthenticateOptions = {},355): Promise<DeviceCredential> {356 const rpId = requireStableRpId(options);357 const credentials = options.credentials ?? browserCredentials();358 const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId359 ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]360 : undefined;361 try {362 const credential = await credentials.get({363 publicKey: {364 rpId,365 challenge: randomBytes(),366 userVerification: "required",367 timeout: 60_000,368 ...(allowCredentials ? { allowCredentials } : {}),369 },370 });371 const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };372 // Verify the assertion even though `allowCredentials` was sent: the browser373 // allow-list is a request hint, not a caller-side guarantee.374 if (options.credentialId && asserted.id !== options.credentialId) {375 throw new AuthError(376 "credential-mismatch",377 "The asserting passkey is not the enrolled credential.",378 );379 }380 return asserted;381 } catch (error) {382 throw mapError(error);383 }384}385386/**387 * Authenticates and derives the PRF secret in one user-verifying ceremony:388 * the PRF evaluation rides the assertion's extensions, so a flow that needs389 * both the asserted credential and a credential-bound key costs one prompt,390 * not two. Pass `credentialId` to pin the ceremony to one enrolled391 * credential. Throws `prf-unavailable` when the client or authenticator392 * returns no PRF result — the secret is never faked — and393 * `credential-mismatch` when a different credential asserts than the one394 * pinned.395 */396export async function authenticateAndDerivePrfSecret(397 salt: BufferSource,398 options: AuthenticateOptions = {},399): Promise<{ credential: DeviceCredential; secret: Uint8Array }> {400 const rpId = requireStableRpId(options);401 const credentials = options.credentials ?? browserCredentials();402 const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId403 ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]404 : undefined;405 try {406 const credential = await credentials.get({407 publicKey: {408 rpId,409 challenge: randomBytes(),410 userVerification: "required",411 timeout: 60_000,412 ...(allowCredentials ? { allowCredentials } : {}),413 extensions: { prf: { eval: { first: salt } } } as RequestExtensions,414 },415 });416 const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };417 // Verify the assertion even though `allowCredentials` was sent: the browser418 // allow-list is a request hint, not a caller-side guarantee.419 if (options.credentialId && asserted.id !== options.credentialId) {420 throw new AuthError(421 "credential-mismatch",422 "The asserting passkey is not the enrolled credential.",423 );424 }425 const results =426 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))427 .getClientExtensionResults?.().prf?.results;428 if (!results?.first) throw new AuthError("prf-unavailable");429 // A copy, not a view: callers zero the secret after deriving from it, and430 // that must never reach through to the extension-result buffer.431 return { credential: asserted, secret: new Uint8Array(results.first.slice(0)) };432 } catch (error) {433 throw mapError(error);434 }435}436437// --- PRF-derived at-rest key --------------------------------------------------438439/**440 * Derives a credential-bound secret from the passkey via the WebAuthn PRF441 * extension in one user-verifying `get()`. The same `salt` on the same442 * authenticator yields the same 32-byte secret; it never leaves the device.443 * Throws `prf-unavailable` if the client or authenticator does not return a444 * PRF result — the secret is never faked.445 */446export async function derivePrfSecret(447 salt: BufferSource,448 dependencies: AuthDependencies = {},449): Promise<Uint8Array> {450 const rpId = requireStableRpId(dependencies);451 const credentials = dependencies.credentials ?? browserCredentials();452 try {453 const credential = await credentials.get({454 publicKey: {455 rpId,456 challenge: randomBytes(),457 userVerification: "required",458 timeout: 60_000,459 extensions: { prf: { eval: { first: salt } } } as RequestExtensions,460 },461 });462 const results =463 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))464 .getClientExtensionResults?.().prf?.results;465 if (!results?.first) throw new AuthError("prf-unavailable");466 return new Uint8Array(results.first);467 } catch (error) {468 throw mapError(error);469 }470}471472/**473 * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.474 * Bind unrelated data to different keys by varying `info`.475 */476export async function deriveAtRestKey(477 prfSecret: Uint8Array,478 info: string,479 salt: Uint8Array = new Uint8Array(0),480): Promise<CryptoKey> {481 const material = await crypto.subtle.importKey(482 "raw",483 prfSecret as BufferSource,484 "HKDF",485 false,486 ["deriveKey"],487 );488 return await crypto.subtle.deriveKey(489 {490 name: "HKDF",491 hash: "SHA-256",492 salt: salt as BufferSource,493 info: new TextEncoder().encode(info),494 },495 material,496 { name: "AES-GCM", length: 256 },497 false,498 ["encrypt", "decrypt"],499 );500}501502/** Encrypts `plaintext` with an at-rest key, returning a self-contained blob. */503export async function encryptAtRest(504 key: CryptoKey,505 plaintext: Uint8Array,506): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {507 const iv = randomBytes(12);508 const ciphertext = await crypto.subtle.encrypt(509 { name: "AES-GCM", iv },510 key,511 plaintext as BufferSource,512 );513 return { iv, ciphertext: new Uint8Array(ciphertext) };514}515516/** Decrypts a blob produced by {@link encryptAtRest}. */517export async function decryptAtRest(518 key: CryptoKey,519 blob: { iv: Uint8Array; ciphertext: Uint8Array },520): Promise<Uint8Array> {521 const plaintext = await crypto.subtle.decrypt(522 { name: "AES-GCM", iv: blob.iv as BufferSource },523 key,524 blob.ciphertext as BufferSource,525 );526 return new Uint8Array(plaintext);527}528529// Account identity is not derived from a credential in lofi. The account is a530// local-first secret (see `session.ts`), backed up by a recovery phrase and,531// optionally, protected at rest by the PRF key above. Deriving the *account*532// from a passkey cannot preserve a pre-existing local-only account's data,533// since a derived key is a different account. This module stays a534// device-credential + at-rest-key primitive, nothing more.