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
↓ peach-browser-6ff9bfed.tar.gzatlas at this save
files changed7 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 −3manifest.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": {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",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": {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 () => {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 });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 numbersrc/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 {