clone

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

Merge pull request #38 from FelineStateMachine/agent/webauthn-prf-auth

FelineStateMachine committed 6f16b4e
WebAuthn/PRF device auth: lofi's standard-library cryptographic auth
commit
6f16b4edd640c113df88ce16fa34362000eac380
parent
ff88666dd98198808821494dad5875b99c2735f0
parent
d820ae9a0fea2fef5667407d1e9fdef10e74481e
author
FelineStateMachine <dami.etoile@gmail.com>
19 files changed, +1956 -77
Mapps/reference/src/_lofi/DeviceStatus.tsx183++++++++---
Aapps/reference/src/_lofi/auth.ts490++++++++++++++++++++++++++++++
Aapps/reference/src/_lofi/auth_test.ts357++++++++++++++++++++++
Mapps/reference/src/_lofi/runtime.ts19+-
Aapps/reference/src/_lofi/session.ts179+++++++++++
Mapps/reference/src/app.ts14+-
Aapps/reference/src/islands/AccountGate.tsx214+++++++++++++
Mapps/reference/src/islands/TaskList.tsx26+-
Mapps/reference/src/islands/use-tasks.ts14+-
Mapps/reference/src/pages/index.astro17+-
Mapps/reference/src/styles/global.css110++++++-
Aapps/reference/tests/auth_e2e_test.ts125++++++++
Adocs/auth-identity.md105++++++
Adocs/auth.md72++++
Mpackage/commands/dev.ts4+-
Mpackage/starter_template.ts5+
Mpackage/testdata/starter.snapshot.json19+-
Mpackage/testing/mod.ts8+-
Apackage/testing/webauthn.ts72++++
M apps/reference/src/_lofi/DeviceStatus.tsx +134 -49
@@ -1,63 +1,148 @@
1+import { useEffect, useState } from "preact/hooks";2+import { referenceApp } from "../app.ts";3+import { serverUrl } from "./config.ts";14 import { useDeviceCapabilities } from "./use-device-capabilities.ts";25 import { settleUiMutation } from "./ui-mutation.ts";6+import { getPwaState, type PwaState, subscribePwaState } from "./pwa.ts";7+import { runtimeRecreatedEvent } from "./runtime.ts";8+import { readSession, type Session } from "./session.ts";9+10+/**11+ * The Device gate: every subsystem's live status, grouped by the system it12+ * belongs to. It doubles as a map of where to hook in — each category names the13+ * module that owns it, so a reading of the deployment is also a reading of the14+ * framework's seams.15+ */16+17+// One label/value row inside a category's definition list.18+function Row({ label, value }: { label: string; value: string }) {19+ return (20+ <div>21+ <dt>{label}</dt>22+ <dd>{value}</dd>23+ </div>24+ );25+}26+27+const available = (present: boolean) => (present ? "available" : "missing");328 429 export default function DeviceStatus() {530 const { report, requestPersistence } = useDeviceCapabilities();6- if (!report) return <p class="device-status">Checking durable-storage capabilities…</p>;31+ const [pwa, setPwa] = useState<PwaState>(getPwaState());32+ const [session, setSession] = useState<Session | null>(null);33+34+ useEffect(() => subscribePwaState(setPwa), []);35+ useEffect(() => {36+ let active = true;37+ const refresh = () => {38+ void readSession().then((next) => {39+ if (active) setSession(next);40+ });41+ };42+ refresh();43+ // Re-read after a sign-in / sign-out recreates the runtime.44+ globalThis.addEventListener(runtimeRecreatedEvent, refresh);45+ return () => {46+ active = false;47+ globalThis.removeEventListener(runtimeRecreatedEvent, refresh);48+ };49+ }, []);50+51+ if (!report) return <p class="device-status">Checking device capabilities…</p>;52+53+ const passkey = referenceApp.identity === "device-passkey";54+ const synced = Boolean(serverUrl);55+ const origin = session?.capability?.origin;56+ const portability = !passkey57+ ? "stays on this device"58+ : session?.profile59+ ? (session.profile.portable ? "roams across your devices" : "this device only")60+ : "known once you sign in";761 862 return (963 <section class="device-status" aria-labelledby="device-status-title">1064 <header>1165 <p class="eyebrow">Device gate</p>12- <h2 id="device-status-title">13- {report.durableDriverSupported ? "Durable driver supported" : "Durable driver blocked"}14- </h2>66+ <h2 id="device-status-title">Deployment status &amp; integration points</h2>1567 </header>16- <dl>17- <div>18- <dt>Context</dt>19- <dd>{report.secureContext ? "secure" : "not secure"}</dd>20- </div>21- <div>22- <dt>OPFS</dt>23- <dd>{report.opfs ? "available" : "missing"}</dd>24- </div>25- <div>26- <dt>SharedWorker</dt>27- <dd>{report.sharedWorker ? "available" : "missing"}</dd>28- </div>29- <div>30- <dt>Web Locks</dt>31- <dd>{report.webLocks ? "available" : "missing"}</dd>32- </div>33- <div>34- <dt>Storage persistence</dt>35- <dd>{report.persistentPermission}</dd>36- </div>37- <div>38- <dt>Display mode</dt>39- <dd>{report.displayMode}</dd>40- </div>41- <div>42- <dt>Identity</dt>43- <dd>device-local key</dd>44- </div>45- <div>46- <dt>Passkey backup</dt>47- <dd>blocked by alpha security review</dd>48- </div>49- </dl>50- <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>51- Request storage persistence52- </button>53- <p>54- OPFS capability and eviction protection are separate. A declined persistence request does55- not mean the Jazz driver is using memory.56- </p>57- <p>58- Clearing site data destroys the device-local identity unless a separate recovery mechanism59- has been verified. This reference does not claim passkey recovery.60- </p>68+69+ <div class="device-category">70+ <h3>Shared worker &amp; durable storage</h3>71+ <p class="device-hook">72+ Hook in: <code>src/_lofi/device-capabilities.ts</code>, <code>runtime.ts</code>73+ </p>74+ <dl>75+ <Row76+ label="Durable driver"77+ value={report.durableDriverSupported ? "supported" : "blocked"}78+ />79+ <Row label="Secure context" value={report.secureContext ? "secure" : "not secure"} />80+ <Row label="OPFS" value={available(report.opfs)} />81+ <Row label="SharedWorker" value={available(report.sharedWorker)} />82+ <Row label="Web Locks" value={available(report.webLocks)} />83+ <Row label="MessageChannel" value={available(report.messageChannel)} />84+ <Row label="Storage persistence" value={report.persistentPermission} />85+ </dl>86+ <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>87+ Request storage persistence88+ </button>89+ <p>90+ OPFS capability and eviction protection are separate. A declined persistence request does91+ not mean the Jazz driver is using memory.92+ </p>93+ </div>94+95+ <div class="device-category">96+ <h3>Data sync</h3>97+ <p class="device-hook">98+ Hook in: <code>src/_lofi/config.ts</code>, <code>runtime.ts</code>99+ </p>100+ <dl>101+ <Row label="Sync" value={synced ? "syncing to your account" : "local-only"} />102+ <Row label="Managed server" value={synced ? "configured" : "not configured"} />103+ </dl>104+ {!synced && (105+ <p>106+ Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in{" "}107+ <code>.env</code>, then rebuild, to replicate writes to your account.108+ </p>109+ )}110+ </div>111+112+ <div class="device-category">113+ <h3>Auth</h3>114+ <p class="device-hook">115+ Hook in: <code>src/_lofi/auth.ts</code>, <code>session.ts</code>116+ </p>117+ <dl>118+ <Row label="Identity" value={passkey ? "passkey account" : "device-local key"} />119+ {passkey && (120+ <Row121+ label="Status"122+ value={session ? (session.signedIn ? "signed in" : "signed out") : "…"}123+ />124+ )}125+ <Row label="WebAuthn" value={available(report.webAuthn)} />126+ <Row label="PRF (account derivation)" value={report.prf} />127+ {passkey && origin && (128+ <Row label={`Origin (${origin.rpId || "unknown"})`} value={origin.status} />129+ )}130+ {passkey && <Row label="Account portability" value={portability} />}131+ </dl>132+ </div>133+134+ <div class="device-category">135+ <h3>PWA</h3>136+ <p class="device-hook">137+ Hook in: <code>src/_lofi/pwa.ts</code>138+ </p>139+ <dl>140+ <Row label="Display mode" value={report.displayMode} />141+ <Row label="Install" value={pwa.install} />142+ <Row label="Service worker" value={pwa.worker} />143+ </dl>144+ {pwa.failure && <p class="account-error">{pwa.failure.code}: {pwa.failure.message}</p>}145+ </div>61146 </section>62147 );63148 }
A apps/reference/src/_lofi/auth.ts +490 -0
@@ -0,0 +1,490 @@
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+ * Whether the credential is backup-eligible — i.e. it syncs/roams across the50+ * user's devices (a password-manager or platform-synced passkey) rather than51+ * being bound to this one device. With "the key is the account", a portable52+ * credential means the account travels with it; a device-bound one does not.53+ */54+ portable: boolean;55+};56+57+/** Injected browser surfaces, so the flows are unit-testable without a device. */58+export type AuthDependencies = {59+ credentials?: CredentialsContainer;60+ rpId?: string;61+ trustedOrigins?: readonly string[];62+};63+64+/** Options for {@link enrollDeviceCredential}. */65+export type EnrollOptions = AuthDependencies & {66+ /**67+ * A human-readable nickname for the credential. It becomes the passkey's68+ * name/displayName, so the user can identify which account it belongs to in69+ * their password manager or OS passkey list. Defaults to the app name.70+ */71+ label?: string;72+};73+74+/** A precise, non-leaking failure reason for a credential operation. */75+export class AuthError extends Error {76+ override readonly name = "AuthError";77+ readonly code:78+ | "cancelled"79+ | "origin-rejected"80+ | "unsupported"81+ | "prf-unavailable"82+ | "credential-missing"83+ | "unknown";84+85+ constructor(code: AuthError["code"], message?: string) {86+ super(message ?? `Device credential operation failed: ${code}.`);87+ this.code = code;88+ }89+}90+91+// WebAuthn PRF extension types are not in the DOM lib yet; declare the minimum.92+type PrfInputs = { eval?: { first: BufferSource; second?: BufferSource } };93+type PrfResults = { results?: { first?: ArrayBuffer; second?: ArrayBuffer } };94+type CreateExtensions = PublicKeyCredentialCreationOptions["extensions"] & {95+ prf?: Record<never, never>;96+};97+type RequestExtensions = PublicKeyCredentialRequestOptions["extensions"] & { prf?: PrfInputs };98+type ClientCapabilities = typeof PublicKeyCredential & {99+ getClientCapabilities?: () => Promise<Record<string, boolean>>;100+};101+102+function toBase64Url(bytes: Uint8Array): string {103+ let binary = "";104+ for (const byte of bytes) binary += String.fromCharCode(byte);105+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");106+}107+108+function randomBytes(length = 32): Uint8Array<ArrayBuffer> {109+ return crypto.getRandomValues(new Uint8Array(length));110+}111+112+function browserCredentials(): CredentialsContainer {113+ if (typeof navigator === "undefined" || !navigator.credentials) {114+ throw new AuthError("unsupported");115+ }116+ return navigator.credentials;117+}118+119+function rawId(credential: Credential | null): Uint8Array {120+ if (!credential || !("rawId" in credential) || !(credential.rawId instanceof ArrayBuffer)) {121+ throw new AuthError("credential-missing");122+ }123+ return new Uint8Array(credential.rawId);124+}125+126+// Reads the WebAuthn "backup eligible" (BE) flag from the authenticator data of127+// a create() attestation or a get() assertion. BE=1 means the credential can128+// roam/sync across the user's devices. Best-effort: false when unreadable.129+function isPortable(credential: Credential | null): boolean {130+ const response = (credential as131+ | Credential & {132+ response?: { getAuthenticatorData?: () => ArrayBuffer; authenticatorData?: ArrayBuffer };133+ }134+ | null)?.response;135+ const authData = response?.getAuthenticatorData?.() ?? response?.authenticatorData;136+ if (!authData) return false;137+ const bytes = new Uint8Array(authData);138+ return bytes.length >= 33 && (bytes[32] & 0x08) !== 0;139+}140+141+function mapError(error: unknown): AuthError {142+ if (error instanceof AuthError) return error;143+ const name = typeof error === "object" && error !== null && "name" in error144+ ? String((error as { name: unknown }).name)145+ : "";146+ if (name === "NotAllowedError") return new AuthError("cancelled");147+ if (name === "SecurityError") return new AuthError("origin-rejected");148+ if (name === "NotSupportedError") return new AuthError("unsupported");149+ return new AuthError("unknown", error instanceof Error ? error.message : undefined);150+}151+152+// --- Origin / RP-ID stability -------------------------------------------------153+154+function canonicalHostname(hostname: string): string {155+ return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;156+}157+158+function isLocalHostname(hostname: string): boolean {159+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1";160+}161+162+function isIpAddress(hostname: string): boolean {163+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");164+}165+166+function matchesTrustedOrigin(hostname: string, pattern: string): boolean {167+ const normalized = canonicalHostname(pattern.toLowerCase());168+ if (normalized.startsWith("*.")) {169+ const suffix = normalized.slice(1);170+ return hostname.length > suffix.length && hostname.endsWith(suffix);171+ }172+ return normalized.length > 0 && !normalized.includes("*") && hostname === normalized;173+}174+175+/**176+ * Classifies whether `url` is a safe origin to enroll a device credential on,177+ * against the author's committed-stable hostnames (`app.ts` `credentialOrigins`,178+ * exact hostnames or `*.` suffix patterns). Guidance is platform-agnostic — the179+ * requirement is a stable HTTPS origin you control, however you serve it.180+ */181+export function classifyCredentialOrigin(182+ url: URL,183+ trustedOrigins: readonly string[] = referenceApp.credentialOrigins ?? [],184+): CredentialOriginReport {185+ const rpId = url.hostname;186+ const hostname = canonicalHostname(rpId);187+ if (isLocalHostname(hostname)) {188+ return {189+ status: "local-only",190+ rpId,191+ action:192+ "localhost credentials do not transfer; enroll on the stable HTTPS origin you will ship on",193+ };194+ }195+ if (url.protocol !== "https:" || !hostname || isIpAddress(hostname)) {196+ return {197+ status: "blocked",198+ rpId,199+ action: "serve over a stable HTTPS origin before enrolling a device credential",200+ };201+ }202+ if (trustedOrigins.some((pattern) => matchesTrustedOrigin(hostname, pattern))) {203+ return {204+ status: "stable",205+ rpId,206+ action: "keep this hostname for the lifetime of any device credential",207+ };208+ }209+ return {210+ status: "unverified",211+ rpId,212+ action:213+ "add this hostname to credentialOrigins once you have committed to keeping it permanent",214+ };215+}216+217+function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginReport {218+ if (typeof location === "undefined") {219+ return { status: "blocked", rpId: "", action: "open the application in a browser" };220+ }221+ const url = new URL(location.href);222+ const declared = trustedOrigins ?? referenceApp.credentialOrigins ?? [];223+ // Default trust is the origin you are actually served from: the passkey binds224+ // to this hostname (its RP ID) regardless, so trusting it lets the app work225+ // wherever it is deployed. Pin explicit hosts in `app.ts` `credentialOrigins`226+ // to keep enrollment working across a host change — a passkey silently breaks227+ // if its origin moves.228+ const trusted = declared.length > 0 ? declared : [url.hostname];229+ return classifyCredentialOrigin(url, trusted);230+}231+232+function requireStableRpId(dependencies: AuthDependencies): string {233+ if (dependencies.rpId) return dependencies.rpId;234+ const origin = currentOrigin(dependencies.trustedOrigins);235+ // Enroll where you are actually served: a committed HTTPS host (`stable`) or236+ // loopback during local development (`local-only`). Refuse insecure origins237+ // and hosts that an explicit `credentialOrigins` allowlist excludes.238+ if (origin.status !== "stable" && origin.status !== "local-only") {239+ throw new AuthError("origin-rejected", origin.action);240+ }241+ return origin.rpId;242+}243+244+// --- Capability ---------------------------------------------------------------245+246+async function readPrfSupport(webAuthn: boolean): Promise<PrfSupport> {247+ if (!webAuthn) return "unavailable";248+ const publicKeyCredential = PublicKeyCredential as ClientCapabilities;249+ if (typeof publicKeyCredential.getClientCapabilities !== "function") return "unknown";250+ try {251+ const capabilities = await publicKeyCredential.getClientCapabilities();252+ return capabilities["extension:prf"] === true ? "available" : "not-reported";253+ } catch {254+ return "unknown";255+ }256+}257+258+/** Reports what the current device/browser/origin can do for credential auth. */259+export async function getAuthCapability(260+ dependencies: AuthDependencies = {},261+): Promise<AuthCapability> {262+ const webAuthn = typeof PublicKeyCredential === "function";263+ return {264+ webAuthn,265+ prf: await readPrfSupport(webAuthn),266+ origin: currentOrigin(dependencies.trustedOrigins),267+ };268+}269+270+// --- Enroll / authenticate ----------------------------------------------------271+272+/**273+ * Enrolls a resident, user-verifying device passkey with the PRF extension274+ * enabled. Refuses unless the current origin is `stable` (pass `rpId` to test).275+ * The returned `portable` flag says whether the credential roams across devices;276+ * `label` names it in the user's password manager. Returns the opaque id.277+ */278+export async function enrollDeviceCredential(279+ options: EnrollOptions = {},280+): Promise<DeviceCredential> {281+ const rpId = requireStableRpId(options);282+ const credentials = options.credentials ?? browserCredentials();283+ const label = options.label ?? referenceApp.name;284+ try {285+ const credential = await credentials.create({286+ publicKey: {287+ rp: { id: rpId, name: referenceApp.name },288+ user: { id: randomBytes(), name: label, displayName: label },289+ challenge: randomBytes(),290+ pubKeyCredParams: [291+ { type: "public-key", alg: -7 },292+ { type: "public-key", alg: -257 },293+ ],294+ authenticatorSelection: {295+ residentKey: "required",296+ requireResidentKey: true,297+ userVerification: "required",298+ },299+ attestation: "none",300+ timeout: 60_000,301+ extensions: { prf: {} } as CreateExtensions,302+ },303+ });304+ return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };305+ } catch (error) {306+ throw mapError(error);307+ }308+}309+310+/** Authenticates with a device passkey (discoverable get, user-verifying). */311+export async function authenticateDeviceCredential(312+ dependencies: AuthDependencies = {},313+): Promise<DeviceCredential> {314+ const rpId = requireStableRpId(dependencies);315+ const credentials = dependencies.credentials ?? browserCredentials();316+ try {317+ const credential = await credentials.get({318+ publicKey: { rpId, challenge: randomBytes(), userVerification: "required", timeout: 60_000 },319+ });320+ return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };321+ } catch (error) {322+ throw mapError(error);323+ }324+}325+326+// --- PRF-derived at-rest key --------------------------------------------------327+328+// Runs a single user-verifying `get()` with the PRF extension and returns both329+// the PRF secret and the asserting credential's identity, so a caller can derive330+// the account and report which key unlocked it from one ceremony (one prompt).331+async function prfAssertion(332+ salt: BufferSource,333+ dependencies: AuthDependencies,334+): Promise<{ prfSecret: Uint8Array; credential: DeviceCredential }> {335+ const rpId = requireStableRpId(dependencies);336+ const credentials = dependencies.credentials ?? browserCredentials();337+ try {338+ const credential = await credentials.get({339+ publicKey: {340+ rpId,341+ challenge: randomBytes(),342+ userVerification: "required",343+ timeout: 60_000,344+ extensions: { prf: { eval: { first: salt } } } as RequestExtensions,345+ },346+ });347+ const results =348+ (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))349+ .getClientExtensionResults?.().prf?.results;350+ if (!results?.first) throw new AuthError("prf-unavailable");351+ return {352+ prfSecret: new Uint8Array(results.first),353+ credential: { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) },354+ };355+ } catch (error) {356+ throw mapError(error);357+ }358+}359+360+/**361+ * Derives a credential-bound secret from the passkey via the WebAuthn PRF362+ * extension. The same `salt` on the same authenticator yields the same 32-byte363+ * secret; it never leaves the device. Throws `prf-unavailable` if the client or364+ * authenticator does not return a PRF result — the secret is never faked.365+ */366+export async function derivePrfSecret(367+ salt: BufferSource,368+ dependencies: AuthDependencies = {},369+): Promise<Uint8Array> {370+ return (await prfAssertion(salt, dependencies)).prfSecret;371+}372+373+/**374+ * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.375+ * Bind unrelated data to different keys by varying `info`.376+ */377+export async function deriveAtRestKey(378+ prfSecret: Uint8Array,379+ info: string,380+ salt: Uint8Array = new Uint8Array(0),381+): Promise<CryptoKey> {382+ const material = await crypto.subtle.importKey(383+ "raw",384+ prfSecret as BufferSource,385+ "HKDF",386+ false,387+ ["deriveKey"],388+ );389+ return await crypto.subtle.deriveKey(390+ {391+ name: "HKDF",392+ hash: "SHA-256",393+ salt: salt as BufferSource,394+ info: new TextEncoder().encode(info),395+ },396+ material,397+ { name: "AES-GCM", length: 256 },398+ false,399+ ["encrypt", "decrypt"],400+ );401+}402+403+/** Encrypts `plaintext` with an at-rest key, returning a self-contained blob. */404+export async function encryptAtRest(405+ key: CryptoKey,406+ plaintext: Uint8Array,407+): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {408+ const iv = randomBytes(12);409+ const ciphertext = await crypto.subtle.encrypt(410+ { name: "AES-GCM", iv },411+ key,412+ plaintext as BufferSource,413+ );414+ return { iv, ciphertext: new Uint8Array(ciphertext) };415+}416+417+/** Decrypts a blob produced by {@link encryptAtRest}. */418+export async function decryptAtRest(419+ key: CryptoKey,420+ blob: { iv: Uint8Array; ciphertext: Uint8Array },421+): Promise<Uint8Array> {422+ const plaintext = await crypto.subtle.decrypt(423+ { name: "AES-GCM", iv: blob.iv as BufferSource },424+ key,425+ blob.ciphertext as BufferSource,426+ );427+ return new Uint8Array(plaintext);428+}429+430+// --- Account identity ("the key is the account") -----------------------------431+432+// A fixed salt so the same credential always derives the same account for lofi.433+// App scoping comes from the credential itself (bound to the app's origin).434+const ACCOUNT_SECRET_SALT = new TextEncoder().encode("lofi:account-secret:v1");435+436+/**437+ * Derives the account secret **deterministically** from the passkey via PRF, in438+ * Jazz's 32-byte base64url auth-secret format. The same portable credential439+ * reconstructs the *same* account on any device — so the key is the account, and440+ * the account lives wherever the key does. Nothing is stored server-side and no441+ * recovery is implied: without the key, the account is gone.442+ *443+ * Requires a `stable` origin and a PRF-capable client; throws `origin-rejected`444+ * or `prf-unavailable` otherwise (never a fabricated secret). Feed the result to445+ * Jazz as the account secret (e.g. via `BrowserAuthSecretStore.saveSecret`).446+ */447+export async function deriveAuthSecret(dependencies: AuthDependencies = {}): Promise<string> {448+ const prfSecret = await derivePrfSecret(ACCOUNT_SECRET_SALT, dependencies);449+ return await hkdfAccountSecret(prfSecret);450+}451+452+// HKDF-SHA-256 expands the raw PRF secret into Jazz's 32-byte base64url453+// auth-secret format. Kept separate so both `deriveAuthSecret` and454+// `deriveAccount` produce byte-identical secrets from the same PRF result.455+async function hkdfAccountSecret(prfSecret: Uint8Array): Promise<string> {456+ const material = await crypto.subtle.importKey(457+ "raw",458+ prfSecret as BufferSource,459+ "HKDF",460+ false,461+ ["deriveBits"],462+ );463+ const bits = await crypto.subtle.deriveBits(464+ {465+ name: "HKDF",466+ hash: "SHA-256",467+ salt: new Uint8Array(0),468+ info: new TextEncoder().encode("lofi:account-secret"),469+ },470+ material,471+ 256,472+ );473+ return toBase64Url(new Uint8Array(bits));474+}475+476+/** An account secret paired with the credential that unlocked it. */477+export type PasskeyAccount = { secret: string; credential: DeviceCredential };478+479+/**480+ * Signs in with a passkey in **one** ceremony: derives the deterministic Jazz481+ * account secret (as {@link deriveAuthSecret}) and reports the asserting482+ * credential (its `rpId` and whether it is `portable`), so the UI can show which483+ * key unlocked the account and whether it roams. Same guarantees and failures as484+ * {@link deriveAuthSecret}: a `stable`/`local-only` origin and a PRF result, or a485+ * thrown `origin-rejected` / `prf-unavailable` — never a fabricated secret.486+ */487+export async function deriveAccount(dependencies: AuthDependencies = {}): Promise<PasskeyAccount> {488+ const { prfSecret, credential } = await prfAssertion(ACCOUNT_SECRET_SALT, dependencies);489+ return { secret: await hkdfAccountSecret(prfSecret), credential };490+}
A apps/reference/src/_lofi/auth_test.ts +357 -0
@@ -0,0 +1,357 @@
1+import {2+ authenticateDeviceCredential,3+ AuthError,4+ classifyCredentialOrigin,5+ decryptAtRest,6+ deriveAccount,7+ deriveAtRestKey,8+ deriveAuthSecret,9+ derivePrfSecret,10+ encryptAtRest,11+ enrollDeviceCredential,12+} from "./auth.ts";13+import { assert } from "./test-assert.ts";14+15+const test = (globalThis as unknown as {16+ Deno: { test(name: string, body: () => void | Promise<void>): void };17+}).Deno.test;18+19+// A base64url-decoding rawId ("AQIDBA" is bytes 1,2,3,4) shared by the fakes.20+function rawIdBuffer(): ArrayBuffer {21+ return new Uint8Array([1, 2, 3, 4]).buffer;22+}23+24+// A 37-byte authenticator-data buffer (rpIdHash[32] + flags + signCount[4]).25+// The WebAuthn "backup eligible" (BE) flag is bit 0x08 of byte index 32.26+function authenticatorData(backupEligible: boolean): ArrayBuffer {27+ const bytes = new Uint8Array(37);28+ if (backupEligible) bytes[32] |= 0x08;29+ return bytes.buffer;30+}31+32+// A minimal fake CredentialsContainer whose create/get are supplied per test.33+function fakeCredentials(handlers: {34+ create?: (options: CredentialCreationOptions) => Promise<Credential | null>;35+ get?: (options: CredentialRequestOptions) => Promise<Credential | null>;36+}): CredentialsContainer {37+ return {38+ create: handlers.create ?? (() => Promise.resolve(null)),39+ get: handlers.get ?? (() => Promise.resolve(null)),40+ store: () => Promise.reject(new Error("unused")),41+ preventSilentAccess: () => Promise.resolve(),42+ } as unknown as CredentialsContainer;43+}44+45+async function expectAuthError(46+ operation: () => Promise<unknown>,47+ code: AuthError["code"],48+): Promise<void> {49+ try {50+ await operation();51+ } catch (error) {52+ assert(error instanceof AuthError, "expected an AuthError to be thrown");53+ assert(54+ error.code === code,55+ `expected AuthError code ${code}, received ${(error as AuthError).code}`,56+ );57+ return;58+ }59+ throw new Error(`expected AuthError code ${code}, but nothing was thrown`);60+}61+62+test("classifyCredentialOrigin treats localhost as local-only", () => {63+ const report = classifyCredentialOrigin(new URL("https://localhost"), []);64+ assert(report.status === "local-only", "localhost must be local-only");65+ assert(report.rpId === "localhost", "the rpId must be the hostname");66+});67+68+test("classifyCredentialOrigin blocks insecure, IP, and loopback origins", () => {69+ assert(70+ classifyCredentialOrigin(new URL("http://example.com"), ["example.com"]).status === "blocked",71+ "non-https must be blocked",72+ );73+ assert(74+ classifyCredentialOrigin(new URL("https://93.184.216.34"), []).status === "blocked",75+ "a bare IPv4 origin must be blocked",76+ );77+ assert(78+ classifyCredentialOrigin(new URL("https://127.0.0.1"), []).status === "local-only",79+ "the loopback address is local-only, never a stable RP-ID",80+ );81+});82+83+test("classifyCredentialOrigin trusts exact and wildcard hostnames", () => {84+ assert(85+ classifyCredentialOrigin(new URL("https://app.example.com"), ["*.example.com"]).status ===86+ "stable",87+ "a host matching a *. pattern must be stable",88+ );89+ assert(90+ classifyCredentialOrigin(new URL("https://example.com"), ["example.com"]).status === "stable",91+ "an exact hostname match must be stable",92+ );93+});94+95+test("classifyCredentialOrigin leaves untrusted secure hosts unverified", () => {96+ const report = classifyCredentialOrigin(new URL("https://other.example.org"), ["example.com"]);97+ assert(report.status === "unverified", "a secure host not in the list must be unverified");98+});99+100+test("enrollDeviceCredential returns a base64url DeviceCredential with an explicit rpId", async () => {101+ let createCalls = 0;102+ const credentials = fakeCredentials({103+ create: () => {104+ createCalls += 1;105+ return Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential);106+ },107+ });108+ const credential = await enrollDeviceCredential({ credentials, rpId: "app.example.com" });109+ assert(createCalls === 1, "create must be invoked exactly once");110+ assert(credential.rpId === "app.example.com", "the credential must carry the supplied rpId");111+ // base64url of bytes [1,2,3,4] is "AQIDBA".112+ assert(credential.id === "AQIDBA", "the id must be the base64url of the rawId");113+});114+115+test("enrollDeviceCredential rejects an unstable origin as origin-rejected", async () => {116+ // With no rpId, no trusted origin, and no `location` in Deno, the origin gate117+ // reports "blocked", so enrollment must refuse before touching credentials.118+ const credentials = fakeCredentials({119+ create: () => Promise.reject(new Error("create must not be reached")),120+ });121+ await expectAuthError(122+ () => enrollDeviceCredential({ credentials, trustedOrigins: [] }),123+ "origin-rejected",124+ );125+});126+127+test("authenticateDeviceCredential returns the DeviceCredential from a get result", async () => {128+ const credentials = fakeCredentials({129+ get: () => Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential),130+ });131+ const credential = await authenticateDeviceCredential({ credentials, rpId: "app.example.com" });132+ assert(credential.id === "AQIDBA", "the authenticated id must be the base64url rawId");133+ assert(credential.rpId === "app.example.com", "the credential must carry the rpId");134+});135+136+test("derivePrfSecret returns the PRF result bytes", async () => {137+ const secret = crypto.getRandomValues(new Uint8Array(32));138+ const credentials = fakeCredentials({139+ get: () =>140+ Promise.resolve({141+ rawId: rawIdBuffer(),142+ getClientExtensionResults: () => ({ prf: { results: { first: secret.buffer } } }),143+ } as unknown as Credential),144+ });145+ const derived = await derivePrfSecret(new Uint8Array([9, 9, 9, 9]), {146+ credentials,147+ rpId: "app.example.com",148+ });149+ assert(derived.length === 32, "the derived secret must be 32 bytes");150+ assert(151+ derived.every((byte, index) => byte === secret[index]),152+ "the derived secret must equal the PRF result bytes",153+ );154+});155+156+test("derivePrfSecret throws prf-unavailable when the client returns no PRF result", async () => {157+ const credentials = fakeCredentials({158+ get: () =>159+ Promise.resolve({160+ rawId: rawIdBuffer(),161+ getClientExtensionResults: () => ({}),162+ } as unknown as Credential),163+ });164+ await expectAuthError(165+ () => derivePrfSecret(new Uint8Array([1]), { credentials, rpId: "app.example.com" }),166+ "prf-unavailable",167+ );168+});169+170+test("at-rest crypto round-trips and binds ciphertext to the info string", async () => {171+ const secret = crypto.getRandomValues(new Uint8Array(32));172+ const key = await deriveAtRestKey(secret, "notes");173+ const plaintext = new TextEncoder().encode("local-first secret");174+175+ const blob = await encryptAtRest(key, plaintext);176+ const roundTripped = await decryptAtRest(key, blob);177+ assert(178+ new TextDecoder().decode(roundTripped) === "local-first secret",179+ "the same key must decrypt its own ciphertext",180+ );181+182+ // The same secret + info re-derives a key that also decrypts the prior blob.183+ const sameKey = await deriveAtRestKey(secret, "notes");184+ const again = await decryptAtRest(sameKey, blob);185+ assert(186+ new TextDecoder().decode(again) === "local-first secret",187+ "the same secret+info must re-derive a working key",188+ );189+190+ // A different info yields a different key that cannot decrypt the blob.191+ const otherKey = await deriveAtRestKey(secret, "photos");192+ let rejected = false;193+ try {194+ await decryptAtRest(otherKey, blob);195+ } catch {196+ rejected = true;197+ }198+ assert(rejected, "a key derived with a different info must fail to decrypt");199+});200+201+test("enrollDeviceCredential maps NotAllowedError to cancelled", async () => {202+ const credentials = fakeCredentials({203+ create: () => Promise.reject({ name: "NotAllowedError" }),204+ });205+ await expectAuthError(206+ () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),207+ "cancelled",208+ );209+});210+211+test("enrollDeviceCredential maps SecurityError to origin-rejected", async () => {212+ const credentials = fakeCredentials({213+ create: () => Promise.reject({ name: "SecurityError" }),214+ });215+ await expectAuthError(216+ () => enrollDeviceCredential({ credentials, rpId: "app.example.com" }),217+ "origin-rejected",218+ );219+});220+221+test("enrollDeviceCredential names the passkey with the label and reports portable", async () => {222+ let captured: PublicKeyCredentialUserEntity | undefined;223+ const credentials = fakeCredentials({224+ create: (options) => {225+ captured = options.publicKey?.user;226+ return Promise.resolve({227+ rawId: rawIdBuffer(),228+ response: { getAuthenticatorData: () => authenticatorData(true) },229+ } as unknown as Credential);230+ },231+ });232+ const credential = await enrollDeviceCredential({233+ rpId: "app.example.com",234+ label: "work account",235+ credentials,236+ });237+ assert(captured?.name === "work account", "the label must become user.name");238+ assert(captured?.displayName === "work account", "the label must become user.displayName");239+ assert(credential.portable === true, "a backup-eligible credential must be portable");240+});241+242+test("enrollDeviceCredential reports portable false without the BE flag or authenticator data", async () => {243+ const clearBit = fakeCredentials({244+ create: () =>245+ Promise.resolve({246+ rawId: rawIdBuffer(),247+ response: { getAuthenticatorData: () => authenticatorData(false) },248+ } as unknown as Credential),249+ });250+ const cleared = await enrollDeviceCredential({ rpId: "app.example.com", credentials: clearBit });251+ assert(cleared.portable === false, "a device-bound credential must not be portable");252+253+ const noAuthData = fakeCredentials({254+ create: () =>255+ Promise.resolve({256+ rawId: rawIdBuffer(),257+ response: {},258+ } as unknown as Credential),259+ });260+ const missing = await enrollDeviceCredential({261+ rpId: "app.example.com",262+ credentials: noAuthData,263+ });264+ assert(missing.portable === false, "an unreadable credential must default to not portable");265+});266+267+test("deriveAuthSecret derives a deterministic Jazz auth-secret from the PRF result", async () => {268+ const first = crypto.getRandomValues(new Uint8Array(32));269+ const credentials = fakeCredentials({270+ get: () =>271+ Promise.resolve({272+ rawId: rawIdBuffer(),273+ getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),274+ } as unknown as Credential),275+ });276+ const secret = await deriveAuthSecret({ credentials, rpId: "app.example.com" });277+ assert(secret.length === 43, "a 32-byte base64url secret must be 43 characters");278+ assert(/^[A-Za-z0-9_-]+$/.test(secret), "the secret must be base64url with no padding");279+280+ const decoded = atob(secret.replaceAll("-", "+").replaceAll("_", "/"));281+ assert(decoded.length === 32, "the secret must decode to 32 bytes");282+283+ const again = await deriveAuthSecret({ credentials, rpId: "app.example.com" });284+ assert(again === secret, "the same PRF input must derive the same secret");285+286+ const other = crypto.getRandomValues(new Uint8Array(32));287+ const otherCredentials = fakeCredentials({288+ get: () =>289+ Promise.resolve({290+ rawId: rawIdBuffer(),291+ getClientExtensionResults: () => ({ prf: { results: { first: other.buffer } } }),292+ } as unknown as Credential),293+ });294+ const otherSecret = await deriveAuthSecret({295+ credentials: otherCredentials,296+ rpId: "app.example.com",297+ });298+ assert(otherSecret !== secret, "a different PRF input must derive a different secret");299+});300+301+test("deriveAuthSecret throws prf-unavailable when the client returns no PRF result", async () => {302+ const credentials = fakeCredentials({303+ get: () =>304+ Promise.resolve({305+ rawId: rawIdBuffer(),306+ getClientExtensionResults: () => ({}),307+ } as unknown as Credential),308+ });309+ await expectAuthError(310+ () => deriveAuthSecret({ credentials, rpId: "app.example.com" }),311+ "prf-unavailable",312+ );313+});314+315+test("deriveAccount returns the account secret and the asserting credential", async () => {316+ const first = crypto.getRandomValues(new Uint8Array(32));317+ const credentials = fakeCredentials({318+ get: () =>319+ Promise.resolve({320+ rawId: rawIdBuffer(),321+ response: { getAuthenticatorData: () => authenticatorData(true) },322+ getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),323+ } as unknown as Credential),324+ });325+ const account = await deriveAccount({ credentials, rpId: "app.example.com" });326+327+ // The secret must equal what deriveAuthSecret derives from the same PRF result,328+ // so a device that enrolled via one path signs in via the other to one account.329+ const viaAuthSecret = await deriveAuthSecret({330+ credentials: fakeCredentials({331+ get: () =>332+ Promise.resolve({333+ rawId: rawIdBuffer(),334+ getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),335+ } as unknown as Credential),336+ }),337+ rpId: "app.example.com",338+ });339+ assert(account.secret === viaAuthSecret, "deriveAccount must match deriveAuthSecret's secret");340+ assert(account.credential.id === "AQIDBA", "the credential id must be the base64url rawId");341+ assert(account.credential.rpId === "app.example.com", "the credential must carry the rpId");342+ assert(account.credential.portable === true, "the BE flag must surface as portable");343+});344+345+test("deriveAccount throws prf-unavailable when the client returns no PRF result", async () => {346+ const credentials = fakeCredentials({347+ get: () =>348+ Promise.resolve({349+ rawId: rawIdBuffer(),350+ getClientExtensionResults: () => ({}),351+ } as unknown as Credential),352+ });353+ await expectAuthError(354+ () => deriveAccount({ credentials, rpId: "app.example.com" }),355+ "prf-unavailable",356+ );357+});
M apps/reference/src/_lofi/runtime.ts +18 -1
@@ -1,4 +1,6 @@
11 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";2+import { referenceApp } from "../app.ts";3+import { AuthError } from "./auth.ts";24 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";35 import { appId, databaseConfig, serverUrl } from "./config.ts";46 import { assertDurableBrowser } from "./device-capabilities.ts";
@@ -52,10 +54,25 @@ function notifyDiagnostics(state = slot()): void {
5254 for (const listener of state.diagnosticListeners) listener();5355 }5456 57+// "device-local": a random per-device secret, created on demand — identity58+// never waits. "device-passkey" (default): the passkey is the account, so the59+// secret is derived from the credential's PRF and cached. Booting the database60+// waits for that cached secret; the auth gate (see `session.ts`) runs the61+// passkey ceremony on a user gesture, saves the secret, and recreates the62+// runtime. Return boots find the cached secret and open without a ceremony.63+async function resolveAccountSecret(): Promise<string> {64+ if (referenceApp.identity !== "device-passkey") {65+ return await BrowserAuthSecretStore.getOrCreateSecret({ appId });66+ }67+ const cached = await BrowserAuthSecretStore.loadSecret({ appId });68+ if (cached) return cached;69+ throw new AuthError("credential-missing", "Sign in with your passkey to open your account.");70+}71+5572 async function createClient(state: RuntimeSlot): Promise<Db> {5673 try {5774 assertDurableBrowser();58- const secret = await BrowserAuthSecretStore.getOrCreateSecret({ appId });75+ const secret = await resolveAccountSecret();5976 const db = await createDb(databaseConfig(secret));6077 state.diagnostics.storageState = "persistent-driver-open";6178 state.diagnostics.clientsCreated += 1;
A apps/reference/src/_lofi/session.ts +179 -0
@@ -0,0 +1,179 @@
1+/**2+ * Account session for the `device-passkey` identity: the glue between the3+ * WebAuthn primitive in `auth.ts` and the runtime's account secret.4+ *5+ * "The key is the account", so signing in *is* deriving the account secret from6+ * the passkey (a user-gesture WebAuthn ceremony), caching it, and recreating the7+ * runtime so data opens. Signing out forgets the cached secret on this device —8+ * it never deletes the passkey and makes no recovery claim. When identity is9+ * `device-local`, there is nothing to sign into and {@link readSession} always10+ * reports signed-in.11+ *12+ * The small, honest UI that drives this lives in author space13+ * (`src/islands/AccountGate.tsx`) — this module is the framework side.14+ *15+ * @module16+ */17+18+import { BrowserAuthSecretStore } from "jazz-tools";19+import { referenceApp } from "../app.ts";20+import { appId, serverUrl } from "./config.ts";21+import {22+ type AuthCapability,23+ AuthError,24+ deriveAccount,25+ type DeviceCredential,26+ enrollDeviceCredential,27+ getAuthCapability,28+} from "./auth.ts";29+import { recreateRuntime, runtimeRecreatedEvent, shutdownRuntime } from "./runtime.ts";30+31+/** A signed-in identity, remembered locally so the status UI can name the key. */32+export type AccountProfile = {33+ /** The relying-party id (origin hostname) the account's passkey is bound to. */34+ rpId: string;35+ /** Whether the passkey roams across devices (backup-eligible). */36+ portable: boolean;37+ /** The nickname the passkey was enrolled with. */38+ label: string;39+};40+41+/** A snapshot of the account gate: what to render and what the client can do. */42+export type Session = {43+ /** The configured identity model (`app.ts`). */44+ identity: "device-local" | "device-passkey";45+ /** Whether this identity requires a passkey sign-in before data opens. */46+ requiresPasskey: boolean;47+ /** Whether an account secret is available (data can open). */48+ signedIn: boolean;49+ /** Whether writes replicate to a managed account (cloud configured). */50+ syncing: boolean;51+ /** Device/browser/origin capability, or `null` for `device-local`. */52+ capability: AuthCapability | null;53+ /** The remembered account on this device, if any. */54+ profile: AccountProfile | null;55+};56+57+const profileKey = `lofi:account-profile:${appId}`;58+59+function loadProfile(): AccountProfile | null {60+ if (typeof localStorage === "undefined") return null;61+ try {62+ const raw = localStorage.getItem(profileKey);63+ if (!raw) return null;64+ const parsed = JSON.parse(raw) as Partial<AccountProfile>;65+ if (typeof parsed?.rpId !== "string") return null;66+ return {67+ rpId: parsed.rpId,68+ portable: parsed.portable === true,69+ label: typeof parsed.label === "string" ? parsed.label : referenceApp.name,70+ };71+ } catch {72+ return null;73+ }74+}75+76+function saveProfile(profile: AccountProfile): void {77+ try {78+ localStorage?.setItem(profileKey, JSON.stringify(profile));79+ } catch {80+ // A private-mode storage failure must not fail the sign-in itself.81+ }82+}83+84+function clearProfile(): void {85+ try {86+ localStorage?.removeItem(profileKey);87+ } catch {88+ // Nothing to clean up if storage is unavailable.89+ }90+}91+92+/** Reads the current session without prompting for any credential. */93+export async function readSession(): Promise<Session> {94+ const identity = referenceApp.identity;95+ const syncing = Boolean(serverUrl);96+ if (identity !== "device-passkey") {97+ return {98+ identity,99+ requiresPasskey: false,100+ signedIn: true,101+ syncing,102+ capability: null,103+ profile: null,104+ };105+ }106+ const [capability, cached] = await Promise.all([107+ getAuthCapability(),108+ BrowserAuthSecretStore.loadSecret({ appId }),109+ ]);110+ return {111+ identity,112+ requiresPasskey: true,113+ signedIn: Boolean(cached),114+ syncing,115+ capability,116+ profile: loadProfile(),117+ };118+}119+120+// Caches the derived secret, remembers the credential, and reopens the runtime121+// so every consumer reconnects against the now-available account.122+async function activate(123+ credential: DeviceCredential,124+ secret: string,125+ label: string,126+): Promise<Session> {127+ await BrowserAuthSecretStore.saveSecret(secret, { appId });128+ saveProfile({ rpId: credential.rpId, portable: credential.portable, label });129+ await recreateRuntime();130+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));131+ return await readSession();132+}133+134+/**135+ * Registers a brand-new passkey and derives the account it represents. Use this136+ * for a first-time account; the `label` names the key in the user's password137+ * manager. Prefer a portable (roaming) passkey so the account reaches other138+ * devices — {@link Session.profile} reports which kind was created.139+ */140+export async function createPasskeyAccount(label: string = referenceApp.name): Promise<Session> {141+ // create() reports backup-eligibility from its attestation; the follow-up142+ // derive uses PRF (a get()), which is the portable way to obtain the secret.143+ const enrolled = await enrollDeviceCredential({ label });144+ const account = await deriveAccount();145+ return await activate(146+ { ...account.credential, portable: enrolled.portable },147+ account.secret,148+ label,149+ );150+}151+152+/**153+ * Signs in with an existing passkey in a single ceremony — the key reconstructs154+ * the same account it always derives. Throws `prf-unavailable` if the client155+ * cannot do PRF and `cancelled` if the user dismisses the prompt.156+ */157+export async function signInWithPasskey(): Promise<Session> {158+ const account = await deriveAccount();159+ const label = loadProfile()?.label ?? referenceApp.name;160+ return await activate(account.credential, account.secret, label);161+}162+163+/**164+ * Forgets the cached account on this device and returns to the signed-out gate.165+ * The passkey itself is untouched — signing in again re-derives the same166+ * account. There is no server-side account, so nothing else is revoked.167+ */168+export async function signOut(): Promise<Session> {169+ await shutdownRuntime();170+ await BrowserAuthSecretStore.clearSecret({ appId });171+ clearProfile();172+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));173+ return await readSession();174+}175+176+/** True when an error means "no account secret yet" — render the sign-in gate. */177+export function isSignedOut(error: unknown): boolean {178+ return error instanceof AuthError && error.code === "credential-missing";179+}
M apps/reference/src/app.ts +13 -1
A apps/reference/src/islands/AccountGate.tsx +214 -0
@@ -0,0 +1,214 @@
1+import { useCallback, useEffect, useState } from "preact/hooks";2+import { referenceApp } from "../app.ts";3+import { AuthError } from "../_lofi/auth.ts";4+import {5+ createPasskeyAccount,6+ readSession,7+ type Session,8+ signInWithPasskey,9+ signOut,10+} from "../_lofi/session.ts";11+12+/**13+ * Author-owned auth example. With `identity: "device-passkey"` (the default),14+ * the passkey *is* the account: signing in derives the Jazz account secret from15+ * the key, so the same passkey opens the same data on every device it reaches.16+ *17+ * This whole island is one pattern — gate the app on a user-gesture sign-in and18+ * show which key you are signed in with. It uses only the primitives in19+ * `src/_lofi/session.ts`; nothing under `src/_lofi/` needs to change. Delete it20+ * (and set `identity: "device-local"`) if you do not want passkey accounts.21+ */22+23+// Turns an AuthError code into a sentence a person can act on.24+function describe(error: unknown): string {25+ if (error instanceof AuthError) {26+ switch (error.code) {27+ case "cancelled":28+ return "Passkey prompt dismissed. Try again when you are ready.";29+ case "prf-unavailable":30+ return "This browser or key cannot derive an account (no WebAuthn PRF). Try a platform passkey in a current browser.";31+ case "origin-rejected":32+ return error.message;33+ case "unsupported":34+ return "This browser does not support passkeys.";35+ case "credential-missing":36+ return "No passkey was returned. Try again, or create an account.";37+ default:38+ return error.message;39+ }40+ }41+ return error instanceof Error ? error.message : String(error);42+}43+44+export default function AccountGate() {45+ const [session, setSession] = useState<Session | null>(null);46+ const [busy, setBusy] = useState<"create" | "signin" | "signout" | null>(null);47+ const [error, setError] = useState<string | null>(null);48+ // The nickname the user gives their passkey — it becomes the credential's name49+ // in their password manager, so they can tell which account a key unlocks.50+ const [label, setLabel] = useState("");51+52+ useEffect(() => {53+ let active = true;54+ void readSession().then((next) => {55+ if (active) setSession(next);56+ });57+ return () => {58+ active = false;59+ };60+ }, []);61+62+ const run = useCallback(63+ (kind: "create" | "signin" | "signout", action: () => Promise<Session>) => {64+ setBusy(kind);65+ setError(null);66+ void action().then((next) => {67+ setSession(next);68+ }, (cause) => {69+ setError(describe(cause));70+ }).finally(() => setBusy(null));71+ },72+ [],73+ );74+75+ // Nothing to sign into under device-local identity; DeviceStatus reports it.76+ if (!session || !session.requiresPasskey) return null;77+78+ const { signedIn, syncing, capability, profile } = session;79+ const prf = capability?.prf ?? "unknown";80+ const origin = capability?.origin;81+ const webAuthn = capability?.webAuthn ?? false;82+ const prfBlocked = prf === "unavailable";83+ const disabled = busy !== null || !webAuthn;84+85+ if (signedIn) {86+ return (87+ <section class="account account-in" aria-labelledby="account-title">88+ <header>89+ <p class="eyebrow">Account</p>90+ <h2 id="account-title">Signed in</h2>91+ </header>92+ <dl>93+ <div>94+ <dt>Key</dt>95+ <dd>{profile?.label ?? "passkey"}</dd>96+ </div>97+ <div>98+ <dt>Bound to</dt>99+ <dd>{profile?.rpId ?? origin?.rpId ?? "this origin"}</dd>100+ </div>101+ <div>102+ <dt>Portability</dt>103+ <dd>104+ {profile105+ ? (profile.portable ? "roams across your devices" : "this device only")106+ : "unknown"}107+ </dd>108+ </div>109+ <div>110+ <dt>Sync</dt>111+ <dd>{syncing ? "syncing to your account" : "local-only"}</dd>112+ </div>113+ </dl>114+ <button115+ type="button"116+ class="account-secondary"117+ disabled={busy !== null}118+ onClick={() => run("signout", signOut)}119+ >120+ {busy === "signout" ? "Signing out…" : "Sign out"}121+ </button>122+ <p>123+ Enable passkey identity in your own app in three steps: (1) set{" "}124+ <code>identity: "device-passkey"</code> in <code>src/app.ts</code>{" "}125+ (the default); (2) list your permanent hostname(s) in <code>credentialOrigins</code>{" "}126+ before shipping — leave it empty and the served origin is trusted; (3) render this{" "}127+ <code>AccountGate</code> island and drive sign-in with <code>createPasskeyAccount</code> /128+ {" "}129+ <code>signInWithPasskey</code> from <code>src/_lofi/session.ts</code>.130+ </p>131+ </section>132+ );133+ }134+135+ return (136+ <section class="account account-out" aria-labelledby="account-title">137+ <header>138+ <p class="eyebrow">Account</p>139+ <h2 id="account-title">Sign in with a passkey</h2>140+ </header>141+ <p>142+ Your passkey is your account. Create one, or sign in with an existing one — the same key143+ reaches the same data on every device it can.144+ </p>145+ <div class="account-field">146+ <label for="passkey-label">Name this passkey</label>147+ <input148+ id="passkey-label"149+ value={label}150+ onInput={(event) => setLabel(event.currentTarget.value)}151+ placeholder={referenceApp.name}152+ autocomplete="off"153+ disabled={busy !== null}154+ />155+ <p class="account-note">Shown in your password manager. Defaults to the app name.</p>156+ </div>157+ <div class="account-actions">158+ <button159+ type="button"160+ disabled={disabled}161+ onClick={() => run("create", () => createPasskeyAccount(label.trim() || undefined))}162+ >163+ {busy === "create" ? "Creating…" : "Create passkey account"}164+ </button>165+ <button166+ type="button"167+ class="account-secondary"168+ disabled={disabled}169+ onClick={() => run("signin", signInWithPasskey)}170+ >171+ {busy === "signin" ? "Signing in…" : "Sign in with existing passkey"}172+ </button>173+ </div>174+ {error && <p class="account-error" role="alert">{error}</p>}175+ <dl class="account-caps">176+ <div>177+ <dt>Passkeys</dt>178+ <dd>{webAuthn ? "supported" : "unsupported in this browser"}</dd>179+ </div>180+ <div>181+ <dt>PRF (account derivation)</dt>182+ <dd>183+ {prf === "available"184+ ? "available"185+ : prf === "not-reported"186+ ? "not reported — will be tried"187+ : prf === "unavailable"188+ ? "unavailable"189+ : "unknown until you try"}190+ </dd>191+ </div>192+ {origin && (193+ <div>194+ <dt>Origin ({origin.rpId || "unknown"})</dt>195+ <dd>{origin.status}{origin.status !== "stable" ? ` — ${origin.action}` : ""}</dd>196+ </div>197+ )}198+ </dl>199+ {prfBlocked && (200+ <p class="account-note">201+ PRF is unavailable here, so account derivation cannot run — sign-in will report it rather202+ than invent a key.203+ </p>204+ )}205+ {origin?.status === "unverified" && (206+ <p class="account-note">207+ This origin is trusted by default so the app works wherever it is served, but a passkey208+ enrolled here breaks if the host changes. Pin a permanent host in{" "}209+ <code>credentialOrigins</code> (in <code>src/app.ts</code>) before shipping.210+ </p>211+ )}212+ </section>213+ );214+}
M apps/reference/src/islands/TaskList.tsx +23 -3
M apps/reference/src/islands/use-tasks.ts +13 -1
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
22 import { schema as s } from "jazz-tools";33 import { referenceApp } from "../app.ts";44 import { getRuntime, runtimeRecreatedEvent } from "../_lofi/runtime.ts";5+import { isSignedOut } from "../_lofi/session.ts";56 import type { TableSnapshot } from "../_lofi/table-store.ts";67 78 /**
@@ -24,6 +25,11 @@ const initial: TableSnapshot<Task> = {
2425 2526 export function useTasks() {2627 const [snapshot, setSnapshot] = useState(initial);28+ // With `identity: "device-passkey"`, the runtime refuses to open until the29+ // user signs in. That is not an error — it is the signed-out gate, so the UI30+ // prompts to sign in instead of reporting a failed write. Signing in dispatches31+ // `runtimeRecreatedEvent`, which reconnects us below.32+ const [signedOut, setSignedOut] = useState(false);2733 2834 useEffect(() => {2935 let cancelled = false;
@@ -34,6 +40,7 @@ export function useTasks() {
3440 unsubscribe?.();3541 unsubscribe = undefined;3642 setSnapshot(initial);43+ setSignedOut(false);3744 void getRuntime().then((runtime) => {3845 if (cancelled || generation !== connectionGeneration) return;3946 const store = runtime.store(tasksTable);
@@ -42,6 +49,11 @@ export function useTasks() {
4249 update();4350 }, (error) => {4451 if (cancelled || generation !== connectionGeneration) return;52+ if (isSignedOut(error)) {53+ setSignedOut(true);54+ setSnapshot(initial);55+ return;56+ }4557 setSnapshot({4658 ...initial,4759 status: "error",
@@ -70,5 +82,5 @@ export function useTasks() {
7082 await (await store()).update(id, { completed });7183 }, [store]);7284 73- return { ...snapshot, tasks: snapshot.rows, create, setCompleted };85+ return { ...snapshot, signedOut, tasks: snapshot.rows, create, setCompleted };7486 }
M apps/reference/src/pages/index.astro +9 -8
M apps/reference/src/styles/global.css +105 -5
@@ -93,6 +93,31 @@ h1 {
9393 margin: 0.25rem 0 0;9494 }9595 96+.device-status h2 {97+ margin-bottom: 0.5rem;98+}99+100+.device-category {101+ border-top: 1px solid #3b594c;102+ margin-top: 1.25rem;103+ padding-top: 1rem;104+}105+106+.device-category h3 {107+ font-size: 1.05rem;108+ margin: 0 0 0.15rem;109+}110+111+.device-hook {112+ color: #9bc9b1;113+ font-size: 0.8rem;114+ margin: 0 0 0.75rem;115+}116+117+.device-hook code {118+ color: #d9efe2;119+}120+96121 label {97122 display: block;98123 font-weight: 750;
@@ -176,18 +201,89 @@ ul {
176201 text-decoration: line-through;177202 }178203 179-.identity-note {204+.account {180205 background: #1d2b25;181206 border: 1px solid #3b594c;182207 border-radius: 0.7rem;183208 color: #bed0c6;184209 line-height: 1.6;185- margin-top: 1rem;186- padding: 1rem 1.4rem;210+ margin-top: 3rem;211+ padding: 1.4rem;187212 }188213 189-.identity-note strong {190- color: #eff8f2;214+.account-out {215+ border-color: #68927e;216+}217+218+.account h2 {219+ margin: 0.25rem 0 1rem;220+}221+222+.account-field {223+ margin: 0.75rem 0 0.25rem;224+}225+226+.account-field input {227+ max-width: 24rem;228+ width: 100%;229+}230+231+.account-field .account-note {232+ margin: 0.35rem 0 0;233+}234+235+.account-actions {236+ display: flex;237+ flex-wrap: wrap;238+ gap: 0.6rem;239+ margin: 0.5rem 0 0.25rem;240+}241+242+.account-secondary {243+ background: transparent;244+ color: #d9efe2;245+}246+247+.account button:disabled {248+ cursor: not-allowed;249+ opacity: 0.55;250+}251+252+.account-error {253+ color: #f6b8b0;254+}255+256+.account-note {257+ color: #9bc9b1;258+ font-size: 0.9rem;259+}260+261+.account dl {262+ display: grid;263+ gap: 0.5rem;264+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));265+ margin: 0.5rem 0 0;266+}267+268+.account dl div {269+ background: #18231f;270+ padding: 0.75rem;271+}272+273+.account dt {274+ color: #9bc9b1;275+ font-size: 0.75rem;276+ font-weight: 800;277+ text-transform: uppercase;278+}279+280+.account dd {281+ margin: 0.25rem 0 0;282+ overflow-wrap: anywhere;283+}284+285+.account-caps dd {286+ color: #d9efe2;191287 }192288 193289 footer {
@@ -195,6 +291,10 @@ footer {
195291 margin-top: 2rem;196292 }197293 294+footer a {295+ color: #99e5bd;296+}297+198298 @media (max-width: 40rem) {199299 main {200300 padding: 2.5rem 1rem;
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-identity.md +105 -0
@@ -0,0 +1,105 @@
1+# The key is the account2+3+lofi's default identity model: **a passkey _is_ the account.** The Jazz account secret is derived4+from the credential, so the account lives wherever the key lives — and moves between devices exactly5+as far as the key does.6+7+This is the default (`identity: "device-passkey"` in `src/app.ts`). Set `identity: "device-local"`8+for a random, device-bound account with no sign-in (each device gets its own account, and it never9+leaves that device).10+11+## How it works12+13+Jazz's account secret is a 32-byte value (base64url). The WebAuthn **PRF** extension returns a14+32-byte, credential-bound secret that is deterministic for a given `(credential, salt)`. So:15+16+```17+account secret = base64url( HKDF-SHA-256( PRF(passkey, fixed-salt) ) )18+```19+20+The same credential always derives the same account. No account material is stored on any server; it21+is a pure function of the key.22+23+### Boot flow (`device-passkey`, the default)24+25+- **First boot on a device** → the runtime finds no cached secret and stays signed-out; the26+ **AccountGate** island (`src/islands/AccountGate.tsx`) shows a sign-in card. A WebAuthn ceremony27+ needs a user gesture, so this is a button, not an automatic prompt.28+- **Sign in / create account** (a click) → derive the secret from the passkey → hand it to Jazz →29+ cache it locally (`BrowserAuthSecretStore.saveSecret`) → recreate the runtime so data opens.30+- **Return boots** → the cached secret is used; no ceremony. "Identity never waits" holds once you31+ are signed in on a device.32+- **A new device** → sign in with the same portable key → the same PRF → the same secret → the same33+ Jazz account → your data syncs down and decrypts. Jazz already E2E-encrypts synced data keyed on34+ the account, so nothing extra is needed for confidentiality.35+- **Sign out** → forgets the cached secret on this device only (`clearSecret`); the passkey is36+ untouched and signs back into the same account.37+38+The framework side of this lives in `src/_lofi/session.ts` (`readSession`, `createPasskeyAccount`,39+`signInWithPasskey`, `signOut`); the gate is the author-owned example you can restyle or replace.40+41+## Portable vs device-bound — and telling the user which they chose42+43+A passkey binds to its origin (RP ID) and roams (or doesn't) based on the credential provider, not44+the OS. `enrollDeviceCredential` reports this via the WebAuthn **backup-eligible (BE)** flag:45+46+- **Portable** (`portable: true`) — a password-manager or platform-synced passkey (1Password,47+ iCloud, Google) or a hardware key you carry. **The account travels with it.** Favor these.48+- **Device-bound** (`portable: false`) — Touch ID / Windows Hello without sync. **The account only49+ exists on this device.** Allowed, but the app should say so: _use a portable passkey to reach this50+ account elsewhere._51+52+The nickname passed as `label` becomes the passkey's name in the user's password manager, so they53+can see which account a key unlocks.54+55+## Custody and recovery — stated plainly56+57+- **No recovery service.** lofi never holds recoverable account material. Recovery is delegated to58+ _where the key lives_: your password manager's own sync, or a second enrolled hardware key.59+- **Lose every copy of the key → lose the account.** That is the honest cost of no-central-custody,60+ and it is the same custody the device-local identity already has.61+- **This is not the rejected passkey-_backup_ ceremony.** The Jazz alpha's server-assisted passkey62+ account backup was rejected on security grounds. This model has no server-side account backup at63+ all — the account is derived locally from the key — so it sidesteps that entirely.64+65+## Guardrails (honest by construction)66+67+- **Feature-detected.** `getAuthCapability()` reports `webAuthn` + `prf`; if PRF is unavailable,68+ derivation throws `prf-unavailable` rather than fabricating a secret.69+- **Origin-gated.** Enrollment and derivation refuse insecure origins (non-HTTPS, bare IPs) and any70+ host an explicit `credentialOrigins` allowlist excludes — a passkey silently breaks if its origin71+ changes. With no allowlist, the served origin is trusted so the app works wherever you deploy it72+ (see [Origin trust](#origin-trust)).73+74+## Origin trust75+76+A passkey binds to its origin hostname (its RP ID) and silently breaks if that host changes. lofi77+reads the current origin automatically; `credentialOrigins` is a separate promise that a host is78+**permanent**:79+80+```ts81+// src/app.ts82+credentialOrigins: [], // empty → trust the origin you are served from (works anywhere you deploy)83+credentialOrigins: ["app.example.com", "*.example.com"], // pin permanent host(s) before shipping84+```85+86+Left empty, the served origin is trusted so the app works on any deployment, but a passkey enrolled87+there dies if the host later changes (e.g. an ephemeral preview URL). Pin your permanent hostname(s)88+once you have committed to them. Loopback (`localhost`) is trusted for local development but89+reported as `local-only` because such credentials do not transfer between machines.90+91+## Using the primitives directly92+93+The default `device-passkey` wiring is done for you by `src/_lofi/session.ts` and the AccountGate94+island. To drive it yourself:95+96+```ts97+import { deriveAccount, getAuthCapability } from "./_lofi/auth.ts";98+99+const { prf, origin } = await getAuthCapability();100+if (prf !== "unavailable" && origin.status !== "blocked") {101+ // One user-gesture ceremony: derive the Jazz account secret and learn which key unlocked it.102+ const { secret, credential } = await deriveAccount();103+ // credential.portable === true → the account travels with this key104+}105+```
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/commands/dev.ts +3 -1
M package/starter_template.ts +5 -0
M package/testdata/starter.snapshot.json +12 -7
@@ -7,11 +7,13 @@
77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",88 "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",99 "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10+ "src/_lofi/auth_test.ts": "ceaacbb00aa61c4ceea44340dfe4cb68baecf1415b733a21ab45c0ea6c03a0bd",11+ "src/_lofi/auth.ts": "a26e5e515a298ea300ffc88caff807f1d318da113dffd8075b7caf5cdf68743e",1012 "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1113 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",1214 "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",1315 "src/_lofi/device-capabilities.ts": "6911fbe8ce93cdd79531982fdafd505202141985baec874e4dd965b02e6115d3",14- "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",16+ "src/_lofi/DeviceStatus.tsx": "876482b5991e3c5398d92c1452323d330d4765963b38e7ceae394f481861be4e",1517 "src/_lofi/diagnostics.ts": "abb2a5f8e4d6b8ab1a86d475db6c374aecf4580b534ba9d7d850e1dbb29bb7f8",1618 "src/_lofi/foreground-recovery_test.ts": "a5081aecaa70b71c63d721a0875f4d2b3898a49879eb8045c8e74baf0d867444",1719 "src/_lofi/foreground-recovery.ts": "a5ed7a65b9454770de62a4ba8cdac7b0d1a867ec3c703049d2c103791c4d0049",
@@ -23,7 +25,8 @@
2325 "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",2426 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2527 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",26- "src/_lofi/runtime.ts": "db204517acc708f5145f48ed5cbd1ab95c01e35ddedb09dc0ba9617ea8bc1f79",28+ "src/_lofi/runtime.ts": "58975d41c648de6ecddeee11a1755a02aadc9490f5c609a58bce9f7fe540c6a6",29+ "src/_lofi/session.ts": "4ae10c831c89e11e3be7625901b6ace5eeafa6f3403fe92c8c882ee12eb71195",2730 "src/_lofi/Shell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",2831 "src/_lofi/table-store_test.ts": "1b8cfd06192b9c0daacc8a8c7811b691338f4ef459b209b3830fb87a0a039955",2932 "src/_lofi/table-store.ts": "7ad445cc34c11e0cab3afcf1cde097345f1a53f2583da6c7fd00c7cb3e082df8",
@@ -31,14 +34,16 @@
3134 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",3235 "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",3336 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",34- "src/app.ts": "c03faa5219c79940e86f19beaaa94e6b917ec30311a51566b8f16d15886219cb",37+ "src/app.ts": "c0ddd4f1994c19ca49de5a7c545b9306d9dbe8cfc7579bd9c71d6459965764f3",3538 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",36- "src/islands/TaskList.tsx": "2d6f4c7ed2475f1ce561e4b2c3a4e21210f4b55f0ec3eee6c4a6047f8622e189",37- "src/islands/use-tasks.ts": "d2a9afa80590d4f38db7cfe0da37e6b7b17c00c1a73d82e06b800f97b5d609af",38- "src/pages/index.astro": "3746bbd23cca65f5561c23fd4690368ee3f1cebb38d16f8bc7552ddff1f3f7fa",39+ "src/islands/AccountGate.tsx": "e6de6be7148f12b5b113109a70efa12c6fe3020a1a3ce6b5f522fe96cb75898e",40+ "src/islands/TaskList.tsx": "81d7fc95248baf4f51e1d53374d7b4700bf165049e846fdcc2694cbeb873652c",41+ "src/islands/use-tasks.ts": "95573b22d5dd1e0e8303ae4536df9a4f0cb27f0c1243aa6c70a6366cc6b7530f",42+ "src/pages/index.astro": "6bcbcbd1c1aad8d55855e5f52473b7a7711c439d4877ef54fe7f6f5d4f88ec3c",3943 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",4044 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",41- "src/styles/global.css": "0e66a21e9b83cc115f01567ab8a87f949ce7fff8e42a3c3dc2efdd70cfffb706",45+ "src/styles/global.css": "27e98368f3fa1c57befc038cc9bf36f800fef192e368f4c393327ac96bfdd448",46+ "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",4247 "tests/author-boundary_test.ts": "529bbd81977294398f4c48a3616d10cdcf0fe5f7c83b705365ad80124ab1a0f1",4348 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",4449 "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+}