2627a8b30c10

finish Firefox passkey ceremonies before popup close (v5.3.7)

time
2026-10-08 17:28 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
reliable firefox passkey sign-in
parent
e013c493c758
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-firefox-proxy.test.ts+43 −0src/background/passkey.ts+144 −126
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.3.6",4+  "version": "5.3.7",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.3.6",5+  "version": "5.3.7",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.3.6",3+  "version": "5.3.7",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/passkey-firefox-proxy.test.ts+43 −0
@@ -510,6 +510,49 @@510510     expect(vault.getEntries().find((candidate) => candidate.id === entry.id)!.passkey!.backupEligible).toBe(expectedBackupEligible);511511   });512512 513+  it("finishes saving a Firefox assertion before approval lets the popup close and lock", async () => {514+    const vault = await import("./vault");515+    const passkey = await import("./passkey");516+    await vault.createVault("correct-pass-12345");517+    autoApprove();518+    const created = await dispatch({519+      type: "PASSKEY_CREATE",520+      payload: { rpId: "github.com", userId: "AQID", userName: "june", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), pubKeyCredParams: [{ alg: -7, type: "public-key" }] },521+    });522+    expect(created.ok).toBe(true);523+    const resultPromise = dispatch({524+      type: "PASSKEY_GET",525+      payload: { rpId: "github.com", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },526+    });527+    await vi.waitFor(async () => expect(await passkey.getPendingPasskeyRequest()).not.toBeNull());528+    const pending = await passkey.getPendingPasskeyRequest();529+    await passkey.resolvePendingPasskeyRequest(pending!.requestId, true);530+    // The popup closes as soon as APPROVE_PASSKEY returns, triggering lock-on-close.531+    vault.lock();532+    await expect(resultPromise).resolves.toMatchObject({ ok: true });533+    await vault.unlock("correct-pass-12345");534+    expect(vault.getEntries().find((entry) => entry.passkey)?.passkey?.signCount).toBe(1);535+  });536+537+  it("accepts approval delivered while the popup is opening", async () => {538+    const vault = await import("./vault");539+    const passkey = await import("./passkey");540+    await vault.createVault("correct-pass-12345");541+    vi.mocked(chrome.action.openPopup).mockImplementationOnce(async () => {542+      const pending = await passkey.getPendingPasskeyRequest();543+      expect(pending).not.toBeNull();544+      void passkey.resolvePendingPasskeyRequest(pending!.requestId, true);545+    });546+    const resultPromise = dispatch({547+      type: "PASSKEY_CREATE",548+      payload: { rpId: "github.com", userId: "AQID", userName: "june", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), pubKeyCredParams: [{ alg: -7, type: "public-key" }] },549+    });550+    await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());551+    await new Promise((resolve) => setTimeout(resolve, 20));552+    await passkey.denyAllPendingPasskeyRequests();553+    await expect(resultPromise).resolves.toMatchObject({ ok: true });554+  });555+513556   it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {514557     const vault = await import("./vault");515558     await vault.createVault("correct-pass-12345");

view file

src/background/passkey.ts+144 −126
@@ -30,6 +30,9 @@3030 let conflict: string | undefined;3131 const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3232 33+// Approval must not close and lock the popup before a Firefox ceremony persists.34+const firefoxCeremonies = new Map<number, Promise<void>>();35+3336 const approvals = new Map<3437   number,3538   { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }@@ -256,6 +259,7 @@256259     if (chrome.storage.session) {257260       await chrome.storage.session.remove(FIREFOX_PENDING_KEY);258261     }262+    if (approved) await firefoxCeremonies.get(requestId);259263     return;260264   }261265 @@ -263,23 +267,29 @@263267   approvals.delete(requestId);264268   await clearPendingRequest(requestId);265269   pending.resolve(approved);270+  if (approved) await firefoxCeremonies.get(requestId);266271 }267272 268273 async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {269-  if (chrome.storage.session) {270-    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });271-  }272-  await openApprovalPopup();273-274-  return new Promise<boolean>((resolve) => {274+  // Register before publishing the request so the popup can approve immediately.275+  const approval = new Promise<boolean>((resolve) => {275276     const timeoutId = setTimeout(async () => {276277       approvals.delete(request.requestId);277278       await clearPendingRequest(request.requestId);278279       resolve(false);279280     }, APPROVAL_TIMEOUT_MS);280-281281     approvals.set(request.requestId, { resolve, timeoutId });282282   });283+  try {284+    if (chrome.storage.session) {285+      await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });286+    }287+    await openApprovalPopup();288+  } catch (error) {289+    await resolvePendingPasskeyRequest(request.requestId, false);290+    throw error;291+  }292+  return approval;283293 }284294 285295 export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {@@ -1130,24 +1140,31 @@11301140  * any new code path. The page-side proxy never sees the request id; the11311141  * background mints one and discards it after resolution.11321142  */1133-async function requestFirefoxApproval(args: {1143+async function requestFirefoxApproval<T>(args: {11341144   type: "create" | "get";11351145   rpId: string;11361146   rpName?: string;11371147   userName: string;11381148   userDisplayName?: string;1139-}): Promise<boolean> {1140-  // Synthetic request id — the Firefox proxy path doesn't have a1141-  // chrome.webAuthenticationProxy request to tie back to.1149+}, operation: () => Promise<FirefoxPasskeyResult<T>>): Promise<FirefoxPasskeyResult<T>> {11421150   const requestId = randomRequestId();1143-  return promptUserApproval({1144-    requestId,1145-    type: args.type,1146-    rpId: args.rpId,1147-    rpName: args.rpName ?? args.rpId,1148-    userName: args.userName,1149-    userDisplayName: args.userDisplayName,1150-  });1151+  let finish!: () => void;1152+  firefoxCeremonies.set(requestId, new Promise<void>((resolve) => { finish = resolve; }));1153+  try {1154+    const approved = await promptUserApproval({1155+      requestId,1156+      type: args.type,1157+      rpId: args.rpId,1158+      rpName: args.rpName ?? args.rpId,1159+      userName: args.userName,1160+      userDisplayName: args.userDisplayName,1161+    });1162+    if (!approved) return { ok: false, error: "user_cancelled" };1163+    return await operation();1164+  } finally {1165+    finish();1166+    firefoxCeremonies.delete(requestId);1167+  }11511168 }11521169 11531170 export async function handleFirefoxPasskeyCreate(@@ -1177,77 +1194,77 @@11771194     return { ok: false, error: "internal_error", message: "No supported algorithm offered" };11781195   }11791196 1180-  const approved = await requestFirefoxApproval({1197+  return requestFirefoxApproval<FirefoxCreateCredentialResponse>({11811198     type: "create",11821199     rpId: payload.rpId,11831200     rpName: payload.rpName,11841201     userName: payload.userName,11851202     userDisplayName: payload.userDisplayName,1186-  });1187-  if (!approved) return { ok: false, error: "user_cancelled" };1203+  }, async () => {11881204 1189-  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1190-  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);1205+    const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1206+    const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11911207 1192-  // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1193-  // registration paths and on later assertions.1194-  const authenticatorData = await buildAuthenticatorData({1195-    rpId: payload.rpId,1196-    flags: 0x4d,1197-    signCount: 0,1198-    credentialId: credentialIdBytes,1199-    publicKeyCose,1200-  });1201-  const attestationObject = createAttestationObject(authenticatorData);1202-1203-  const passkey: PasskeyData = {1204-    rpId: payload.rpId,1205-    rpName: payload.rpName ?? payload.rpId,1206-    credentialId,1207-    publicKeyCose: base64urlEncode(publicKeyCose),1208-    privateKeyJwk,1209-    userHandle: payload.userId,1210-    userName: payload.userName,1211-    userDisplayName: payload.userDisplayName ?? payload.userName,1212-    algorithm,1213-    signCount: 0,1214-    aaguid: base64urlEncode(new Uint8Array(16)),1215-    backupEligible: true,1216-    backupState: false,1217-    browser: "firefox",1218-    transports: ["internal"],1219-    createdAt: Date.now(),1220-  };1221-1222-  const target = findAttachTarget(payload.rpId);1223-  if (target) {1224-    await vault.updateEntry(target.id, { passkey });1225-  } else {1226-    await vault.addEntry({1227-      type: "login",1228-      title: payload.rpName || payload.rpId,1229-      url: `https://${payload.rpId}`,1230-      username: payload.userName,1231-      passkey,1208+    // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1209+    // registration paths and on later assertions.1210+    const authenticatorData = await buildAuthenticatorData({1211+      rpId: payload.rpId,1212+      flags: 0x4d,1213+      signCount: 0,1214+      credentialId: credentialIdBytes,1215+      publicKeyCose,12321216     });1233-  }1234-  await vault.consolidatePasskeyEntries();1217+    const attestationObject = createAttestationObject(authenticatorData);12351218 1236-  return {1237-    ok: true,1238-    credential: {1239-      type: "create",1240-      id: credentialId,1241-      // The content script already encoded clientDataJSON with the page1242-      // origin; round-trip the exact bytes back so the RP sees the same1243-      // hash it computes locally.1244-      clientDataJSON: payload.clientDataJSON,1245-      attestationObject: base64urlEncode(attestationObject),1246-      authenticatorData: base64urlEncode(authenticatorData),1247-      publicKey: base64urlEncode(publicKeySpki),1219+    const passkey: PasskeyData = {1220+      rpId: payload.rpId,1221+      rpName: payload.rpName ?? payload.rpId,1222+      credentialId,1223+      publicKeyCose: base64urlEncode(publicKeyCose),1224+      privateKeyJwk,1225+      userHandle: payload.userId,1226+      userName: payload.userName,1227+      userDisplayName: payload.userDisplayName ?? payload.userName,12481228       algorithm,1249-    },1250-  };1229+      signCount: 0,1230+      aaguid: base64urlEncode(new Uint8Array(16)),1231+      backupEligible: true,1232+      backupState: false,1233+      browser: "firefox",1234+      transports: ["internal"],1235+      createdAt: Date.now(),1236+    };1237+1238+    const target = findAttachTarget(payload.rpId);1239+    if (target) {1240+      await vault.updateEntry(target.id, { passkey });1241+    } else {1242+      await vault.addEntry({1243+        type: "login",1244+        title: payload.rpName || payload.rpId,1245+        url: `https://${payload.rpId}`,1246+        username: payload.userName,1247+        passkey,1248+      });1249+    }1250+    await vault.consolidatePasskeyEntries();1251+1252+    return {1253+      ok: true,1254+      credential: {1255+        type: "create",1256+        id: credentialId,1257+        // The content script already encoded clientDataJSON with the page1258+        // origin; round-trip the exact bytes back so the RP sees the same1259+        // hash it computes locally.1260+        clientDataJSON: payload.clientDataJSON,1261+        attestationObject: base64urlEncode(attestationObject),1262+        authenticatorData: base64urlEncode(authenticatorData),1263+        publicKey: base64urlEncode(publicKeySpki),1264+        algorithm,1265+      },1266+    };1267+  });12511268 }12521269 12531270 export async function handleFirefoxPasskeyGet(@@ -1300,67 +1317,68 @@13001317   const match = candidates[0]!;13011318   const credential = match.passkey;13021319 1303-  if (!credential.privateKeyJwk) {1320+  const privateKeyJwk = credential.privateKeyJwk;1321+  if (!privateKeyJwk) {13041322     // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator13051323     // — no Peach-held private key to sign with. The MAIN-world proxy treats13061324     // no_credentials as a fall-through, which routes to the platform UI.13071325     return { ok: false, error: "no_credentials" };13081326   }13091327 1310-  const approved = await requestFirefoxApproval({1328+  return requestFirefoxApproval<FirefoxGetCredentialResponse>({13111329     type: "get",13121330     rpId: credential.rpId,13131331     rpName: credential.rpName,13141332     userName: credential.userName,13151333     userDisplayName: credential.userDisplayName,1316-  });1317-  if (!approved) return { ok: false, error: "user_cancelled" };1334+  }, async () => {13181335 1319-  const nextSignCount = credential.signCount + 1;1320-  // Legacy Chrome registrations used BE=1 even when the stored flag was false.1321-  // Legacy Firefox registrations used BE=0, so retain their recorded value.1322-  const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;1323-  const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);1324-  const authenticatorData = await buildAuthenticatorData({1325-    rpId: credential.rpId,1326-    flags,1327-    signCount: nextSignCount,1328-  });1336+    const nextSignCount = credential.signCount + 1;1337+    // Legacy Chrome registrations used BE=1 even when the stored flag was false.1338+    // Legacy Firefox registrations used BE=0, so retain their recorded value.1339+    const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;1340+    const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);1341+    const authenticatorData = await buildAuthenticatorData({1342+      rpId: credential.rpId,1343+      flags,1344+      signCount: nextSignCount,1345+    });13291346 1330-  // The content script supplied clientDataJSON bytes built with the page1331-  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1332-  // diverges from what the RP computes.1333-  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1334-  const clientDataHash = new Uint8Array(1335-    await crypto.subtle.digest("SHA-256", clientDataBytes)1336-  );1337-  const signature = await signPasskeyAssertion(1338-    credential.privateKeyJwk,1339-    credential.algorithm,1340-    authenticatorData,1341-    clientDataHash1342-  );1347+    // The content script supplied clientDataJSON bytes built with the page1348+    // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1349+    // diverges from what the RP computes.1350+    const clientDataBytes = new Uint8Array(payload.clientDataJSON);1351+    const clientDataHash = new Uint8Array(1352+      await crypto.subtle.digest("SHA-256", clientDataBytes)1353+    );1354+    const signature = await signPasskeyAssertion(1355+      privateKeyJwk,1356+      credential.algorithm,1357+      authenticatorData,1358+      clientDataHash1359+    );13431360 1344-  await vault.updateEntry(match.id, {1345-    passkey: {1346-      ...credential,1347-      backupEligible,1348-      signCount: nextSignCount,1349-      lastUsedAt: Date.now(),1350-    },1351-  });1361+    await vault.updateEntry(match.id, {1362+      passkey: {1363+        ...credential,1364+        backupEligible,1365+        signCount: nextSignCount,1366+        lastUsedAt: Date.now(),1367+      },1368+    });13521369 1353-  return {1354-    ok: true,1355-    credential: {1356-      type: "get",1357-      id: credential.credentialId,1358-      clientDataJSON: payload.clientDataJSON,1359-      authenticatorData: base64urlEncode(authenticatorData),1360-      signature: base64urlEncode(signature),1361-      userHandle: credential.userHandle,1362-    },1363-  };1370+    return {1371+      ok: true,1372+      credential: {1373+        type: "get",1374+        id: credential.credentialId,1375+        clientDataJSON: payload.clientDataJSON,1376+        authenticatorData: base64urlEncode(authenticatorData),1377+        signature: base64urlEncode(signature),1378+        userHandle: credential.userHandle,1379+      },1380+    };1381+  });13641382 }13651383 13661384 export async function setupPasskeyProxy(): Promise<void> {

view file