clone

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

feat/fix-sync-enrollment-startup
lofi / docs / auth.md
auth.md 5.1K rendered
1# lofi device auth23Status: **primitive validated in reference source; `./auth` subpath deferred to a later milestone**\4Scope: **device-local WebAuthn + PRF at-rest key derivation**56> **Advanced runtime seam.** Generated applications consume this through the supported root package7> export, and normal product work should not reimplement it. Use this document only when8> deliberately evaluating the optional device-credential primitive. Account sync and recovery are9> documented separately in [Sync and recovery](sync-and-recovery.md).1011lofi device auth is a small, honest **primitive**, not a mandated identity or recovery scheme.12Local-first identity is device-local and cryptographic — there is no central store to authenticate13against — so this module does exactly three things: enroll a device passkey, authenticate with it,14and derive a credential-bound key to encrypt data **at rest**. The generated session runtime handles15accounts, recoverable-passkey backup, recovery phrases, and optional multi-device sync separately.1617The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lofi`.1819## What it guarantees2021- **Enrollment is gated on a stable origin.** A passkey binds to its origin hostname (its RP-ID); if22 that origin later changes, the credential silently breaks. `classifyCredentialOrigin` reports the23 current origin as `stable`, `local-only`, `unverified`, or `blocked`, and enrollment refuses24 anything but `stable` or `local-only`. Authors declare their committed hostnames in `src/app.ts`25 `credentialOrigins` (exact, or a `*.` suffix pattern); a deployed host that is not listed is26 `unverified` and refused — there is no implicit trust of the served hostname, so a preview origin27 cannot mint credentials that strand on the production one. `localhost` and `127.0.0.1` are28 `local-only` — useful for development, never a shipping RP-ID.29- **The phrase-guard ceremony is pinned to its enrolled credential.** Authentication passes30 `allowCredentials` for the enrolled credential id and verifies the asserting credential against31 it, failing with `credential-mismatch` otherwise — another resident passkey for the same RP-ID32 cannot satisfy the guard. Guards enrolled before pinning remain discoverable until re-enrolled.33- **PRF derives an at-rest key, and is never faked.** The WebAuthn PRF extension yields a 32-byte34 secret bound to the credential, from which `deriveAtRestKey` produces an AES-GCM key via35 HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or36 authenticator does not return a PRF result, the derive path throws `prf-unavailable` rather than37 inventing a key. The capability report is a UX hint only — flows that seal data attempt the38 evaluation and record what actually succeeded, never gate on the report.39- **Assertion and PRF evaluation compose into one prompt.** `authenticateAndDerivePrfSecret` rides40 the PRF evaluation on a user-verifying assertion, so a flow that needs both the asserted41 credential and a credential-bound key (sealing or unlocking an envelope) costs one ceremony, with42 the same `credential-mismatch` pinning as plain authentication.43- **No recovery claim for this primitive.** `enrollDeviceCredential` and the PRF-derived key do not44 contain the Jazz account secret, so they cannot restore an account. Account recovery uses the45 separate `createRecoverablePasskeyBackup` / `restoreFromPasskey` flow documented in46 [Sync and recovery](sync-and-recovery.md), with an RP-ID- and provider-independent phrase47 fallback.4849## Usage5051```ts52import {53 deriveAtRestKey,54 derivePrfSecret,55 encryptAtRest,56 enrollDeviceCredential,57 getAuthCapability,58} from "@nzip/lofi";5960// 1. Feature-detect before offering enrollment.61const capability = await getAuthCapability();62if (!capability.webAuthn || capability.origin.status !== "stable") {63 throw new Error(capability.origin.action);64}6566// 2. Enroll a resident, user-verifying device passkey (once per device).67const credential = await enrollDeviceCredential();6869// 3. Derive a credential-bound PRF secret from a stable, app-owned salt.70const salt = new TextEncoder().encode("notes.v1");71const prfSecret = await derivePrfSecret(salt);7273// 4. Turn the secret into an at-rest key and encrypt. Vary `info` to bind74// unrelated data to different keys.75const key = await deriveAtRestKey(prfSecret, "notes");76const blob = await encryptAtRest(key, new TextEncoder().encode("local-first secret"));7778console.log(credential.id, blob.iv.length, blob.ciphertext.length);79```8081## Testing8283- **Unit** (`package/runtime/auth_test.ts`): origin classification, enroll/authenticate, PRF result84 handling, error mapping, and the HKDF -> AES-GCM round-trip run without a browser by injecting a85 fake `CredentialsContainer` and an explicit `rpId`.86- **Browser** (`tests/auth_e2e_test.ts` + `@nzip/lofi/testing`'s `withVirtualAuthenticator`): a CDP87 virtual authenticator drives an enroll -> authenticate round-trip headless. CDP virtual88 authenticators do **not** reliably model the PRF extension, so PRF derivation is not exercised89 there; it is feature-detected and validated on real devices.