e50cd95264aa

fix: complete*Request safe-wraps 'Invalid requestId' from Chrome-cancelled requests (conditional-UI gets cancel fast); onRequestCanceled now clears unlock waiters; log every proxy entry point; 3 new tests (v3.6.109)

time
2026-05-28 18:09 UTC · 5 months ago
machine
koh2
parent
5c2e4bb0970a
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-chrome-ceremony.test.ts+62 −0src/background/passkey.ts+113 −30
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.108",5+  "version": "3.6.109",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.108",4+  "version": "3.6.109",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.108",3+  "version": "3.6.109",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/passkey-chrome-ceremony.test.ts+62 −0
@@ -614,6 +614,68 @@614614   // onRequestCanceled — popup-closed / RP-cancelled cleanup.615615   // ─────────────────────────────────────────────────────────────────────616616 617+  // ─────────────────────────────────────────────────────────────────────618+  // "Invalid requestId" survival — conditional-UI gets cancel fast.619+  // ─────────────────────────────────────────────────────────────────────620+621+  it("rejectGet swallows 'Invalid requestId' from a cancelled conditional-UI get without throwing", async () => {622+    const vault = await import("./vault");623+    await vault.createVault("correct-pass-12345");624+625+    // Simulate Chrome cancelling the request between handleGetRequest626+    // running and rejectGet calling completeGetRequest.627+    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy628+      .completeGetRequest as unknown as ReturnType<typeof vi.fn>;629+    ce.mockImplementationOnce(async () => {630+      throw new Error("Invalid requestId");631+    });632+633+    // No matching passkey for this rpId → goes straight to rejectGet,634+    // which must NOT propagate the Invalid requestId error.635+    let resolved = false;636+    proxyListeners.get!({637+      requestId: 999,638+      requestDetailsJson: JSON.stringify({ rpId: "unknown.example", challenge: "x", allowCredentials: [] }),639+    });640+    // Allow the handler to finish; if it threw, the test would unhandle-reject.641+    await new Promise((r) => setTimeout(r, 30));642+    resolved = true;643+    expect(resolved).toBe(true);644+  });645+646+  it("rejectCreate swallows 'Invalid requestId' from a cancelled create", async () => {647+    const vault = await import("./vault");648+    await vault.createVault("correct-pass-12345");649+650+    const ce = (globalThis as { chrome: typeof chrome }).chrome.webAuthenticationProxy651+      .completeCreateRequest as unknown as ReturnType<typeof vi.fn>;652+    ce.mockImplementationOnce(async () => {653+      throw new Error("Invalid requestId");654+    });655+656+    // Force the early rp.id missing path so rejectCreate runs first.657+    fireCreate({ rp: {}, user: { id: "x", name: "u" }, challenge: "c", pubKeyCredParams: [{ alg: -7, type: "public-key" }] }, 998);658+    await new Promise((r) => setTimeout(r, 30));659+    // No assertions on completes — the assertion is that the handler did660+    // not throw an unhandled rejection (the test would fail under vitest's661+    // unhandled-rejection guard otherwise).662+  });663+664+  it("onRequestCanceled clears a parked unlock waiter so the handler exits promptly", async () => {665+    const vault = await import("./vault");666+    await vault.createVault("correct-pass-12345");667+    vault.lock();668+669+    fireCreate(buildCreateOptions(), 997);670+    // Wait for the request to park.671+    await new Promise((r) => setTimeout(r, 30));672+673+    proxyListeners.cancel!(997);674+675+    const done = await waitForCreateComplete(997, 500);676+    expect(done.error).toMatchObject({ name: "NotAllowedError" });677+  });678+617679   it("onRequestCanceled resolves a still-pending approval as denied", async () => {618680     const vault = await import("./vault");619681     await vault.createVault("correct-pass-12345");

view file

src/background/passkey.ts+113 −30
@@ -38,6 +38,21 @@3838 // waiting for the user to unlock before the passkey flow can continue.3939 const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4040 41+// Tracks requestIds that Chrome cancelled on us (via onRequestCanceled) while42+// we were still mid-handler. The complete*Request calls would otherwise reject43+// with "Invalid requestId"; the handler checks this set before any work.44+const cancelledRequests = new Set<number>();45+function markCancelled(requestId: number): void {46+  cancelledRequests.add(requestId);47+  // Bounded GC — never grow forever. Old IDs that haven't been polled in a48+  // long time can be forgotten without harm because the Chrome-side request49+  // is already gone.50+  if (cancelledRequests.size > 256) {51+    const iter = cancelledRequests.values();52+    for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);53+  }54+}55+4156 export function onVaultUnlocked(): void {4257   for (const { resolve, timeoutId } of unlockWaiters.values()) {4358     clearTimeout(timeoutId);@@ -164,15 +179,35 @@164179   }165180 }166181 182+/**183+ * Some ceremonies — especially conditional-UI `navigator.credentials.get`184+ * calls — get cancelled by Chrome before we finish processing. Calling185+ * complete*Request on a cancelled requestId rejects with "Invalid186+ * requestId". That is expected, not exceptional: swallow it. Re-throwing187+ * would surface an uncaught promise rejection in the SW and cascade through188+ * the catch wrappers in setupPasskeyProxy.189+ */190+function isInvalidRequestIdError(err: unknown): boolean {191+  return err instanceof Error && /invalid requestid/i.test(err.message);192+}193+167194 async function rejectCreate(168195   requestId: number,169196   name: string,170197   message = ""171198 ): Promise<void> {172-  await chrome.webAuthenticationProxy.completeCreateRequest({173-    requestId,174-    error: { name, message },175-  });199+  try {200+    await chrome.webAuthenticationProxy.completeCreateRequest({201+      requestId,202+      error: { name, message },203+    });204+  } catch (err) {205+    if (isInvalidRequestIdError(err)) {206+      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });207+      return;208+    }209+    throw err;210+  }176211 }177212 178213 async function rejectGet(@@ -180,10 +215,18 @@180215   name: string,181216   message = ""182217 ): Promise<void> {183-  await chrome.webAuthenticationProxy.completeGetRequest({184-    requestId,185-    error: { name, message },186-  });218+  try {219+    await chrome.webAuthenticationProxy.completeGetRequest({220+      requestId,221+      error: { name, message },222+    });223+  } catch (err) {224+    if (isInvalidRequestIdError(err)) {225+      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });226+      return;227+    }228+    throw err;229+  }187230 }188231 189232 export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {@@ -500,24 +543,33 @@500543     });501544   }502545 503-  await chrome.webAuthenticationProxy.completeCreateRequest({504-    requestId: request.requestId,505-    responseJson: JSON.stringify({506-      id: passkey.credentialId,507-      rawId: passkey.credentialId,508-      type: "public-key",509-      response: {510-        clientDataJSON: base64urlEncode(clientDataJSON),511-        authenticatorData: base64urlEncode(authData),512-        attestationObject: base64urlEncode(attestationObject),513-        transports: ["internal"],514-        publicKey: base64urlEncode(publicKeySpki),515-        publicKeyAlgorithm: algorithm,516-      },517-      authenticatorAttachment: "platform",518-      clientExtensionResults: {},519-    }),520-  });546+  try {547+    await chrome.webAuthenticationProxy.completeCreateRequest({548+      requestId: request.requestId,549+      responseJson: JSON.stringify({550+        id: passkey.credentialId,551+        rawId: passkey.credentialId,552+        type: "public-key",553+        response: {554+          clientDataJSON: base64urlEncode(clientDataJSON),555+          authenticatorData: base64urlEncode(authData),556+          attestationObject: base64urlEncode(attestationObject),557+          transports: ["internal"],558+          publicKey: base64urlEncode(publicKeySpki),559+          publicKeyAlgorithm: algorithm,560+        },561+        authenticatorAttachment: "platform",562+        clientExtensionResults: {},563+      }),564+    });565+    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });566+  } catch (err) {567+    if (isInvalidRequestIdError(err)) {568+      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });569+      return;570+    }571+    throw err;572+  }521573 }522574 523575 function broadcastPasskeyProGate(): void {@@ -697,10 +749,19 @@697749     clientExtensionResults: {},698750   });699751 700-  await chrome.webAuthenticationProxy.completeGetRequest({701-    requestId: request.requestId,702-    responseJson,703-  });752+  try {753+    await chrome.webAuthenticationProxy.completeGetRequest({754+      requestId: request.requestId,755+      responseJson,756+    });757+    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });758+  } catch (err) {759+    if (isInvalidRequestIdError(err)) {760+      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });761+      return;762+    }763+    throw err;764+  }704765 }705766 706767 // ── Firefox path ────────────────────────────────────────────────────────@@ -1193,7 +1254,15 @@11931254       });11941255     });11951256     chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1257+      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });11961258       void handleCreateRequest(requestInfo).catch(async (error) => {1259+        // "Invalid requestId" means Chrome already cancelled — handlers will1260+        // log it themselves and short-circuit, but if it propagates out1261+        // (e.g., from a still-in-flight branch) don't double-complete.1262+        if (isInvalidRequestIdError(error)) {1263+          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1264+          return;1265+        }11971266         console.error("Peach passkey create failed", error);11981267         await rejectCreate(11991268           requestInfo.requestId,@@ -1203,7 +1272,12 @@12031272       });12041273     });12051274     chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1275+      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });12061276       void handleGetRequest(requestInfo).catch(async (error) => {1277+        if (isInvalidRequestIdError(error)) {1278+          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1279+          return;1280+        }12071281         console.error("Peach passkey get failed", error);12081282         await rejectGet(12091283           requestInfo.requestId,@@ -1213,6 +1287,15 @@12131287       });12141288     });12151289     chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1290+      console.log("[Peach passkey] onRequestCanceled", { requestId });1291+      markCancelled(requestId);1292+      // Release any unlock waiter parked for this request.1293+      const waiter = unlockWaiters.get(requestId);1294+      if (waiter) {1295+        clearTimeout(waiter.timeoutId);1296+        waiter.resolve(false);1297+        unlockWaiters.delete(requestId);1298+      }12161299       void resolvePendingPasskeyRequest(requestId, false);12171300     });12181301     listenersRegistered = true;

view file