e013c493c758
wait for Peach unlock before Firefox passkey sign-in (v5.3.6)
- time
- 2026-10-08 17:12 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- gpt-6
- session
- unlock firefox passkey sign-in
- parent
- a22c047efc1a
↓ peach-browser-e013c493.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-firefox-proxy.test.ts+38 −3src/background/passkey.ts+14 −5src/content/passkeys-firefox.test.ts+17 −0src/content/passkeys-firefox.ts+2 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.3.5",4+ "version": "5.3.6",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.5",5+ "version": "5.3.6",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.5",3+ "version": "5.3.6",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/passkey-firefox-proxy.test.ts+38 −3
@@ -233,7 +233,7 @@233233 ).resolves.toMatchObject({ ok: true, hasCredentials: true });234234 });235235 236- it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {236+ it("PASSKEY_HAS_CREDENTIALS reports a locked vault without enumerating credentials", async () => {237237 const vault = await import("./vault");238238 await vault.createVault("correct-pass-12345");239239 vault.lock();@@ -243,7 +243,7 @@243243 type: "PASSKEY_HAS_CREDENTIALS",244244 payload: { rpId: "github.com", allowCredentials: [] },245245 })246- ).resolves.toMatchObject({ ok: true, hasCredentials: false });246+ ).resolves.toMatchObject({ ok: false, error: "vault_locked" });247247 });248248 249249 it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {@@ -434,9 +434,38 @@434434 ).resolves.toMatchObject({ ok: false, error: "no_credentials" });435435 });436436 437- it.each([["chrome", true], ["firefox", false]] as const)("PASSKEY_GET preserves registration backup eligibility for a legacy %s passkey", async (browser, expectedBackupEligible) => {437+ it.each(["unlock", "dismiss", "timeout"] as const)("PASSKEY_GET waits for a locked vault: %s", async (outcome) => {438438 const vault = await import("./vault");439+ const passkey = await import("./passkey");439440 await vault.createVault("correct-pass-12345");441+ vault.lock();442+ if (outcome === "timeout") vi.useFakeTimers();443+ const resultPromise = dispatch({444+ type: "PASSKEY_GET",445+ payload: { rpId: "github.com", challenge: "x", origin: "https://github.com", clientDataJSON: Array.from(new TextEncoder().encode("{}")), allowCredentials: [] },446+ });447+ await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());448+ let settled = false;449+ void resultPromise.then(() => { settled = true; });450+ await Promise.resolve();451+ expect(settled).toBe(false);452+ if (outcome === "unlock") {453+ await vault.unlock("correct-pass-12345");454+ passkey.onVaultUnlocked();455+ await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "no_credentials" });456+ } else if (outcome === "dismiss") {457+ await passkey.denyAllPendingPasskeyRequests();458+ await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });459+ } else {460+ await vi.advanceTimersByTimeAsync(60_000);461+ await expect(resultPromise).resolves.toMatchObject({ ok: false, error: "user_cancelled" });462+ vi.useRealTimers();463+ }464+ });465+466+ it.each([["chrome", true, false], ["firefox", false, false], ["chrome", true, true]] as const)("PASSKEY_GET preserves legacy %s backup eligibility %s (locked: %s)", async (browser, expectedBackupEligible, locked) => {467+ const vault = await import("./vault");468+ await vault.createVault("correct-pass-12345");440469 const generated = await generatePasskeyKeypair(-7);441470 const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));442471 const entry = await vault.addEntry({@@ -457,11 +486,17 @@457486 JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })458487 ));459488 489+ if (locked) vault.lock();460490 const resultPromise = dispatch({461491 type: "PASSKEY_GET",462492 payload: { rpId: "github.com", challenge, origin, clientDataJSON, allowCredentials: [{ id: credentialId, type: "public-key" }] },463493 });464494 const passkey = await import("./passkey");495+ if (locked) {496+ await vi.waitFor(() => expect(chrome.action.openPopup).toHaveBeenCalled());497+ await vault.unlock("correct-pass-12345");498+ passkey.onVaultUnlocked();499+ }465500 await vi.waitFor(async () => {466501 expect(await passkey.getPendingPasskeyRequest()).not.toBeNull();467502 });src/background/passkey.ts+14 −5
@@ -1106,10 +1106,8 @@11061106 return { ok: true, hasCredentials: false };11071107 }11081108 if (!vault.isUnlocked()) {1109- // Cannot enumerate without the vault key. The MAIN-world proxy treats a1110- // false answer as "fall through to the platform" — exactly the right1111- // outcome when Peach is locked: the user gets the native UI instead.1112- return { ok: true, hasCredentials: false };1109+ // A locked vault cannot answer whether a credential exists until unlock.1110+ return { ok: false, error: "vault_locked" };11131111 }11141112 const passkeys = listPasskeyEntries().filter(11151113 (entry) => entry.passkey.rpId === payload.rpId@@ -1271,8 +1269,19 @@12711269 return { ok: false, error: "pro_required" };12721270 }12731271 if (!vault.isUnlocked()) {1272+ const requestId = randomRequestId();1273+ // Register before opening the popup so an immediate unlock cannot be missed.1274+ const unlocked = new Promise<boolean>((resolve) => {1275+ const timeoutId = setTimeout(() => {1276+ unlockWaiters.delete(requestId);1277+ resolve(false);1278+ }, APPROVAL_TIMEOUT_MS);1279+ unlockWaiters.set(requestId, { resolve, timeoutId });1280+ });12741281 await openApprovalPopup();1275- return { ok: false, error: "vault_locked" };1282+ if (!(await unlocked) || !vault.isUnlocked()) {1283+ return { ok: false, error: "user_cancelled" };1284+ }12761285 }12771286 12781287 const allow = payload.allowCredentials ?? [];src/content/passkeys-firefox.test.ts+17 −0
@@ -454,6 +454,23 @@454454 ).resolves.toBeNull();455455 });456456 457+ it("get: continues through unlock when the credential check reports vault_locked", async () => {458+ mockSendMessage459+ .mockResolvedValueOnce({ ok: false, error: "vault_locked" })460+ .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });461+ const result = await navigator.credentials.get(makeGetOptions());462+ expect(result?.id).toBe(validGetCredential().id);463+ expect(nativeGet).not.toHaveBeenCalled();464+ });465+466+ it("get: rejects an abandoned unlock without opening native Firefox UI", async () => {467+ mockSendMessage468+ .mockResolvedValueOnce({ ok: false, error: "vault_locked" })469+ .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });470+ await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });471+ expect(nativeGet).not.toHaveBeenCalled();472+ });473+457474 it("get: falls through on no_credentials after has-credentials pre-check", async () => {458475 mockSendMessage459476 .mockResolvedValueOnce({ ok: true, hasCredentials: true })src/content/passkeys-firefox.ts+2 −2
@@ -173,12 +173,12 @@173173 type: c.type,174174 }));175175 176- // Cheap pre-check before showing any Peach UI.176+ // A locked vault must unlock before it can check for matching credentials.177177 const has = await sendMessage({178178 type: "PASSKEY_HAS_CREDENTIALS",179179 payload: { rpId, allowCredentials },180180 });181- if (!has?.ok || !has.hasCredentials) {181+ if (has?.error !== "vault_locked" && (!has?.ok || !has.hasCredentials)) {182182 return originalGet(options);183183 }184184