d9428e0ddd2d

retain Firefox passkey requests through background delivery failures (v5.3.11)

time
2026-10-08 18:25 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
reliable firefox passkey sign-in
parent
8254614ba3a7
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/passkeys-firefox.test.ts+72 −6src/content/passkeys-firefox.ts+26 −9
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.3.10",4+  "version": "5.3.11",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.10",5+  "version": "5.3.11",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.10",3+  "version": "5.3.11",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/content/passkeys-firefox.test.ts+72 −6
@@ -270,7 +270,7 @@270270       ).resolves.toBeNull();271271     });272272 273-    it("get: falls through when signature is not base64url", async () => {273+    it("get: rejects when signature is not base64url", async () => {274274       // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET275275       mockSendMessage276276         .mockResolvedValueOnce({ ok: true, hasCredentials: true })@@ -281,10 +281,10 @@281281 282282       await expect(283283         navigator.credentials.get(makeGetOptions()),284-      ).resolves.toBeNull();284+      ).rejects.toMatchObject({ name: "NotAllowedError" });285285     });286286 287-    it("get: falls through when authenticatorData is not base64url", async () => {287+    it("get: rejects when authenticatorData is not base64url", async () => {288288       mockSendMessage289289         .mockResolvedValueOnce({ ok: true, hasCredentials: true })290290         .mockResolvedValueOnce({@@ -294,17 +294,17 @@294294 295295       await expect(296296         navigator.credentials.get(makeGetOptions()),297-      ).resolves.toBeNull();297+      ).rejects.toMatchObject({ name: "NotAllowedError" });298298     });299299 300-    it("get: falls through when credential is null", async () => {300+    it("get: rejects when credential is null", async () => {301301       mockSendMessage302302         .mockResolvedValueOnce({ ok: true, hasCredentials: true })303303         .mockResolvedValueOnce({ ok: true, credential: null });304304 305305       await expect(306306         navigator.credentials.get(makeGetOptions()),307-      ).resolves.toBeNull();307+      ).rejects.toMatchObject({ name: "NotAllowedError" });308308     });309309   });310310 @@ -337,6 +337,72 @@337337       mockSendMessage338338         .mockResolvedValueOnce({ ok: true, hasCredentials: true })339339         .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });340+      await expect(navigator.credentials.get(options)).resolves.toBeNull();341+      expect(nativeGet).toHaveBeenCalledWith(options);342+      expect(mockSendMessage).not.toHaveBeenCalled();343+    });344+345+    it("get: waits for a cold background instead of opening Firefox native UI", async () => {346+      mockSendMessage347+        .mockRejectedValueOnce(new Error("Could not establish connection. Receiving end does not exist."))348+        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })349+        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });350+      const result = await navigator.credentials.get(makeGetOptions());351+      expect(result?.id).toBe(validGetCredential().id);352+      expect(nativeGet).not.toHaveBeenCalled();353+    });354+355+    it("get: retries a missing background response before continuing through unlock", async () => {356+      mockSendMessage357+        .mockResolvedValueOnce(undefined)358+        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })359+        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });360+      expect((await navigator.credentials.get(makeGetOptions()))?.id).toBe(validGetCredential().id);361+      expect(nativeGet).not.toHaveBeenCalled();362+    });363+364+    it("get: rejects unavailable Peach instead of silently opening native Firefox UI", async () => {365+      mockSendMessage.mockRejectedValue(new Error("Receiving end does not exist"));366+      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });367+      expect(mockSendMessage).toHaveBeenCalledTimes(3);368+      expect(nativeGet).not.toHaveBeenCalled();369+    });370+371+    it("get: rejects a Peach operation error instead of opening native Firefox UI", async () => {372+      mockSendMessage373+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })374+        .mockResolvedValueOnce({ ok: false, error: "operation_failed" });375+      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });376+      expect(nativeGet).not.toHaveBeenCalled();377+    });378+379+    it("get: never retries a signing request after a delivery error", async () => {380+      mockSendMessage381+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })382+        .mockRejectedValueOnce(new Error("Background disconnected"));383+      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });384+      expect(mockSendMessage).toHaveBeenCalledTimes(2);385+      expect(nativeGet).not.toHaveBeenCalled();386+    });387+388+    it("get: preserves native sign-in when interception is disabled", async () => {389+      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "not_applicable" });390+      const options = makeGetOptions();391+      await expect(navigator.credentials.get(options)).resolves.toBeNull();392+      expect(nativeGet).toHaveBeenCalledWith(options);393+      expect(mockSendMessage).toHaveBeenCalledTimes(1);394+    });395+396+    it("get: rejects a failed credential check without retrying a delivered reply", async () => {397+      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "internal_error" });398+      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });399+      expect(mockSendMessage).toHaveBeenCalledTimes(1);400+      expect(nativeGet).not.toHaveBeenCalled();401+    });402+403+    it("get: preserves an explicit security-key request without contacting Peach", async () => {404+      const options = makeGetOptions();405+      options.publicKey = { ...options.publicKey!, hints: ["security-key"] } as PublicKeyCredentialRequestOptions;340406       await expect(navigator.credentials.get(options)).resolves.toBeNull();341407       expect(nativeGet).toHaveBeenCalledWith(options);342408       expect(mockSendMessage).not.toHaveBeenCalled();

view file

src/content/passkeys-firefox.ts+26 −9
@@ -163,7 +163,10 @@163163     function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {164164       return pagePromise(async () => {165165         // Conditional autofill must remain pending under Firefox's UI and cancellation rules.166-        if (!options?.publicKey || options.mediation === "conditional") return originalGet(options);166+        const hints = (options?.publicKey as (PublicKeyCredentialRequestOptions & { hints?: string[] }) | undefined)?.hints;167+        if (!options?.publicKey || options.mediation === "conditional" || hints?.[0] === "security-key") {168+          return originalGet(options);169+        }167170 168171         try {169172           const publicKey = options.publicKey;@@ -173,14 +176,27 @@173176             type: c.type,174177           }));175178 176-          // A locked vault must unlock before it can check for matching credentials.177-          const has = await sendMessage({178-            type: "PASSKEY_HAS_CREDENTIALS",179-            payload: { rpId, allowCredentials },180-          });181-          if (has?.error !== "vault_locked" && (!has?.ok || !has.hasCredentials)) {179+          // Only retry the read-only check; repeating GET could sign twice.180+          let has: { ok: boolean; error?: string; hasCredentials?: boolean } | undefined;181+          for (let attempt = 0; attempt < 3; attempt++) {182+            try {183+              has = await sendMessage({184+                type: "PASSKEY_HAS_CREDENTIALS",185+                payload: { rpId, allowCredentials },186+              });187+            } catch {188+              // A suspended background may not have a receiver on the first delivery.189+              has = undefined;190+            }191+            if (has) break;192+            if (attempt < 2) await new Promise<void>((resolve) => window.setTimeout(resolve, 150));193+          }194+          if (has?.error === "not_applicable" || (has?.ok && has.hasCredentials === false)) {182195             return originalGet(options);183196           }197+          if (has?.error !== "vault_locked" && !(has?.ok && has.hasCredentials === true)) {198+            throw new window.DOMException("Peach could not connect to its passkey provider. Try again.", "NotAllowedError");199+          }184200 185201           const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));186202           const clientDataJSON = encodeJsonBytes({@@ -216,14 +232,15 @@216232               );217233               return originalGet(options);218234             }219-            return originalGet(options);235+            if (error === "no_credentials" || error === "not_applicable") return originalGet(options);236+            throw new window.DOMException("Peach could not complete this passkey request. Try again.", "NotAllowedError");220237           }221238 222239           const cred = validateGetCredential(response.credential);223240           return buildPublicKeyCredential(cred);224241         } catch (err) {225242           if (err instanceof DOMException && err.name === "NotAllowedError") throw err;226-          return originalGet(options);243+          throw new window.DOMException("Peach could not complete this passkey request. Try again.", "NotAllowedError");227244         }228245       });229246     },

view file