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
↓ peach-browser-e50cd952.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-chrome-ceremony.test.ts+62 −0src/background/passkey.ts+113 −30manifest.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": {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",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": {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");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;