// T-023: vault.consolidatePasskeyEntries — passkey merge with lastUsed ranking // // Why this test exists: `consolidatePasskeyEntries()` (exposed via the // CONSOLIDATE_PASSKEYS message) calls `mergePasskeyOnlyIntoLogins()`, which: // 1. Groups all passkey-bearing entries by rpId. // 2. Within each group, sorts by lastUsedAt (then createdAt, then modifiedAt). // 3. The "winner" (most-recently-used) passkey is merged into the existing // non-passkey login for the same rpId domain (if one exists). // 4. If no login exists but multiple passkey entries exist for the same rpId, // the loser entries are soft-deleted (winner kept). // // This function is called on every passkey registration (via REGISTER_PASSKEY) // and has ZERO direct test coverage. A regression in the sort order would // incorrectly merge an older passkey, losing a newer one — a silent security // regression. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; function makeChromeStorageMock() { const storage: StorageShape = {}; const chrome = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === "string") return { [key]: storage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = storage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const ks = Array.isArray(keys) ? keys : [keys]; for (const k of ks) delete storage[k]; }), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, } as unknown as typeof globalThis.chrome; return { storage, chrome }; } // Minimal passkey object for test entries function makePasskey(opts: { rpId: string; credentialId: string; lastUsedAt?: number; createdAt?: number; }) { return { rpId: opts.rpId, rpName: opts.rpId, credentialId: opts.credentialId, publicKeyCose: "a401020326", privateKeyJwk: { kty: "EC", crv: "P-256", d: "test", x: "test", y: "test" }, userHandle: "uh", userName: opts.rpId + "-user", userDisplayName: opts.rpId + " User", algorithm: -7 as const, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: false, backupState: false, createdAt: opts.createdAt ?? 1000, lastUsedAt: opts.lastUsedAt, }; } describe("T-023: vault.consolidatePasskeyEntries", () => { let vault: typeof import("./vault"); beforeEach(async () => { vi.resetModules(); const mock = makeChromeStorageMock(); (globalThis as Record).chrome = mock.chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); vault = await import("./vault"); await vault.createVault("consolidate-test-pass"); }); // T-023a: passkey entry for a domain + login entry for the same domain → // consolidatePasskeyEntries() merges the passkey onto the login entry and // soft-deletes the passkey-only entry. // // Bug this catches: if the rpId → URL domain match is broken (e.g., wrong // extractDomain call), the passkey would never merge and the user would see // duplicate entries. it("T-023a: passkey-only entry merges into existing login with matching domain", async () => { // Add a login entry for example.com const login = await vault.addEntry({ type: "login", title: "Example Login", username: "user@example.com", password: "pw123", url: "https://example.com/login", }); // Add a passkey-only entry for the same rpId const passkeyEntry = await vault.addEntry({ type: "login", title: "Example Passkey", username: "user@example.com", password: "", url: "https://example.com", passkey: makePasskey({ rpId: "example.com", credentialId: "cred-001" }), }); await vault.consolidatePasskeyEntries(); const entries = vault.getEntries(); const loginAfter = entries.find((e) => e.id === login.id); const passkeyAfter = vault.getEntryById(passkeyEntry.id); // The login must now have the passkey merged in expect(loginAfter?.passkey).toBeDefined(); expect(loginAfter?.passkey?.credentialId).toBe("cred-001"); // The passkey-only entry must be soft-deleted (not in active entries) expect(passkeyAfter).toBeUndefined(); }); it("preserves TOTP when the passkey entry is consolidated into a login", async () => { const login = await vault.addEntry({ type: "login", title: "Example Login", username: "user@example.com", password: "pw123", url: "https://example.com/login", }); const passkeyEntry = await vault.addEntry({ type: "login", title: "Example Passkey", username: "user@example.com", password: "", url: "https://example.com", totp: "JBSWY3DPEHPK3PXP", totpAlgorithm: "SHA256", totpDigits: 8, totpPeriod: 60, passkey: makePasskey({ rpId: "example.com", credentialId: "cred-totp" }), }); await vault.consolidatePasskeyEntries(); expect(vault.getEntryById(login.id)).toMatchObject({ totp: "JBSWY3DPEHPK3PXP", totpAlgorithm: "SHA256", totpDigits: 8, totpPeriod: 60, }); expect(vault.getEntryById(passkeyEntry.id)).toBeUndefined(); }); // T-023b: two passkey entries for the same rpId, no login → winner is kept // (by lastUsedAt), loser is soft-deleted. // // Bug this catches: an incorrect sort that picks the OLDER passkey as the // winner would retain a stale credential and soft-delete the current one. it("T-023b: two passkeys for same rpId → most recently used is kept, other soft-deleted", async () => { const olderPasskey = await vault.addEntry({ type: "login", title: "Old Passkey", username: "user", password: "", url: "https://github.com", passkey: makePasskey({ rpId: "github.com", credentialId: "cred-old", lastUsedAt: 1000, createdAt: 1000, }), }); const newerPasskey = await vault.addEntry({ type: "login", title: "New Passkey", username: "user", password: "", url: "https://github.com", passkey: makePasskey({ rpId: "github.com", credentialId: "cred-new", lastUsedAt: 9999, createdAt: 9999, }), }); await vault.consolidatePasskeyEntries(); const entries = vault.getEntries(); // The newer passkey must still be in active entries const keptEntry = entries.find((e) => e.passkey?.credentialId === "cred-new" && !e.deletedAt, ); expect(keptEntry).toBeDefined(); // The older passkey must be soft-deleted const deletedEntry = vault.getEntryById(olderPasskey.id); expect(deletedEntry).toBeUndefined(); // Void reference to suppress unused var warning void newerPasskey; }); // T-023c: passkey entry for a domain with no matching login → it stays // (is not merged or deleted). consolidatePasskeyEntries must not touch // single-passkey, no-login groups. it("T-023c: single passkey with no matching login → entry is not deleted", async () => { const passkey = await vault.addEntry({ type: "login", title: "Solo Passkey", username: "user", password: "", url: "https://uniquesite.example.com", passkey: makePasskey({ rpId: "uniquesite.example.com", credentialId: "cred-solo" }), }); await vault.consolidatePasskeyEntries(); const after = vault.getEntryById(passkey.id); expect(after).toBeDefined(); expect(after?.passkey?.credentialId).toBe("cred-solo"); }); // T-023d: passkey on a different domain than any login → no merge. // The domain match must use exact domain extraction, not substring matching. // A passkey for "signin.google.com" must not absorb a login for "google.com". it("T-023d: passkey domain mismatch → no merge (exact domain match, not substring)", async () => { await vault.addEntry({ type: "login", title: "Google Login", username: "user", password: "pw", url: "https://google.com", }); const signinPasskey = await vault.addEntry({ type: "login", title: "Google Signin Passkey", username: "user", password: "", url: "https://signin.google.com", passkey: makePasskey({ rpId: "signin.google.com", credentialId: "cred-signin" }), }); await vault.consolidatePasskeyEntries(); // The passkey-only entry must remain (no login for "signin.google.com") const after = vault.getEntryById(signinPasskey.id); expect(after).toBeDefined(); }); });