clone

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

feat(auth): WebAuthn/PRF device-credential primitive with PRF-derived at-rest encryption

Dami committed bae2ca1
Standard-library auth for local-first lofi apps: identity is device-local and
cryptographic, with no central store to authenticate against.

- src/_lofi/auth.ts (runtime-owned): getAuthCapability; enroll/authenticate a
  resident, user-verifying passkey gated on a stable-origin RP-ID; derivePrfSecret
  (WebAuthn PRF) + deriveAtRestKey (HKDF-SHA-256 -> AES-GCM) + encrypt/decrypt helpers
- honest + feature-detected: a missing PRF result throws instead of faking a key,
  enrollment is refused off a stable HTTPS origin, and it makes no cross-device
  recovery claim (a lost device is lost access)
- the origin/RP-ID gate is decoupled from the removed Deno Tunnel path
- app.ts gains author-owned credentialOrigins config
- @nzip/lofi/testing: CDP virtual-authenticator helper (enroll/authenticate/RP-ID;
  CDP does not model PRF, so PRF is covered by browser-free unit tests)
- opt-in tests/auth_e2e_test.ts example; auth_test.ts unit tests via injected fakes
- docs/auth.md design note (primitive, not an identity/recovery scheme)
commit
bae2ca16e949d00811a3bf601e2f35819da1d066
parent
ff88666dd98198808821494dad5875b99c2735f0
author
Dami <dami.etoile@gmail.com>
9 files changed, +864 -2
Aapps/reference/src/_lofi/auth.ts368++++++++++++++++++++++++++++++
Aapps/reference/src/_lofi/auth_test.ts209+++++++++++++++++
Mapps/reference/src/app.ts4+
Aapps/reference/tests/auth_e2e_test.ts125++++++++++
Adocs/auth.md72++++++
Mpackage/starter_template.ts3+
Mpackage/testdata/starter.snapshot.json5+-
Mpackage/testing/mod.ts8+-
Apackage/testing/webauthn.ts72++++++
A apps/reference/src/_lofi/auth.ts +368 -0
@@ -0,0 +1,368 @@
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+ */16+17+import { referenceApp } from "../app.ts";18+19+/** Whether the WebAuthn PRF extension can be used on this client. */20+export type PrfSupport = "available" | "not-reported" | "unknown" | "unavailable";21+22+/**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 gated on `stable`.26+ */27+export type CredentialOriginReport = {28+ status: "stable" | "local-only" | "unverified" | "blocked";29+ /** The relying-party id (origin hostname) a credential would bind to. */30+ rpId: string;31+ /** Human-readable next step for the current status. */32+ action: string;33+};34+35+/** What the current device/browser/origin can do for credential auth. */36+export type AuthCapability = {37+ webAuthn: boolean;38+ prf: PrfSupport;39+ origin: CredentialOriginReport;40+};41+42+/** An enrolled or authenticated device credential. */43+export type DeviceCredential = {44+ /** The opaque, base64url-encoded credential id. Safe to persist and compare. */45+ id: string;46+ /** The relying-party id the credential is bound to. */47+ rpId: string;48+};49+50+/** Injected browser surfaces, so the flows are unit-testable without a device. */51+export type AuthDependencies = {52+ credentials?: CredentialsContainer;53+ rpId?: string;54+ trustedOrigins?: readonly string[];55+};56+57+/** A precise, non-leaking failure reason for a credential operation. */58+export class AuthError extends Error {59+ override readonly name = "AuthError";60+ readonly code:61+ | "cancelled"62+ | "origin-rejected"63+ | "unsupported"64+ | "prf-unavailable"65+ | "credential-missing"66+ | "unknown";67+68+ constructor(code: AuthError["code"], message?: string) {69+ super(message ?? `Device credential operation failed: ${code}.`);70+ this.code = code;71+ }72+}73+74+// WebAuthn PRF extension types are not in the DOM lib yet; declare the minimum.75+type PrfInputs = { eval?: { first: BufferSource; second?: BufferSource } };76+type PrfResults = { results?: { first?: ArrayBuffer; second?: ArrayBuffer } };77+type CreateExtensions = PublicKeyCredentialCreationOptions["extensions"] & {78+ prf?: Record<never, never>;79+};80+type RequestExtensions = PublicKeyCredentialRequestOptions["extensions"] & { prf?: PrfInputs };81+type ClientCapabilities = typeof PublicKeyCredential & {82+ getClientCapabilities?: () => Promise<Record<string, boolean>>;83+};84+85+function toBase64Url(bytes: Uint8Array): string {86+ let binary = "";87+ for (const byte of bytes) binary += String.fromCharCode(byte);88+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");89+}90+91+function randomBytes(length = 32): Uint8Array<ArrayBuffer> {92+ return crypto.getRandomValues(new Uint8Array(length));93+}94+95+function browserCredentials(): CredentialsContainer {96+ if (typeof navigator === "undefined" || !navigator.credentials) {97+ throw new AuthError("unsupported");98+ }99+ return navigator.credentials;100+}101+102+function rawId(credential: Credential | null): Uint8Array {103+ if (!credential || !("rawId" in credential) || !(credential.rawId instanceof ArrayBuffer)) {104+ throw new AuthError("credential-missing");105+ }106+ return new Uint8Array(credential.rawId);107+}108+109+function mapError(error: unknown): AuthError {110+ if (error instanceof AuthError) return error;111+ const name = typeof error === "object" && error !== null && "name" in error112+ ? String((error as { name: unknown }).name)113+ : "";114+ if (name === "NotAllowedError") return new AuthError("cancelled");115+ if (name === "SecurityError") return new AuthError("origin-rejected");116+ if (name === "NotSupportedError") return new AuthError("unsupported");117+ return new AuthError("unknown", error instanceof Error ? error.message : undefined);118+}119+120+// --- Origin / RP-ID stability -------------------------------------------------121+122+function canonicalHostname(hostname: string): string {123+ return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;124+}125+126+function isLocalHostname(hostname: string): boolean {127+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1";128+}129+130+function isIpAddress(hostname: string): boolean {131+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");132+}133+134+function matchesTrustedOrigin(hostname: string, pattern: string): boolean {135+ const normalized = canonicalHostname(pattern.toLowerCase());136+ if (normalized.startsWith("*.")) {137+ const suffix = normalized.slice(1);138+ return hostname.length > suffix.length && hostname.endsWith(suffix);139+ }140+ return normalized.length > 0 && !normalized.includes("*") && hostname === normalized;141+}142+143+/**144+ * Classifies whether `url` is a safe origin to enroll a device credential on,145+ * against the author's committed-stable hostnames (`app.ts` `credentialOrigins`,146+ * exact hostnames or `*.` suffix patterns). Guidance is platform-agnostic — the147+ * requirement is a stable HTTPS origin you control, however you serve it.148+ */149+export function classifyCredentialOrigin(150+ url: URL,151+ trustedOrigins: readonly string[] = referenceApp.credentialOrigins ?? [],152+): CredentialOriginReport {153+ const rpId = url.hostname;154+ const hostname = canonicalHostname(rpId);155+ if (isLocalHostname(hostname)) {156+ return {157+ status: "local-only",158+ rpId,159+ action:160+ "localhost credentials do not transfer; enroll on the stable HTTPS origin you will ship on",161+ };162+ }163+ if (url.protocol !== "https:" || !hostname || isIpAddress(hostname)) {164+ return {165+ status: "blocked",166+ rpId,167+ action: "serve over a stable HTTPS origin before enrolling a device credential",168+ };169+ }170+ if (trustedOrigins.some((pattern) => matchesTrustedOrigin(hostname, pattern))) {171+ return {172+ status: "stable",173+ rpId,174+ action: "keep this hostname for the lifetime of any device credential",175+ };176+ }177+ return {178+ status: "unverified",179+ rpId,180+ action:181+ "add this hostname to credentialOrigins once you have committed to keeping it permanent",182+ };183+}184+185+function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginReport {186+ if (typeof location === "undefined") {187+ return { status: "blocked", rpId: "", action: "open the application in a browser" };188+ }189+ return classifyCredentialOrigin(new URL(location.href), trustedOrigins);190+}191+192+function requireStableRpId(dependencies: AuthDependencies): string {193+ if (dependencies.rpId) return dependencies.rpId;194+ const origin = currentOrigin(dependencies.trustedOrigins);195+ if (origin.status !== "stable") throw new AuthError("origin-rejected", origin.action);196+ return origin.rpId;197+}198+199+// --- Capability ---------------------------------------------------------------200+201+async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {202+ if (!webAuthn) return "unavailable";203+ const publicKeyCredential = PublicKeyCredential as ClientCapabilities;204+ if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";205+ try {206+ const capabilities = await publicKeyCredential.getClientCapabilities();207+ return capabilities["extension:prf"] === true ? "available" : "not-reported";208+ } catch {209+ return "unknown";210+ }211+}212+213+/** Reports what the current device/browser/origin can do for credential auth. */214+export async function getAuthCapability(215+ dependencies: AuthDependencies = {},216+): Promise<AuthCapability> {217+ const webAuthn = typeof PublicKeyCredential === "function";218+ return {219+ webAuthn,220+ prf: await readPrfSupport(webAuthn),221+ origin: currentOrigin(dependencies.trustedOrigins),222+ };223+}224+225+// --- Enroll / authenticate ----------------------------------------------------226+227+/**228+ * Enrolls a resident, user-verifying device passkey with the PRF extension229+ * enabled. Refuses unless the current origin is `stable` (pass `rpId` to test).230+ * Returns the opaque credential id to persist.231+ */232+export async function enrollDeviceCredential(233+ dependencies: AuthDependencies = {},234+): Promise<DeviceCredential> {235+ const rpId = requireStableRpId(dependencies);236+ const credentials = dependencies.credentials ?? browserCredentials();237+ try {238+ const credential = await credentials.create({239+ publicKey: {240+ rp: { id: rpId, name: referenceApp.name },241+ user: { id: randomBytes(), name: referenceApp.name, displayName: referenceApp.name },242+ challenge: randomBytes(),243+ pubKeyCredParams: [244+ { type: "public-key", alg: -7 },245+ { type: "public-key", alg: -257 },246+ ],247+ authenticatorSelection: {248+ residentKey: "required",249+ requireResidentKey: true,250+ userVerification: "required",251+ },252+ attestation: "none",253+ timeout: 60_000,254+ extensions: { prf: {} } as CreateExtensions,255+ },256+ });257+ return { id: toBase64Url(rawId(credential)), rpId };258+ } catch (error) {259+ throw mapError(error);260+ }261+}262+263+/** Authenticates with a device passkey (discoverable get, user-verifying). */264+export async function authenticateDeviceCredential(265+ dependencies: AuthDependencies = {},266+): Promise<DeviceCredential> {267+ const rpId = requireStableRpId(dependencies);268+ const credentials = dependencies.credentials ?? browserCredentials();269+ try {270+ const credential = await credentials.get({271+ publicKey: { rpId, challenge: randomBytes(), userVerification: "required", timeout: 60_000 },272+ });273+ return { id: toBase64Url(rawId(credential)), rpId };274+ } catch (error) {275+ throw mapError(error);276+ }277+}278+279+// --- PRF-derived at-rest key --------------------------------------------------280+281+/**282+ * Derives a credential-bound secret from the passkey via the WebAuthn PRF283+ * extension. The same `salt` on the same authenticator yields the same 32-byte284+ * secret; it never leaves the device. Throws `prf-unavailable` if the client or285+ * authenticator does not return a PRF result — the secret is never faked.286+ */287+export async function derivePrfSecret(288+ salt: BufferSource,289+ dependencies: AuthDependencies = {},290+): Promise<Uint8Array> {291+ const rpId = requireStableRpId(dependencies);292+ const credentials = dependencies.credentials ?? browserCredentials();293+ try {294+ const credential = await credentials.get({295+ publicKey: {296+ rpId,297+ challenge: randomBytes(),298+ userVerification: "required",299+ timeout: 60_000,300+ extensions: { prf: { eval: { first: salt } } } as RequestExtensions,301+ },302+ });303+ const results =304+ (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))305+ .getClientExtensionResults?.().prf?.results;306+ if (!results?.first) throw new AuthError("prf-unavailable");307+ return new Uint8Array(results.first);308+ } catch (error) {309+ throw mapError(error);310+ }311+}312+313+/**314+ * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.315+ * Bind unrelated data to different keys by varying `info`.316+ */317+export async function deriveAtRestKey(318+ prfSecret: Uint8Array,319+ info: string,320+ salt: Uint8Array = new Uint8Array(0),321+): Promise<CryptoKey> {322+ const material = await crypto.subtle.importKey(323+ "raw",324+ prfSecret as BufferSource,325+ "HKDF",326+ false,327+ ["deriveKey"],328+ );329+ return await crypto.subtle.deriveKey(330+ {331+ name: "HKDF",332+ hash: "SHA-256",333+ salt: salt as BufferSource,334+ info: new TextEncoder().encode(info),335+ },336+ material,337+ { name: "AES-GCM", length: 256 },338+ false,339+ ["encrypt", "decrypt"],340+ );341+}342+343+/** Encrypts `plaintext` with an at-rest key, returning a self-contained blob. */344+export async function encryptAtRest(345+ key: CryptoKey,346+ plaintext: Uint8Array,347+): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {348+ const iv = randomBytes(12);349+ const ciphertext = await crypto.subtle.encrypt(350+ { name: "AES-GCM", iv },351+ key,352+ plaintext as BufferSource,353+ );354+ return { iv, ciphertext: new Uint8Array(ciphertext) };355+}356+357+/** Decrypts a blob produced by {@link encryptAtRest}. */358+export async function decryptAtRest(359+ key: CryptoKey,360+ blob: { iv: Uint8Array; ciphertext: Uint8Array },361+): Promise<Uint8Array> {362+ const plaintext = await crypto.subtle.decrypt(363+ { name: "AES-GCM", iv: blob.iv as BufferSource },364+ key,365+ blob.ciphertext as BufferSource,366+ );367+ return new Uint8Array(plaintext);368+}
A apps/reference/src/_lofi/auth_test.ts +209 -0
@@ -0,0 +1,209 @@
1+import {2+ authenticateDeviceCredential,3+ AuthError,4+ classifyCredentialOrigin,5+ decryptAtRest,6+ deriveAtRestKey,7+ derivePrfSecret,8+ encryptAtRest,9+ enrollDeviceCredential,10+} from "./auth.ts";11+import { assert } from "./test-assert.ts";12+13+const test = (globalThis as unknown as {14+ Deno: { test(name: string, body: () => void | Promise<void>): void };15+}).Deno.test;16+17+// A base64url-decoding rawId ("AQIDBA" is bytes 1,2,3,4) shared by the fakes.18+function rawIdBuffer(): ArrayBuffer {19+ return new Uint8Array([1, 2, 3, 4]).buffer;20+}21+22+// A minimal fake CredentialsContainer whose create/get are supplied per test.23+function fakeCredentials(handlers: {24+ create?: (options: CredentialCreationOptions) => Promise<Credential | null>;25+ get?: (options: CredentialRequestOptions) => Promise<Credential | null>;26+}): CredentialsContainer {27+ return {28+ create: handlers.create ?? (() => Promise.resolve(null)),29+ get: handlers.get ?? (() => Promise.resolve(null)),30+ store: () => Promise.reject(new Error("unused")),31+ preventSilentAccess: () => Promise.resolve(),32+ } as unknown as CredentialsContainer;33+}34+35+async function expectAuthError(36+ operation: () => Promise<unknown>,37+ code: AuthError["code"],38+): Promise<void> {39+ try {40+ await operation();41+ } catch (error) {42+ assert(error instanceof AuthError, "expected an AuthError to be thrown");43+ assert(44+ error.code === code,45+ `expected AuthError code ${code}, received ${(error as AuthError).code}`,46+ );47+ return;48+ }49+ throw new Error(`expected AuthError code ${code}, but nothing was thrown`);50+}51+52+test("classifyCredentialOrigin treats localhost as local-only", () => {53+ const report = classifyCredentialOrigin(new URL("https://localhost"), []);54+ assert(report.status === "local-only", "localhost must be local-only");55+ assert(report.rpId === "localhost", "the rpId must be the hostname");56+});57+58+test("classifyCredentialOrigin blocks insecure, IP, and loopback origins", () => {59+ assert(60+ classifyCredentialOrigin(new URL("http://example.com"), ["example.com"]).status === "blocked",61+ "non-https must be blocked",62+ );63+ assert(64+ classifyCredentialOrigin(new URL("https://93.184.216.34"), []).status === "blocked",65+ "a bare IPv4 origin must be blocked",66+ );67+ assert(68+ classifyCredentialOrigin(new URL("https://127.0.0.1"), []).status === "local-only",69+ "the loopback address is local-only, never a stable RP-ID",70+ );71+});72+73+test("classifyCredentialOrigin trusts exact and wildcard hostnames", () => {74+ assert(75+ classifyCredentialOrigin(new URL("https://app.example.com"), ["*.example.com"]).status ===76+ "stable",77+ "a host matching a *. pattern must be stable",78+ );79+ assert(80+ classifyCredentialOrigin(new URL("https://example.com"), ["example.com"]).status === "stable",81+ "an exact hostname match must be stable",82+ );83+});84+85+test("classifyCredentialOrigin leaves untrusted secure hosts unverified", () => {86+ const report = classifyCredentialOrigin(new URL("https://other.example.org"), ["example.com"]);87+ assert(report.status === "unverified", "a secure host not in the list must be unverified");88+});89+90+test("enrollDeviceCredential returns a base64url DeviceCredential with an explicit rpId", async () => {91+ let createCalls = 0;92+ const credentials = fakeCredentials({93+ create: () => {94+ createCalls += 1;95+ return Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential);96+ },97+ });98+ const credential = await enrollDeviceCredential({ credentials, rpId: "app.example.com" });99+ assert(createCalls === 1, "create must be invoked exactly once");100+ assert(credential.rpId === "app.example.com", "the credential must carry the supplied rpId");101+ // base64url of bytes [1,2,3,4] is "AQIDBA".102+ assert(credential.id === "AQIDBA", "the id must be the base64url of the rawId");103+});104+105+test("enrollDeviceCredential rejects an unstable origin as origin-rejected", async () => {106+ // With no rpId, no trusted origin, and no `location` in Deno, the origin gate107+ // reports "blocked", so enrollment must refuse before touching credentials.108+ const credentials = fakeCredentials({109+ create: () => Promise.reject(new Error("create must not be reached")),110+ });111+ await expectAuthError(112+ () => enrollDeviceCredential({ credentials, trustedOrigins: [] }),113+ "origin-rejected",114+ );115+});116+117+test("authenticateDeviceCredential returns the DeviceCredential from a get result", async () => {118+ const credentials = fakeCredentials({119+ get: () => Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential),120+ });121+ const credential = await authenticateDeviceCredential({ credentials, rpId: "app.example.com" });122+ assert(credential.id === "AQIDBA", "the authenticated id must be the base64url rawId");123+ assert(credential.rpId === "app.example.com", "the credential must carry the rpId");124+});125+126+test("derivePrfSecret returns the PRF result bytes", async () => {127+ const secret = crypto.getRandomValues(new Uint8Array(32));128+ const credentials = fakeCredentials({129+ get: () =>130+ Promise.resolve({131+ rawId: rawIdBuffer(),132+ getClientExtensionResults: () => ({ prf: { results: { first: secret.buffer } } }),133+ } as unknown as Credential),134+ });135+ const derived = await derivePrfSecret(new Uint8Array([9, 9, 9, 9]), {136+ credentials,137+ rpId: "app.example.com",138+ });139+ assert(derived.length === 32, "the derived secret must be 32 bytes");140+ assert(141+ derived.every((byte, index) => byte === secret[index]),142+ "the derived secret must equal the PRF result bytes",143+ );144+});145+146+test("derivePrfSecret throws prf-unavailable when the client returns no PRF result", async () => {147+ const credentials = fakeCredentials({148+ get: () =>149+ Promise.resolve({150+ rawId: rawIdBuffer(),151+ getClientExtensionResults: () => ({}),152+ } as unknown as Credential),153+ });154+ await expectAuthError(155+ () => derivePrfSecret(new Uint8Array([1]), { credentials, rpId: "app.example.com" }),156+ "prf-unavailable",157+ );158+});159+160+test("at-rest crypto round-trips and binds ciphertext to the info string", async () => {161+ const secret = crypto.getRandomValues(new Uint8Array(32));162+ const key = await deriveAtRestKey(secret, "notes");163+ const plaintext = new TextEncoder().encode("local-first secret");164+165+ const blob = await encryptAtRest(key, plaintext);166+ const roundTripped = await decryptAtRest(key, blob);167+ assert(168+ new TextDecoder().decode(roundTripped) === "local-first secret",169+ "the same key must decrypt its own ciphertext",170+ );171+172+ // The same secret + info re-derives a key that also decrypts the prior blob.173+ const sameKey = await deriveAtRestKey(secret, "notes");174+ const again = await decryptAtRest(sameKey, blob);175+ assert(176+ new TextDecoder().decode(again) === "local-first secret",177+ "the same secret+info must re-derive a working key",178+ );179+180+ // A different info yields a different key that cannot decrypt the blob.181+ const otherKey = await deriveAtRestKey(secret, "photos");182+ let rejected = false;183+ try {184+ await decryptAtRest(otherKey, blob);185+ } catch {186+ rejected = true;187+ }188+ assert(rejected, "a key derived with a different info must fail to decrypt");189+});190+191+test("enrollDeviceCredential maps NotAllowedError to cancelled", async () => {192+ const credentials = fakeCredentials({193+ create: () => Promise.reject({ name: "NotAllowedError" }),194+ });195+ await expectAuthError(196+ () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),197+ "cancelled",198+ );199+});200+201+test("enrollDeviceCredential maps SecurityError to origin-rejected", async () => {202+ const credentials = fakeCredentials({203+ create: () => Promise.reject({ name: "SecurityError" }),204+ });205+ await expectAuthError(206+ () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),207+ "origin-rejected",208+ );209+});
M apps/reference/src/app.ts +4 -0
A apps/reference/tests/auth_e2e_test.ts +125 -0
@@ -0,0 +1,125 @@
1+import type { VirtualAuthenticatorHandle } from "@nzip/lofi/testing";2+3+/**4+ * A worked example of the `@nzip/lofi/testing` WebAuthn helper: install a CDP5+ * virtual authenticator and drive a device-credential enroll -> authenticate6+ * round-trip against the running app.7+ *8+ * This is an opt-in browser gate, not part of `deno task test`:9+ * 1. Serve the app on a stable origin — for example `deno task build` followed10+ * by `deno task preview`, or `deno task dev`. Enrollment is gated on a11+ * stable RP-ID, so add the served hostname to `credentialOrigins` in12+ * `src/app.ts` (127.0.0.1 is treated as local-only and refused).13+ * 2. Point the test at that URL and run it directly:14+ * LOFI_E2E_BASE_URL=https://app.example.com/ deno test -A tests/auth_e2e_test.ts15+ *16+ * Without LOFI_E2E_BASE_URL (or without Chromium installed) it skips, so the17+ * default suite stays fast and never launches a browser.18+ *19+ * NOTE: CDP virtual authenticators do not model the WebAuthn PRF extension, so20+ * this example covers enroll/authenticate only. PRF derivation is feature-21+ * detected in the runtime and must be validated on a real device.22+ */23+24+function e2eBaseURL(): string | undefined {25+ // The default suite runs without --allow-env; treat a denied read as "skip".26+ try {27+ return Deno.env.get("LOFI_E2E_BASE_URL") || undefined;28+ } catch {29+ return undefined;30+ }31+}32+33+// Runs inside the browser page, so it may only touch WebAuthn DOM APIs. A minimal34+// inline enroll -> authenticate round-trip against the page's own origin (its35+// RP-ID); returns the base64url credential id observed on both operations.36+const roundTripCredential = async (37+ rpId: string,38+): Promise<{ enrolled: string; asserted: string }> => {39+ const toBase64Url = (buffer: ArrayBuffer): string => {40+ let binary = "";41+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);42+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");43+ };44+ const randomBytes = (length: number) => crypto.getRandomValues(new Uint8Array(length));45+46+ const created = await navigator.credentials.create({47+ publicKey: {48+ rp: { id: rpId, name: "lofi auth example" },49+ user: { id: randomBytes(16), name: "example", displayName: "example" },50+ challenge: randomBytes(32),51+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],52+ authenticatorSelection: {53+ residentKey: "required",54+ requireResidentKey: true,55+ userVerification: "required",56+ },57+ attestation: "none",58+ timeout: 60_000,59+ },60+ }) as (PublicKeyCredential | null);61+ if (!created) throw new Error("enrollment returned no credential");62+63+ const asserted = await navigator.credentials.get({64+ publicKey: {65+ rpId,66+ challenge: randomBytes(32),67+ userVerification: "required",68+ timeout: 60_000,69+ },70+ }) as (PublicKeyCredential | null);71+ if (!asserted) throw new Error("authentication returned no credential");72+73+ return {74+ enrolled: toBase64Url(created.rawId),75+ asserted: toBase64Url(asserted.rawId),76+ };77+};78+79+Deno.test("a virtual authenticator drives an enroll -> authenticate round-trip", async () => {80+ const baseURL = e2eBaseURL();81+ if (!baseURL) {82+ console.log(83+ "skipping auth example; set LOFI_E2E_BASE_URL to a stable-origin deployment to run it",84+ );85+ return;86+ }87+88+ // Imported lazily so the default suite never loads Playwright.89+ const { chromium } = await import("npm:playwright@1.61.1");90+ const { BrowserUnavailableError, withVirtualAuthenticator } = await import("@nzip/lofi/testing");91+92+ let browser;93+ try {94+ browser = await chromium.launch({ headless: true });95+ } catch (error) {96+ // Match the fixture's missing-Chromium classification and skip cleanly.97+ const message = error instanceof Error ? error.message : String(error);98+ if (99+ /executable doesn't exist|browser executable|playwright install|failed to launch/i.test(100+ message,101+ )102+ ) {103+ console.log(new BrowserUnavailableError({ cause: error }).message);104+ return;105+ }106+ throw error;107+ }108+109+ let authenticator: VirtualAuthenticatorHandle | undefined;110+ try {111+ const context = await browser.newContext({ baseURL });112+ const page = await context.newPage();113+ authenticator = await withVirtualAuthenticator(page);114+ await page.goto(baseURL, { waitUntil: "domcontentloaded" });115+116+ const rpId = new URL(baseURL).hostname;117+ const result = await page.evaluate(roundTripCredential, rpId);118+ if (result.enrolled !== result.asserted) {119+ throw new Error("the authenticated credential id must match the enrolled id");120+ }121+ } finally {122+ await authenticator?.dispose();123+ await browser.close();124+ }125+});
A docs/auth.md +72 -0
@@ -0,0 +1,72 @@
1+# lofi device auth2+3+Status: **primitive validated in reference source; `./auth` subpath deferred to a later milestone**\4+Scope: **device-local WebAuthn + PRF at-rest key derivation**5+6+lofi device auth is a small, honest **primitive**, not a mandated identity or recovery scheme.7+Local-first identity is device-local and cryptographic — there is no central store to authenticate8+against — so this module does exactly three things: enroll a device passkey, authenticate with it,9+and derive a credential-bound key to encrypt data **at rest**. Everything else (accounts, recovery,10+multi-device) is left to the application, deliberately.11+12+The runtime lives at `apps/reference/src/_lofi/auth.ts`.13+14+## What it guarantees15+16+- **Enrollment is gated on a stable origin.** A passkey binds to its origin hostname (its RP-ID); if17+ that origin later changes, the credential silently breaks. `classifyCredentialOrigin` reports the18+ current origin as `stable`, `local-only`, `unverified`, or `blocked`, and enrollment refuses19+ anything but `stable`. Authors declare their committed hostnames in `src/app.ts`20+ `credentialOrigins` (exact, or a `*.` suffix pattern). `localhost` and `127.0.0.1` are21+ `local-only` — useful for development, never a shipping RP-ID.22+- **PRF derives an at-rest key, and is never faked.** The WebAuthn PRF extension yields a 32-byte23+ secret bound to the credential, from which `deriveAtRestKey` produces an AES-GCM key via24+ HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or25+ authenticator does not return a PRF result, the derive path throws `prf-unavailable` rather than26+ inventing a key.27+- **No cross-device recovery claim.** A lost device is lost access — the same custody the28+ device-local identity already has. This is intentional and consistent with the earlier rejection29+ of the Jazz alpha's passkey-backup ceremony: lofi does not smuggle a recovery/escrow model in30+ through the auth primitive.31+32+## Usage33+34+```ts35+import {36+ deriveAtRestKey,37+ derivePrfSecret,38+ encryptAtRest,39+ enrollDeviceCredential,40+ getAuthCapability,41+} from "./_lofi/auth.ts";42+43+// 1. Feature-detect before offering enrollment.44+const capability = await getAuthCapability();45+if (!capability.webAuthn || capability.origin.status !== "stable") {46+ throw new Error(capability.origin.action);47+}48+49+// 2. Enroll a resident, user-verifying device passkey (once per device).50+const credential = await enrollDeviceCredential();51+52+// 3. Derive a credential-bound PRF secret from a stable, app-owned salt.53+const salt = new TextEncoder().encode("notes.v1");54+const prfSecret = await derivePrfSecret(salt);55+56+// 4. Turn the secret into an at-rest key and encrypt. Vary `info` to bind57+// unrelated data to different keys.58+const key = await deriveAtRestKey(prfSecret, "notes");59+const blob = await encryptAtRest(key, new TextEncoder().encode("local-first secret"));60+61+console.log(credential.id, blob.iv.length, blob.ciphertext.length);62+```63+64+## Testing65+66+- **Unit** (`src/_lofi/auth_test.ts`): origin classification, enroll/authenticate, PRF result67+ handling, error mapping, and the HKDF -> AES-GCM round-trip run without a browser by injecting a68+ fake `CredentialsContainer` and an explicit `rpId`.69+- **Browser** (`tests/auth_e2e_test.ts` + `@nzip/lofi/testing`'s `withVirtualAuthenticator`): a CDP70+ virtual authenticator drives an enroll -> authenticate round-trip headless. CDP virtual71+ authenticators do **not** reliably model the PRF extension, so PRF derivation is not exercised72+ there; it is feature-detected and validated on real devices.
M package/starter_template.ts +3 -0
M package/testdata/starter.snapshot.json +4 -1
@@ -7,6 +7,8 @@
77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",88 "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",99 "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10+ "src/_lofi/auth_test.ts": "b0ad5fcc315c947088b9f4d2cc5b0f1c7c3957ca3308adcdb7bfed97f1614c26",11+ "src/_lofi/auth.ts": "b27d25ade797842374d341651ddb68dffcc5d3cf4dd2288cbc357298929119bc",1012 "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1113 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",1214 "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",
@@ -31,7 +33,7 @@
3133 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",3234 "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",3335 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",34- "src/app.ts": "c03faa5219c79940e86f19beaaa94e6b917ec30311a51566b8f16d15886219cb",36+ "src/app.ts": "3d7307fbec76e9647aeb4e087ba1e7710803f302c8ee76db90af4128409437c5",3537 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",3638 "src/islands/TaskList.tsx": "2d6f4c7ed2475f1ce561e4b2c3a4e21210f4b55f0ec3eee6c4a6047f8622e189",3739 "src/islands/use-tasks.ts": "d2a9afa80590d4f38db7cfe0da37e6b7b17c00c1a73d82e06b800f97b5d609af",
@@ -39,6 +41,7 @@
3941 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",4042 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",4143 "src/styles/global.css": "0e66a21e9b83cc115f01567ab8a87f949ce7fff8e42a3c3dc2efdd70cfffb706",44+ "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",4245 "tests/author-boundary_test.ts": "529bbd81977294398f4c48a3616d10cdcf0fe5f7c83b705365ad80124ab1a0f1",4346 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",4447 "tests/testing-contract_test.ts": "4257306060d42566b879d92b3a6c395805b20f53717bee55d670c0ef7a117eec",
M package/testing/mod.ts +7 -1
A package/testing/webauthn.ts +72 -0
@@ -0,0 +1,72 @@
1+/**2+ * A Playwright helper that installs a Chrome DevTools Protocol virtual3+ * authenticator on a page, so WebAuthn enroll/authenticate flows and RP-ID4+ * isolation can be exercised headless without a physical device.5+ *6+ * CDP virtual authenticators do **not** reliably model the WebAuthn PRF7+ * extension, so this helper is for enroll / authenticate / RP-ID coverage only.8+ * PRF derivation is feature-detected in the runtime and must be validated on a9+ * real device; do not assert PRF results against a virtual authenticator.10+ *11+ * @module12+ */13+14+import type { Page } from "npm:playwright@1.61.1";15+16+/** Options for {@link withVirtualAuthenticator}. */17+export interface VirtualAuthenticatorOptions {18+ /** CTAP2 (`ctap2`) or legacy U2F (`u2f`); defaults to `ctap2`. */19+ readonly protocol?: "ctap2" | "u2f";20+ /** The credential transport reported to the page; defaults to `internal`. */21+ readonly transport?: "usb" | "nfc" | "ble" | "cable" | "internal";22+ /** Whether the authenticator stores resident (discoverable) keys; defaults to true. */23+ readonly hasResidentKey?: boolean;24+ /** Whether the authenticator can perform user verification; defaults to true. */25+ readonly hasUserVerification?: boolean;26+ /** Whether the simulated user is already verified; defaults to true. */27+ readonly isUserVerified?: boolean;28+ /** Whether presence/verification is auto-satisfied without a prompt; defaults to true. */29+ readonly automaticPresenceSimulation?: boolean;30+}31+32+/** A handle to an installed virtual authenticator; call {@link dispose} to remove it. */33+export interface VirtualAuthenticatorHandle {34+ /** The CDP-assigned id of the virtual authenticator. */35+ readonly authenticatorId: string;36+ /** Remove the virtual authenticator and detach the CDP session. Idempotent. */37+ dispose(): Promise<void>;38+}39+40+/**41+ * Install a CDP virtual authenticator on `page` and return a handle that removes42+ * it on {@link VirtualAuthenticatorHandle.dispose}. Defaults model an internal,43+ * resident, user-verifying platform authenticator that auto-satisfies presence.44+ */45+export async function withVirtualAuthenticator(46+ page: Page,47+ options: VirtualAuthenticatorOptions = {},48+): Promise<VirtualAuthenticatorHandle> {49+ const session = await page.context().newCDPSession(page);50+ await session.send("WebAuthn.enable");51+ const { authenticatorId } = await session.send("WebAuthn.addVirtualAuthenticator", {52+ options: {53+ protocol: options.protocol ?? "ctap2",54+ transport: options.transport ?? "internal",55+ hasResidentKey: options.hasResidentKey ?? true,56+ hasUserVerification: options.hasUserVerification ?? true,57+ isUserVerified: options.isUserVerified ?? true,58+ automaticPresenceSimulation: options.automaticPresenceSimulation ?? true,59+ },60+ });61+62+ let disposed = false;63+ return {64+ authenticatorId,65+ async dispose(): Promise<void> {66+ if (disposed) return;67+ disposed = true;68+ await session.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });69+ await session.detach();70+ },71+ };72+}