fix(auth): pin phrase-guard credential, remove implicit origin trust
- commit
- 810e6fb17af9b999096e84fb8ffd999cd956968b
- parent
- b288b437642e9f292787c1977d77931f2404e730
- parent
- d66c49a8f49ee3be993be9b24d0825b47d81caa4
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 8 | ++- |
| M | docs/ | 10 | +++- |
| M | package/ | 24 | ++++++++++ |
| M | package/ | 16 | +++++-- |
| M | package/ | 74 | +++++++++++++++++++++++++----- |
| M | package/ | 64 | ++++++++++++++++++++++++++ |
| M | package/ | 73 | ++++++++++++++++++++++++------ |
| M | package/ | 42 | +++++++++++++++++- |
| M | package/ | 2 | +- |
@@ -14,9 +14,11 @@ export const app = defineLofiApp({
1414 // `docs/sync-and-recovery.md` guide.1515 //1616 // Hostnames you have committed to keeping stable, for the optional device-credential17- // primitive in `@nzip/lofi` (WebAuthn/PRF at-rest encryption). A credential18- // binds to its origin; left empty, the served origin is trusted. Pin your permanent19- // hostname(s) here before relying on a credential across a host change.17+ // primitive in `@nzip/lofi` (WebAuthn/PRF at-rest encryption). A credential binds18+ // to its origin: localhost works for development without configuration, but a19+ // deployed origin must be listed here before credentials can enroll on it. An20+ // unlisted host is refused so a preview hostname cannot mint passkeys and21+ // PRF-protected data that strand when the app moves to its production origin.2022 credentialOrigins: [] as readonly string[],2123 // Pin this to the canonical production hostname before users create recoverable2224 // passkeys. Omit it in local development to use the current hostname.@@ -21,9 +21,15 @@ The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lof
2121 - **Enrollment is gated on a stable origin.** A passkey binds to its origin hostname (its RP-ID); if2222 that origin later changes, the credential silently breaks. `classifyCredentialOrigin` reports the2323 current origin as `stable`, `local-only`, `unverified`, or `blocked`, and enrollment refuses24- anything but `stable`. Authors declare their committed hostnames in `src/app.ts`25- `credentialOrigins` (exact, or a `*.` suffix pattern). `localhost` and `127.0.0.1` are24+ 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` are2628 `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.2733 - **PRF derives an at-rest key, and is never faked.** The WebAuthn PRF extension yields a 32-byte2834 secret bound to the credential, from which `deriveAtRestKey` produces an AES-GCM key via2935 HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or@@ -28,6 +28,30 @@ Deno.test("sharing identities round-trip only inside the configured app", () =>
2828 assert(code === "invalid-identity", "cross-app identity was accepted");2929 });3030 31+Deno.test("sharing identities tolerate copy/paste padding but never inner whitespace", () => {32+ const identity = encodeSharingIdentity("stable-jazz-principal");33+ assert(34+ decodeSharingIdentity(` ${identity}\n`) === "stable-jazz-principal",35+ "surrounding whitespace from copy/paste must not change the decoded principal",36+ );37+ // A grant recorded for a whitespace-bearing principal looks active to the38+ // owner but never matches the recipient's account — reject at both ends.39+ let decoded = "";40+ try {41+ decodeSharingIdentity(identity.replace("principal", "prin cipal"));42+ } catch (error) {43+ decoded = error instanceof AccessError ? error.code : "unexpected";44+ }45+ assert(decoded === "invalid-identity", "inner whitespace in the principal was accepted");46+ let encoded = "";47+ try {48+ encodeSharingIdentity("principal with spaces");49+ } catch (error) {50+ encoded = error instanceof AccessError ? error.code : "unexpected";51+ }52+ assert(encoded === "invalid-identity", "a whitespace-bearing principal must not encode");53+});54+3155 Deno.test("shared operations reject managed-sync work before touching a local-only runtime", async () => {3256 const operations = createSharingOperations({ resource: app.docs, grants: app.grants });3357 let code = "";@@ -9,16 +9,24 @@ const prefix = "lofi1";
99 1010 /** Encodes a raw Jazz principal as a versioned identity scoped to the current app. */1111 export function encodeSharingIdentity(userId: string): SharingIdentity {12- if (!userId.trim() || userId.includes(":")) {12+ if (!userId || /[\s:]/.test(userId)) {1313 throw new AccessError("invalid-identity", "Jazz returned an invalid sharing identity.");1414 }1515 return `${prefix}:${appId}:${userId}` as SharingIdentity;1616 }1717 18-/** Validates an app-scoped sharing identity and returns its raw Jazz principal. */18+/**19+ * Validates an app-scoped sharing identity and returns its raw Jazz principal.20+ * Surrounding whitespace from copy/paste is tolerated; whitespace inside the21+ * principal is rejected — a grant for a padded principal would look active to22+ * the owner while never matching the recipient's actual account.23+ */1924 export function decodeSharingIdentity(identity: string): string {20- const [version, identityAppId, userId, extra] = identity.split(":");21- if (version !== prefix || identityAppId !== appId || !userId || extra !== undefined) {25+ const [version, identityAppId, userId, extra] = identity.trim().split(":");26+ if (27+ version !== prefix || identityAppId !== appId || !userId || /\s/.test(userId) ||28+ extra !== undefined29+ ) {2230 throw new AccessError(2331 "invalid-identity",2432 "That sharing identity belongs to another app or is malformed. Ask the recipient to copy it again from this app.",@@ -83,6 +83,7 @@ export class AuthError extends Error {
8383 | "unsupported"8484 | "prf-unavailable"8585 | "credential-missing"86+ | "credential-mismatch"8687 | "unknown";8788 8889 /** Creates a device-credential error without including credential material. */@@ -109,6 +110,20 @@ function toBase64Url(bytes: Uint8Array): string {
109110 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");110111 }111112 113+function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {114+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");115+ try {116+ const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));117+ const bytes = new Uint8Array(binary.length);118+ for (let index = 0; index < binary.length; index += 1) {119+ bytes[index] = binary.charCodeAt(index);120+ }121+ return bytes;122+ } catch {123+ throw new AuthError("credential-mismatch", "The pinned credential id is unreadable.");124+ }125+}126+112127 function randomBytes(length = 32): Uint8Array<ArrayBuffer> {113128 return crypto.getRandomValues(new Uint8Array(length));114129 }@@ -224,13 +239,13 @@ function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginRepo
224239 }225240 const url = new URL(location.href);226241 const declared = trustedOrigins ?? getLofiApp().credentialOrigins ?? [];227- // Default trust is the origin you are actually served from: the passkey binds228- // to this hostname (its RP ID) regardless, so trusting it lets the app work229- // wherever it is deployed. Pin explicit hosts in `app.ts` `credentialOrigins`230- // to keep enrollment working across a host change — a passkey silently breaks231- // if its origin moves.232- const trusted = declared.length > 0 ? declared : [url.hostname];233- return classifyCredentialOrigin(url, trusted);242+ // No implicit trust: with an empty `credentialOrigins` allowlist, a deployed243+ // HTTPS host classifies as `unverified` and enrollment refuses. Implicitly244+ // trusting the served hostname made that refusal unreachable — a preview245+ // host could mint credentials (and PRF-protected data) that strand the246+ // moment the app moves to its production hostname. Development on247+ // localhost stays `local-only` and keeps working without configuration.248+ return classifyCredentialOrigin(url, declared);234249 }235250 236251 function requireStableRpId(dependencies: AuthDependencies): string {@@ -312,17 +327,50 @@ export async function enrollDeviceCredential(
312327 }313328 }314329 315-/** Authenticates with a device passkey (discoverable get, user-verifying). */330+/** Options for {@link authenticateDeviceCredential}. */331+export type AuthenticateOptions = AuthDependencies & {332+ /**333+ * The base64url id of the one enrolled credential this ceremony must assert.334+ * When set, the request pins `allowCredentials` to it and the returned335+ * assertion is verified against it — any other credential for the same RP ID336+ * fails with `credential-mismatch` instead of passing as the enrolled one.337+ */338+ credentialId?: string;339+};340+341+/**342+ * Authenticates with a device passkey (user-verifying). Pass `credentialId` to343+ * pin the ceremony to one enrolled credential; without it the request is344+ * discoverable and any resident credential for the RP ID can assert.345+ */316346 export async function authenticateDeviceCredential(317- dependencies: AuthDependencies = {},347+ options: AuthenticateOptions = {},318348 ): Promise<DeviceCredential> {319- const rpId = requireStableRpId(dependencies);320- const credentials = dependencies.credentials ?? browserCredentials();349+ const rpId = requireStableRpId(options);350+ const credentials = options.credentials ?? browserCredentials();351+ const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = options.credentialId352+ ? [{ type: "public-key", id: fromBase64Url(options.credentialId) }]353+ : undefined;321354 try {322355 const credential = await credentials.get({323- publicKey: { rpId, challenge: randomBytes(), userVerification: "required", timeout: 60_000 },356+ publicKey: {357+ rpId,358+ challenge: randomBytes(),359+ userVerification: "required",360+ timeout: 60_000,361+ ...(allowCredentials ? { allowCredentials } : {}),362+ },324363 });325- return { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };364+ const asserted = { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) };365+ // Verify the assertion even though `allowCredentials` was sent: the browser366+ // allow-list is a request hint, not a caller-side guarantee.367+ if (options.credentialId && asserted.id !== options.credentialId) {368+ throw new AuthError(369+ "credential-mismatch",370+ "The asserting passkey is not the enrolled credential.",371+ );372+ }373+ return asserted;326374 } catch (error) {327375 throw mapError(error);328376 }@@ -8,6 +8,7 @@ import {
88 derivePrfSecret,99 encryptAtRest,1010 enrollDeviceCredential,11+ getAuthCapability,1112 } from "./auth.ts";1213 import { defineLofiApp } from "./app.ts";1314 import { assert } from "./test-assert.ts";@@ -142,6 +143,69 @@ test("authenticateDeviceCredential returns the DeviceCredential from a get resul
142143 assert(credential.rpId === "app.example.com", "the credential must carry the rpId");143144 });144145 146+test("authenticateDeviceCredential pins the ceremony to the supplied credential id", async () => {147+ let captured: PublicKeyCredentialRequestOptions | undefined;148+ const credentials = fakeCredentials({149+ get: (options) => {150+ captured = options.publicKey;151+ return Promise.resolve({ rawId: rawIdBuffer() } as unknown as Credential);152+ },153+ });154+ const credential = await authenticateDeviceCredential({155+ credentials,156+ rpId: "app.example.com",157+ credentialId: "AQIDBA",158+ });159+ assert(credential.id === "AQIDBA", "the pinned assertion must return the enrolled id");160+ const allowed = captured?.allowCredentials;161+ assert(allowed?.length === 1, "the request must carry exactly the enrolled credential");162+ const allowedBytes = allowed[0].id as Uint8Array;163+ assert(164+ allowedBytes.length === 4 &&165+ [1, 2, 3, 4].every((byte, index) => allowedBytes[index] === byte),166+ "allowCredentials must contain the decoded enrolled credential id",167+ );168+});169+170+test("authenticateDeviceCredential rejects an assertion from a different credential", async () => {171+ // The RP has other resident credentials; the guard must not accept them.172+ const credentials = fakeCredentials({173+ get: () =>174+ Promise.resolve(175+ { rawId: new Uint8Array([9, 9, 9, 9]).buffer } as unknown as Credential,176+ ),177+ });178+ await expectAuthError(179+ () =>180+ authenticateDeviceCredential({181+ credentials,182+ rpId: "app.example.com",183+ credentialId: "AQIDBA",184+ }),185+ "credential-mismatch",186+ );187+});188+189+test("a deployed origin outside credentialOrigins is refused, never implicitly trusted", async () => {190+ (globalThis as { location?: unknown }).location = { href: "https://preview.example.com/" };191+ try {192+ const capability = await getAuthCapability();193+ assert(194+ capability.origin.status === "unverified",195+ "an unlisted deployed host must report unverified, not stable",196+ );197+ const credentials = fakeCredentials({198+ create: () => Promise.reject(new Error("create must not be reached")),199+ });200+ await expectAuthError(() => enrollDeviceCredential({ credentials }), "origin-rejected");201+202+ const pinned = await getAuthCapability({ trustedOrigins: ["preview.example.com"] });203+ assert(pinned.origin.status === "stable", "a listed hostname must classify as stable");204+ } finally {205+ delete (globalThis as { location?: unknown }).location;206+ }207+});208+145209 test("derivePrfSecret returns the PRF result bytes", async () => {146210 const secret = crypto.getRandomValues(new Uint8Array(32));147211 const credentials = fakeCredentials({@@ -65,20 +65,48 @@ export type Session = {
6565 const phraseGuardKey = `lofi:phrase-passkey:${appId}`;6666 const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;6767 68+// The guard record pins the enrolled credential's id so the reveal ceremony69+// accepts only that passkey. Legacy records stored the bare flag "1" — those70+// devices stay guarded but unpinned until the guard is re-enrolled.71+type PhraseGuard = { guarded: boolean; credentialId: string | null };72+73+function readPhraseGuard(): PhraseGuard {74+ if (typeof localStorage === "undefined") return { guarded: false, credentialId: null };75+ try {76+ const raw = localStorage.getItem(phraseGuardKey);77+ if (!raw) return { guarded: false, credentialId: null };78+ if (raw === "1") return { guarded: true, credentialId: null };79+ try {80+ const value = JSON.parse(raw) as { id?: unknown };81+ return {82+ guarded: true,83+ credentialId: typeof value.id === "string" && value.id ? value.id : null,84+ };85+ } catch {86+ return { guarded: true, credentialId: null };87+ }88+ } catch {89+ return { guarded: false, credentialId: null };90+ }91+}92+6893 function phraseGuarded(): boolean {69- if (typeof localStorage === "undefined") return false;94+ return readPhraseGuard().guarded;95+}96+97+function setPhraseGuard(credentialId: string): void {98+ if (typeof localStorage === "undefined") return;7099 try {71- return localStorage.getItem(phraseGuardKey) === "1";100+ localStorage.setItem(phraseGuardKey, JSON.stringify({ id: credentialId }));72101 } catch {73- return false;102+ // A private-mode storage failure must not break the ceremony itself.74103 }75104 }76105 77-function setPhraseGuarded(guarded: boolean): void {106+function clearPhraseGuard(): void {78107 if (typeof localStorage === "undefined") return;79108 try {80- if (guarded) localStorage.setItem(phraseGuardKey, "1");81- else localStorage.removeItem(phraseGuardKey);109+ localStorage.removeItem(phraseGuardKey);82110 } catch {83111 // A private-mode storage failure must not break the ceremony itself.84112 }@@ -138,15 +166,19 @@ async function currentSecret(): Promise<string> {
138166 */139167 export async function createBackupPasskey(label?: string): Promise<boolean> {140168 try {141- await enrollDeviceCredential(label ? { label } : {});142- setPhraseGuarded(true);169+ const credential = await enrollDeviceCredential(label ? { label } : {});170+ setPhraseGuard(credential.id);143171 return true;144172 } catch (error) {145173 if (146174 error instanceof AuthError &&147175 (error.code === "unsupported" || error.code === "origin-rejected")148176 ) {149- setPhraseGuarded(false);177+ // A failed re-enrollment must not remove a guard that already stands:178+ // the existing credential still protects the reveal, so report the179+ // device as guarded rather than silently downgrading it.180+ if (readPhraseGuard().guarded) return true;181+ clearPhraseGuard();150182 return false;151183 }152184 throw error;@@ -181,12 +213,18 @@ export async function createRecoverablePasskeyBackup(
181213 182214 /**183215 * Runs the user-verifying passkey ceremony that must precede revealing the phrase184- * on a guarded device. A no-op when the device is not guarded. Throws `cancelled`185- * if the user dismisses the prompt — the phrase is then not revealed.216+ * on a guarded device. The ceremony is pinned to the enrolled credential, so no217+ * other passkey for the same RP ID can satisfy it (guards enrolled before pinning218+ * remain discoverable until re-enrolled). A no-op when the device is not guarded.219+ * Throws `cancelled` if the user dismisses the prompt — the phrase is then not220+ * revealed.186221 */187222 export async function confirmPhraseAccess(): Promise<void> {188- if (!phraseGuarded()) return;189- await authenticateDeviceCredential();223+ const guard = readPhraseGuard();224+ if (!guard.guarded) return;225+ await authenticateDeviceCredential(226+ guard.credentialId ? { credentialId: guard.credentialId } : {},227+ );190228 }191229 192230 /**@@ -261,17 +299,20 @@ export class AccountReplacementError extends Error {
261299 async function replaceAccountSecret(262300 secret: string,263301 options: AccountReplacementOptions,302+ onConfirmed?: () => void,264303 ): Promise<Session> {265304 const current = await currentSecret();266305 if (current === secret) {267306 // The device already holds this account; there is no principal to replace.268307 // Electing backup and sync is the whole remaining intent, and the election269308 // path migrates a never-synced device's local rows instead of hiding them.309+ onConfirmed?.();270310 return await enableSyncBackup();271311 }272312 if (!syncElected() && !options.confirmLocalReplacement) {273313 throw new AccountReplacementError();274314 }315+ onConfirmed?.();275316 await replaceRuntimePrincipal(secret, {276317 onSecretSaved: () => {277318 // Commit election only after the restored secret is durably saved: a@@ -303,8 +344,10 @@ export async function restoreFromPasskey(
303344 options: AccountReplacementOptions = {},304345 ): Promise<Session> {305346 const secret = await restoreSecretWithPasskey(createPasskeyBackupAdapter());306- setPasskeyRecoverable(true);307- return await replaceAccountSecret(secret, options);347+ // Recoverability is recorded only once replacement is confirmed: an aborted348+ // restore must not leave metadata claiming this device's account is backed349+ // up by the passkey that was merely asserted.350+ return await replaceAccountSecret(secret, options, () => setPasskeyRecoverable(true));308351 }309352 310353 /** True when an error is a recovery-phrase problem the user can fix and retry. */@@ -1,9 +1,19 @@
11 // Package contract tests for account-session election behavior.2-import { enableSyncBackup } from "./session.ts";2+import { createBackupPasskey, enableSyncBackup } from "./session.ts";33 import { readNamespaceState } from "./namespace-state.ts";44 import { appId, syncAvailable } from "./config.ts";5+import { defineLofiApp } from "./app.ts";56 import { assert } from "./test-assert.ts";67 8+defineLofiApp({9+ name: "lofi-test",10+ databaseName: "lofi-test",11+ schema: {},12+ storage: "durable",13+ credentialOrigins: [],14+ sync: { adapter: "jazz" },15+});16+717 const test = (globalThis as unknown as {818 Deno: { test(name: string, body: () => void | Promise<void>): void };919 }).Deno.test;@@ -37,3 +47,33 @@ test("enabling sync without a managed Jazz app is the documented no-op", async (
3747 for (const key of electionKeys) localStorage.removeItem(key);3848 }3949 });50+51+// In Deno there is no `location`, so enrollment fails with `origin-rejected`52+// before any WebAuthn surface is touched — exactly the failure class whose53+// guard handling these tests pin down.54+const phraseGuardKey = `lofi:phrase-passkey:${appId}`;55+56+test("a failed re-enrollment preserves an existing phrase guard", async () => {57+ const pinned = JSON.stringify({ id: "AQIDBA" });58+ localStorage.setItem(phraseGuardKey, pinned);59+ try {60+ const guarded = await createBackupPasskey();61+ assert(guarded, "the device must still report itself guarded");62+ assert(63+ localStorage.getItem(phraseGuardKey) === pinned,64+ "the pinned guard record must survive a failed re-enrollment",65+ );66+ } finally {67+ localStorage.removeItem(phraseGuardKey);68+ }69+});70+71+test("a failed first enrollment stores no guard and reports unguarded", async () => {72+ localStorage.removeItem(phraseGuardKey);73+ const guarded = await createBackupPasskey();74+ assert(!guarded, "a device that never enrolled must report unguarded");75+ assert(76+ localStorage.getItem(phraseGuardKey) === null,77+ "no guard record may be written by a failed enrollment",78+ );79+});@@ -12,7 +12,7 @@
1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",1414 "README.md": "10d47c681ae44a3d70b790e3952e0ac361e0eeec4a433f0ff35cfe9db61a7778",15- "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",15+ "src/app.ts": "0f508004dfb12456d65f3ee2231199b932fe239bef6977405fb93135972c0f33",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "9375e8393fadeb621244cccd14234ede017fbf1f2014f28cbddf55ea412a9094",1818 "src/islands/TaskList.tsx": "cf559f8529e3836730abcdafab8184032c8ac662a1e794e20f0879161bee37be",