6ff9bfed174f

merge passkey-only entries into matching login entries on read (consolidates duplicates from pre-v2.11 registrations); fix isPasskeyOnly to check password not username; permit empty-password updates on logins that have a passkey attached; bump v2.11.2

time
2026-04-28 22:29 UTC · 6 months ago
machine
koh2
parent
d5f614d9f6b0
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+1 −1src/background/vault.test.ts+1 −1src/background/vault.ts+101 −9src/shared/types.ts+7 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.11.1",4+  "version": "2.11.2",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.11.1",4+  "version": "2.11.2",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.11.1",3+  "version": "2.11.2",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.test.ts+1 −1
@@ -233,7 +233,7 @@233233         type: "UPDATE_ENTRY",234234         payload: { id: entry.id, patch: { password: "" } },235235       })236-    ).resolves.toMatchObject({ ok: false, error: "password is required for login entries" });236+    ).resolves.toMatchObject({ ok: false, error: "password or passkey required" });237237   });238238 239239   it("DELETE_ENTRY soft-deletes the target entry", async () => {

view file

src/background/vault.test.ts+1 −1
@@ -627,7 +627,7 @@627627     });628628 629629     await expect(vault.updateEntry(entry.id, { password: "" })).rejects.toThrow(630-      /password is required/i630+      /password or passkey required/i631631     );632632     expect(vault.getEntryById(entry.id)?.password).toBe("secret123");633633   });

view file

src/background/vault.ts+101 −9
@@ -357,23 +357,110 @@357357     | VaultPayload;358358 359359   const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360-  const migratedFromLegacy = rawEntries.some(rawNeedsMigration);360+  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);361361 362+  const normalized = rawEntries.map(normalizeEntry);363+  const { merged } = mergePasskeyOnlyIntoLogins(normalized);364+362365   if (Array.isArray(parsed)) {363366     return {364-      entries: parsed.map(normalizeEntry),367+      entries: normalized,365368       duressConfigured: false,366369       migratedFromLegacy: true, // pre-A-2 array shape always needs save367370     };368371   }369372 370373   return {371-    entries: rawEntries.map(normalizeEntry),374+    entries: normalized,372375     duressConfigured: parsed.duressConfigured === true,373-    migratedFromLegacy,376+    migratedFromLegacy: flatMigrationNeeded || merged,374377   };375378 }376379 380+/**381+ * One-time consolidation pass run alongside the legacy passkey migration.382+ *383+ * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`384+ * entry per registration — so users who tested registration multiple times385+ * accumulated several passkey-bearing entries for the same RP, and the386+ * migration to the unified shape carried that duplication forward. This387+ * pass collapses the duplicates:388+ *389+ *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.390+ *   2. Within each group, the most recently-active credential wins (prefer391+ *      lastUsedAt, fall back to passkey.createdAt, fall back to modifiedAt).392+ *   3. If a NON-passkey login entry exists for that RP (matched by url or393+ *      additionalUrls hostname), attach the winning passkey to it and394+ *      soft-delete every passkey-bearing entry in the group.395+ *   4. Otherwise, soft-delete every loser in the group, keeping the winner396+ *      as a passkey-only entry.397+ *398+ * Soft delete (deletedAt) rather than splice — the trash flow keeps the399+ * record around for retention and the user can restore if the heuristic400+ * picked wrong. The `merged` flag goes back to decodePayload so the401+ * unlock path persists the consolidated shape immediately.402+ */403+function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {404+  let merged = false;405+  const now = Date.now();406+407+  const candidates = entries.filter((e) => !e.deletedAt && e.passkey);408+  const byRpId = new Map<string, VaultEntry[]>();409+  for (const e of candidates) {410+    const rpId = e.passkey!.rpId;411+    const list = byRpId.get(rpId);412+    if (list) list.push(e);413+    else byRpId.set(rpId, [e]);414+  }415+416+  for (const [rpId, group] of byRpId) {417+    if (group.length === 0) continue;418+    group.sort((a, b) => {419+      const aT =420+        a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;421+      const bT =422+        b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;423+      return bT - aT;424+    });425+    const winner = group[0]!;426+427+    // Look for a NON-passkey login entry matching this RP. Use the URL428+    // hostname rather than a substring search so a "login.foo.com" entry429+    // doesn't accidentally absorb a passkey for "foo.com".430+    const loginTarget = entries.find((e) => {431+      if (e.deletedAt || e.passkey || e.type !== "login") return false;432+      if (e.url && extractDomain(e.url) === rpId) return true;433+      for (const extra of e.additionalUrls ?? []) {434+        if (extractDomain(extra) === rpId) return true;435+      }436+      return false;437+    });438+439+    if (loginTarget) {440+      loginTarget.passkey = winner.passkey;441+      loginTarget.modifiedAt = now;442+      for (const dup of group) {443+        if (!dup.deletedAt) {444+          dup.deletedAt = now;445+          dup.modifiedAt = now;446+        }447+      }448+      merged = true;449+    } else if (group.length > 1) {450+      for (let i = 1; i < group.length; i += 1) {451+        const dup = group[i]!;452+        if (!dup.deletedAt) {453+          dup.deletedAt = now;454+          dup.modifiedAt = now;455+        }456+      }457+      merged = true;458+    }459+  }460+461+  return { merged };462+}463+377464 async function tryDecryptSlot(378465   vault: StoredVault,379466   key: Uint8Array@@ -871,12 +958,17 @@871958     ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),872959   };873960 874-  // sanitizeEntryPatch maps an empty password to `undefined`. For login875-  // entries that would silently produce a passwordless record — roll back876-  // the merge and refuse.877-  if (entries[idx].type === "login" && !entries[idx].password) {961+  // A login entry must carry SOMETHING fillable — either a password or a962+  // passkey credential. An empty-password login with no passkey is dead963+  // state. Permit the password-clear when a passkey is attached (that's964+  // the legitimate "drop the password, keep the passkey" path).965+  if (966+    entries[idx].type === "login" &&967+    !entries[idx].password &&968+    !entries[idx].passkey969+  ) {878970     entries[idx] = current;879-    throw new Error("password is required for login entries");971+    throw new Error("password or passkey required");880972   }881973 882974   // Card entries must retain their card number

view file

src/shared/types.ts+7 −3
@@ -133,11 +133,15 @@133133 }134134 135135 /**136- * Returns true when an entry is "passkey-only" — has a passkey but no137- * username or password. Such entries render with login fields hidden.136+ * Returns true when an entry is "passkey-only" — has a passkey credential137+ * but no password to fill into a form. Login fields hide for these entries138+ * and the inline autofill selector filters them out (signing in via the139+ * passkey is a separate WebAuthn flow). Username may still be populated140+ * (the migration mirrors passkey.userName onto the entry's username so the141+ * domain-index/search keep working) and is not part of this discriminator.138142  */139143 export function isPasskeyOnly(entry: VaultEntry): boolean {140-  return hasPasskey(entry) && !entry.username && !entry.password;144+  return hasPasskey(entry) && !entry.password;141145 }142146 143147 export interface LegacyVaultEntry {

view file