clone

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

feat(auth): "the key is the account" — passkey-derived portable Jazz identity

Dami committed 3e17acd
Opt-in identity built on the WebAuthn/PRF primitive.

- deriveAuthSecret(): the Jazz 32-byte account secret is
  base64url(HKDF(PRF(passkey))), so the same portable key reconstructs the same
  account on any device
- enrollDeviceCredential gains a `label` (nicknames the passkey in the user's
  password manager) and returns `portable` (the WebAuthn backup-eligible flag),
  so the app can tell the user whether the account travels with the key or is
  bound to this device
- runtime.ts: identity: "device-passkey" derives the account from the passkey on
  first boot and caches it (return boots stay instant, no ceremony); the default
  device-local identity is unchanged
- docs/auth-identity.md records the model: portable-first, no recovery service
  (custody delegated to where the key lives), and why it sidesteps the ADR-0004
  passkey-backup rejection (no server-side account custody)
commit
3e17acdf9185bd0a4902440c8b7fdc0ec813172d
parent
bae2ca16e949d00811a3bf601e2f35819da1d066
author
Dami <dami.etoile@gmail.com>
6 files changed, +292 -13
Mapps/reference/src/_lofi/auth.ts87+++++++++++++++++++++++--
Mapps/reference/src/_lofi/auth_test.ts103++++++++++++++++++++++++++++++
Mapps/reference/src/_lofi/runtime.ts19+++++-
Mapps/reference/src/app.ts5+-
Adocs/auth-identity.md83++++++++++++++++++++++++
Mpackage/testdata/starter.snapshot.json8+-
M apps/reference/src/_lofi/auth.ts +80 -7
@@ -45,6 +45,13 @@ export type DeviceCredential = {
4545 id: string;4646 /** The relying-party id the credential is bound to. */4747 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;4855 };4956 5057 /** Injected browser surfaces, so the flows are unit-testable without a device. */
@@ -54,6 +61,16 @@ export type AuthDependencies = {
5461 trustedOrigins?: readonly string[];5562 };5663 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+5774 /** A precise, non-leaking failure reason for a credential operation. */5875 export class AuthError extends Error {5976 override readonly name = "AuthError";
@@ -106,6 +123,21 @@ function rawId(credential: Credential | null): Uint8Array {
106123 return new Uint8Array(credential.rawId);107124 }108125 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+109141 function mapError(error: unknown): AuthError {110142 if (error instanceof AuthError) return error;111143 const name = typeof error === "object" && error !== null && "name" in error
@@ -227,18 +259,20 @@ export async function getAuthCapability(
227259 /**228260 * Enrolls a resident, user-verifying device passkey with the PRF extension229261 * enabled. Refuses unless the current origin is `stable` (pass `rpId` to test).230- * Returns the opaque credential id to persist.262+ * The returned `portable` flag says whether the credential roams across devices;263+ * `label` names it in the user's password manager. Returns the opaque id.231264 */232265 export async function enrollDeviceCredential(233- dependencies: AuthDependencies = {},266+ options: EnrollOptions = {},234267 ): Promise<DeviceCredential> {235- const rpId = requireStableRpId(dependencies);236- const credentials = dependencies.credentials ?? browserCredentials();268+ const rpId = requireStableRpId(options);269+ const credentials = options.credentials ?? browserCredentials();270+ const label = options.label ?? referenceApp.name;237271 try {238272 const credential = await credentials.create({239273 publicKey: {240274 rp: { id: rpId, name: referenceApp.name },241- user: { id: randomBytes(), name: referenceApp.name, displayName: referenceApp.name },275+ user: { id: randomBytes(), name: label, displayName: label },242276 challenge: randomBytes(),243277 pubKeyCredParams: [244278 { type: "public-key", alg: -7 },
@@ -254,7 +288,7 @@ export async function enrollDeviceCredential(
254288 extensions: { prf: {} } as CreateExtensions,255289 },256290 });257- return { id: toBase64Url(rawId(credential)), rpId };291+ return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };258292 } catch (error) {259293 throw mapError(error);260294 }
@@ -270,7 +304,7 @@ export async function authenticateDeviceCredential(
270304 const credential = await credentials.get({271305 publicKey: { rpId, challenge: randomBytes(), userVerification: "required", timeout: 60_000 },272306 });273- return { id: toBase64Url(rawId(credential)), rpId };307+ return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };274308 } catch (error) {275309 throw mapError(error);276310 }
@@ -366,3 +400,42 @@ export async function decryptAtRest(
366400 );367401 return new Uint8Array(plaintext);368402 }403+404+// --- Account identity ("the key is the account") -----------------------------405+406+// A fixed salt so the same credential always derives the same account for lofi.407+// App scoping comes from the credential itself (bound to the app's origin).408+const ACCOUNT_SECRET_SALT = new TextEncoder().encode("lofi:account-secret:v1");409+410+/**411+ * Derives the account secret **deterministically** from the passkey via PRF, in412+ * Jazz's 32-byte base64url auth-secret format. The same portable credential413+ * reconstructs the *same* account on any device — so the key is the account, and414+ * the account lives wherever the key does. Nothing is stored server-side and no415+ * recovery is implied: without the key, the account is gone.416+ *417+ * Requires a `stable` origin and a PRF-capable client; throws `origin-rejected`418+ * or `prf-unavailable` otherwise (never a fabricated secret). Feed the result to419+ * Jazz as the account secret (e.g. via `BrowserAuthSecretStore.saveSecret`).420+ */421+export async function deriveAuthSecret(dependencies: AuthDependencies = {}): Promise<string> {422+ const prfSecret = await derivePrfSecret(ACCOUNT_SECRET_SALT, dependencies);423+ const material = await crypto.subtle.importKey(424+ "raw",425+ prfSecret as BufferSource,426+ "HKDF",427+ false,428+ ["deriveBits"],429+ );430+ const bits = await crypto.subtle.deriveBits(431+ {432+ name: "HKDF",433+ hash: "SHA-256",434+ salt: new Uint8Array(0),435+ info: new TextEncoder().encode("lofi:account-secret"),436+ },437+ material,438+ 256,439+ );440+ return toBase64Url(new Uint8Array(bits));441+}
M apps/reference/src/_lofi/auth_test.ts +103 -0
@@ -4,6 +4,7 @@ import {
44 classifyCredentialOrigin,55 decryptAtRest,66 deriveAtRestKey,7+ deriveAuthSecret,78 derivePrfSecret,89 encryptAtRest,910 enrollDeviceCredential,
@@ -19,6 +20,14 @@ function rawIdBuffer(): ArrayBuffer {
1920 return new Uint8Array([1, 2, 3, 4]).buffer;2021 }2122 23+// A 37-byte authenticator-data buffer (rpIdHash[32] + flags + signCount[4]).24+// The WebAuthn "backup eligible" (BE) flag is bit 0x08 of byte index 32.25+function authenticatorData(backupEligible: boolean): ArrayBuffer {26+ const bytes = new Uint8Array(37);27+ if (backupEligible) bytes[32] |= 0x08;28+ return bytes.buffer;29+}30+2231 // A minimal fake CredentialsContainer whose create/get are supplied per test.2332 function fakeCredentials(handlers: {2433 create?: (options: CredentialCreationOptions) => Promise<Credential | null>;
@@ -207,3 +216,97 @@ test("enrollDeviceCredential maps SecurityError to origin-rejected", async () =>
207216 "origin-rejected",208217 );209218 });219+220+test("enrollDeviceCredential names the passkey with the label and reports portable", async () => {221+ let captured: PublicKeyCredentialUserEntity | undefined;222+ const credentials = fakeCredentials({223+ create: (options) => {224+ captured = options.publicKey?.user;225+ return Promise.resolve({226+ rawId: rawIdBuffer(),227+ response: { getAuthenticatorData: () => authenticatorData(true) },228+ } as unknown as Credential);229+ },230+ });231+ const credential = await enrollDeviceCredential({232+ rpId: "app.example.com",233+ label: "work account",234+ credentials,235+ });236+ assert(captured?.name === "work account", "the label must become user.name");237+ assert(captured?.displayName === "work account", "the label must become user.displayName");238+ assert(credential.portable === true, "a backup-eligible credential must be portable");239+});240+241+test("enrollDeviceCredential reports portable false without the BE flag or authenticator data", async () => {242+ const clearBit = fakeCredentials({243+ create: () =>244+ Promise.resolve({245+ rawId: rawIdBuffer(),246+ response: { getAuthenticatorData: () => authenticatorData(false) },247+ } as unknown as Credential),248+ });249+ const cleared = await enrollDeviceCredential({ rpId: "app.example.com", credentials: clearBit });250+ assert(cleared.portable === false, "a device-bound credential must not be portable");251+252+ const noAuthData = fakeCredentials({253+ create: () =>254+ Promise.resolve({255+ rawId: rawIdBuffer(),256+ response: {},257+ } as unknown as Credential),258+ });259+ const missing = await enrollDeviceCredential({260+ rpId: "app.example.com",261+ credentials: noAuthData,262+ });263+ assert(missing.portable === false, "an unreadable credential must default to not portable");264+});265+266+test("deriveAuthSecret derives a deterministic Jazz auth-secret from the PRF result", async () => {267+ const first = crypto.getRandomValues(new Uint8Array(32));268+ const credentials = fakeCredentials({269+ get: () =>270+ Promise.resolve({271+ rawId: rawIdBuffer(),272+ getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),273+ } as unknown as Credential),274+ });275+ const secret = await deriveAuthSecret({ credentials, rpId: "app.example.com" });276+ assert(secret.length === 43, "a 32-byte base64url secret must be 43 characters");277+ assert(/^[A-Za-z0-9_-]+$/.test(secret), "the secret must be base64url with no padding");278+279+ const decoded = atob(secret.replaceAll("-", "+").replaceAll("_", "/"));280+ assert(decoded.length === 32, "the secret must decode to 32 bytes");281+282+ const again = await deriveAuthSecret({ credentials, rpId: "app.example.com" });283+ assert(again === secret, "the same PRF input must derive the same secret");284+285+ const other = crypto.getRandomValues(new Uint8Array(32));286+ const otherCredentials = fakeCredentials({287+ get: () =>288+ Promise.resolve({289+ rawId: rawIdBuffer(),290+ getClientExtensionResults: () => ({ prf: { results: { first: other.buffer } } }),291+ } as unknown as Credential),292+ });293+ const otherSecret = await deriveAuthSecret({294+ credentials: otherCredentials,295+ rpId: "app.example.com",296+ });297+ assert(otherSecret !== secret, "a different PRF input must derive a different secret");298+});299+300+test("deriveAuthSecret throws prf-unavailable when the client returns no PRF result", async () => {301+ const credentials = fakeCredentials({302+ get: () =>303+ Promise.resolve({304+ rawId: rawIdBuffer(),305+ getClientExtensionResults: () => ({}),306+ } as unknown as Credential),307+ });308+ await expectAuthError(309+ () => deriveAuthSecret({ credentials, rpId: "app.example.com" }),310+ "prf-unavailable",311+ );312+});
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 { deriveAuthSecret } 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 (default): a random per-device secret. "device-passkey" makes58+// the passkey the account — the secret is derived from the credential's PRF, so59+// a portable key reconstructs the same account on every device. The derived60+// secret is cached, so only the first boot on a device runs a passkey ceremony.61+async function resolveAccountSecret(): Promise<string> {62+ if (referenceApp.identity !== "device-passkey") {63+ return await BrowserAuthSecretStore.getOrCreateSecret({ appId });64+ }65+ const cached = await BrowserAuthSecretStore.loadSecret({ appId });66+ if (cached) return cached;67+ const secret = await deriveAuthSecret();68+ await BrowserAuthSecretStore.saveSecret(secret, { appId });69+ return secret;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;
M apps/reference/src/app.ts +4 -1
A docs/auth-identity.md +83 -0
@@ -0,0 +1,83 @@
1+# The key is the account2+3+lofi's optional 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 opt-in. The default identity is `device-local` (each device gets its own random account).8+Set `identity: "device-passkey"` in `src/app.ts` to switch.9+10+## How it works11+12+Jazz's account secret is a 32-byte value (base64url). The WebAuthn **PRF** extension returns a13+32-byte, credential-bound secret that is deterministic for a given `(credential, salt)`. So:14+15+```16+account secret = base64url( HKDF-SHA-256( PRF(passkey, fixed-salt) ) )17+```18+19+The same credential always derives the same account. No account material is stored on any server; it20+is a pure function of the key.21+22+### Boot flow (`device-passkey`)23+24+- **First boot on a device** → passkey ceremony → derive the secret → hand it to Jazz → cache it25+ locally (`BrowserAuthSecretStore.saveSecret`).26+- **Return boots** → the cached secret is used; no ceremony. "Identity never waits" still holds for27+ the common path.28+- **A new device** → the same portable key → the same PRF → the same secret → the same Jazz account29+ → your data syncs down and decrypts. Jazz already E2E-encrypts synced data keyed on the account,30+ so nothing extra is needed for confidentiality.31+32+## Portable vs device-bound — and telling the user which they chose33+34+A passkey binds to its origin (RP ID) and roams (or doesn't) based on the credential provider, not35+the OS. `enrollDeviceCredential` reports this via the WebAuthn **backup-eligible (BE)** flag:36+37+- **Portable** (`portable: true`) — a password-manager or platform-synced passkey (1Password,38+ iCloud, Google) or a hardware key you carry. **The account travels with it.** Favor these.39+- **Device-bound** (`portable: false`) — Touch ID / Windows Hello without sync. **The account only40+ exists on this device.** Allowed, but the app should say so: _use a portable passkey to reach this41+ account elsewhere._42+43+The nickname passed as `label` becomes the passkey's name in the user's password manager, so they44+can see which account a key unlocks.45+46+## Custody and recovery — stated plainly47+48+- **No recovery service.** lofi never holds recoverable account material. Recovery is delegated to49+ _where the key lives_: your password manager's own sync, or a second enrolled hardware key.50+- **Lose every copy of the key → lose the account.** That is the honest cost of no-central-custody,51+ and it is the same custody the device-local identity already has.52+- **This is not the rejected passkey-_backup_ ceremony.** The Jazz alpha's server-assisted passkey53+ account backup was rejected on security grounds. This model has no server-side account backup at54+ all — the account is derived locally from the key — so it sidesteps that entirely.55+56+## Guardrails (honest by construction)57+58+- **Feature-detected.** `getAuthCapability()` reports `webAuthn` + `prf`; if PRF is unavailable,59+ derivation throws `prf-unavailable` rather than fabricating a secret.60+- **Origin-gated.** Enrollment and derivation refuse unless the origin is `stable` (an61+ author-committed hostname in `credentialOrigins`), because a passkey silently breaks if its origin62+ changes.63+64+## Enabling it65+66+```ts67+// src/app.ts68+identity: "device-passkey",69+credentialOrigins: ["app.example.com"], // your committed-stable hostname(s)70+```71+72+```ts73+import { deriveAuthSecret, enrollDeviceCredential, getAuthCapability } from "./_lofi/auth.ts";74+75+const { prf, origin } = await getAuthCapability();76+if (prf === "available" && origin.status === "stable") {77+ const cred = await enrollDeviceCredential({ label: "work account" });78+ // cred.portable === true → the account will travel with this key79+ const secret = await deriveAuthSecret(); // the Jazz account secret80+}81+```82+83+The runtime does this wiring for you when `identity: "device-passkey"`.
M package/testdata/starter.snapshot.json +4 -4
@@ -7,8 +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",10+ "src/_lofi/auth_test.ts": "700520bce0bb0b238b8f97edf54ae0a75898e3011cdce7035c6dd8c47aa38921",11+ "src/_lofi/auth.ts": "b726b0c7cfec458d7348c05c8d47f3e15d50b146c577845c0f4cea3eb8d0c29b",1212 "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1313 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",1414 "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",
@@ -25,7 +25,7 @@
2525 "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",2626 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2727 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",28- "src/_lofi/runtime.ts": "db204517acc708f5145f48ed5cbd1ab95c01e35ddedb09dc0ba9617ea8bc1f79",28+ "src/_lofi/runtime.ts": "c11c7fa1fc340c8ec1f935a255dc76174b8dfe4a26cb92aa92eac0a7f6e0b8be",2929 "src/_lofi/Shell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",3030 "src/_lofi/table-store_test.ts": "1b8cfd06192b9c0daacc8a8c7811b691338f4ef459b209b3830fb87a0a039955",3131 "src/_lofi/table-store.ts": "7ad445cc34c11e0cab3afcf1cde097345f1a53f2583da6c7fd00c7cb3e082df8",
@@ -33,7 +33,7 @@
3333 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",3434 "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",3535 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",36- "src/app.ts": "3d7307fbec76e9647aeb4e087ba1e7710803f302c8ee76db90af4128409437c5",36+ "src/app.ts": "fa5068957b68a78d14a11d2bc48b130fc8e093bfa0865d1eabe361e3ef310ff5",3737 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",3838 "src/islands/TaskList.tsx": "2d6f4c7ed2475f1ce561e4b2c3a4e21210f4b55f0ec3eee6c4a6047f8622e189",3939 "src/islands/use-tasks.ts": "d2a9afa80590d4f38db7cfe0da37e6b7b17c00c1a73d82e06b800f97b5d609af",