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
7 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 −2
manifest.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": {

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.5",5+  "version": "5.3.6",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.5",3+  "version": "5.3.6",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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     });

view file

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 ?? [];

view file

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 })

view file

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 

view file