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
↓ peach-browser-d9428e0d.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/passkeys-firefox.test.ts+72 −6src/content/passkeys-firefox.ts+26 −9manifest.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": {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",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",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();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 },