062893bfec58

passkey locked-vault auto-resume: wait for inline unlock instead of immediate reject (v2.39.9)

time
2026-05-13 16:26 UTC · 5 months ago
machine
koh2
parent
41a04198eca1
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+7 −1src/background/passkey.ts+42 −3src/content/index.ts+18 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.39.8",4+  "version": "2.39.9",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.39.8",4+  "version": "2.39.9",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": "2.39.8",3+  "version": "2.39.9",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+7 −1
@@ -31,6 +31,7 @@3131   handlePasskeyHasCredentials,3232   initiateFirefoxPasskeyCreate,3333   isPasskeyOfferDeclined,34+  onVaultUnlocked,3435   recordPasskeyOfferDecline,3536   resolvePendingPasskeyRequest,3637   setPasskeyProviderEnabled,@@ -360,6 +361,7 @@360361         await vault.createVault(password);361362         await setupAutoLock();362363         broadcastToContentScripts({ type: "VAULT_UNLOCKED" });364+        onVaultUnlocked();363365         return { ok: true };364366       }365367       case "UNLOCK": {@@ -368,6 +370,7 @@368370         if (ok) {369371           await setupAutoLock();370372           broadcastToContentScripts({ type: "VAULT_UNLOCKED" });373+          onVaultUnlocked();371374           if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());372375         }373376         return { ok };@@ -1195,7 +1198,10 @@11951198         }11961199         try {11971200           const result = await vault.unlockWithKey(masterKeyHex);1198-          if (result.ok) await publishInMemoryVault(result.entries);1201+          if (result.ok) {1202+            onVaultUnlocked();1203+            await publishInMemoryVault(result.entries);1204+          }11991205           return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };12001206         } catch {12011207           return { ok: false, error: "invalid_key" };

view file

src/background/passkey.ts+42 −3
@@ -35,6 +35,18 @@3535   { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }3636 >();3737 38+// Resolvers for GET requests that arrived while the vault was locked and are39+// waiting for the user to unlock before the passkey flow can continue.40+const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();41+42+export function onVaultUnlocked(): void {43+  for (const { resolve, timeoutId } of unlockWaiters.values()) {44+    clearTimeout(timeoutId);45+    resolve(true);46+  }47+  unlockWaiters.clear();48+}49+3850 function isPasskeySupported(): boolean {3951   if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;4052   // Firefox 150+ exposes navigator.credentials.create/get from the popup@@ -446,9 +458,36 @@446458 ): Promise<void> {447459   const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;448460   if (!vault.isUnlocked()) {449-    await openApprovalPopup();450-    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");451-    return;461+    // Summon the inline unlock prompt on the active tab so the user can unlock462+    // without leaving the page. Fall back to the popup if the content script463+    // is unavailable (e.g. restricted URL, script not yet injected).464+    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>465+      chrome.tabs.query({ active: true, currentWindow: true }, resolve)466+    );467+    const activeTab = tabs[0];468+    if (activeTab?.id) {469+      chrome.tabs.sendMessage(activeTab.id, {470+        type: 'SUMMON_AUTOFILL',471+        source: 'passkey-unlock',472+      }).catch(() => { void openApprovalPopup(); });473+    } else {474+      await openApprovalPopup();475+    }476+477+    // Hold the request open until the vault unlocks or the timeout fires.478+    const unlocked = await new Promise<boolean>((resolve) => {479+      const timeoutId = setTimeout(() => {480+        unlockWaiters.delete(request.requestId);481+        resolve(false);482+      }, APPROVAL_TIMEOUT_MS);483+      unlockWaiters.set(request.requestId, { resolve, timeoutId });484+    });485+486+    if (!unlocked || !vault.isUnlocked()) {487+      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");488+      return;489+    }490+    // Vault is now unlocked — fall through to authenticate.452491   }453492 454493   if (!(await isProUser())) {

view file

src/content/index.ts+18 −1
@@ -837,6 +837,7 @@837837   if (msg?.type === "SUMMON_AUTOFILL") {838838     const source = (msg as { type: string; source?: string }).source;839839     const isShortcut = source === "shortcut";840+    const isPasskeyUnlock = source === "passkey-unlock";840841 841842     const activeInput = getDeepActiveInput();842843     const fields = detectLoginFields();@@ -859,6 +860,22 @@859860         lastDismissedField = null;860861         void handleFieldInteraction(target);861862       }863+    } else if (isPasskeyUnlock) {864+      // Vault is locked during a passkey GET request. Show the inline unlock865+      // prompt anchored to whatever element is focused on the page (typically866+      // the passkey/sign-in button the user just tapped).867+      const anchor = (document.activeElement instanceof HTMLElement && document.activeElement !== document.body)868+        ? document.activeElement as unknown as HTMLInputElement869+        : null;870+      if (anchor) {871+        currentDismiss?.();872+        currentAnchorField = null;873+        currentDismiss = showAutofillSelector(anchor, [], () => {}, {874+          domain: getPageDomain(),875+          locked: true,876+          onDismiss: () => {},877+        });878+      }862879     } else if (isShortcut) {863880       // Shortcut on a page with no login form — show clipboard-copy search mode.864881       showSearchPalette();@@ -872,7 +889,7 @@872889       }873890     }874891 875-    sendResponse({ ok: hasForm || isShortcut });892+    sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });876893     return true;877894   }878895   if (msg?.type === "PASSKEY_PRO_GATE") {

view file