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
↓ peach-browser-2627a8b3.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-firefox-proxy.test.ts+43 −0src/background/passkey.ts+144 −126manifest.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": {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",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",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");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> {