// PB-26-108: declined-card metadata must not be offline-enumerable. // // `peach_settings` lives unencrypted in chrome.storage.local, and on Firefox a // content script can read it directly. The declined-card record was a truncated // SHA-256 over `last4|expMonth|expYear` "salted" with a per-vault random value // stored right next to it. A stored salt defeats precomputed tables, not an // attacker holding the file: the candidate space is ~10^4 last-four values x 12 // months x a handful of years, so the card's last four and expiry were // recoverable by brute force in seconds. // // The fix keys the digest with material that is never at rest — a value derived // from the vault root key, available only while unlocked. Storage alone no // longer contains the inputs needed to recompute a record, so enumeration has // nothing to enumerate against. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; function installChromeMock(storage: StorageShape, sessionStorage: StorageShape) { const chrome = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; const keys = Array.isArray(key) ? key : [key]; return keys.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[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k]; }), }, session: { get: vi.fn(async () => ({ ...sessionStorage })), set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { id: "test-extension-id", getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`), }, } as unknown as typeof globalThis.chrome; (globalThis as Record).chrome = chrome; } /** The pre-fix record: unsalted, truncated SHA-256 over the card's own fields. */ async function legacyUnsaltedFingerprint(last4: string, month: string, year: string): Promise { const buf = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(`${last4}|${month}|${year}`), ); return Array.from(new Uint8Array(buf).slice(0, 8)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } describe("PB-26-108: declined-card metadata at rest", () => { let storage: StorageShape; let sessionStorage: StorageShape; beforeEach(() => { vi.resetModules(); storage = {}; sessionStorage = {}; installChromeMock(storage, sessionStorage); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); }); async function freshVault(password: string) { const vault = await import("./vault"); await vault.createVault(password); return vault; } function persistedSettings(): Record { return storage.peach_settings as Record; } it("persists no plaintext domain ledger and no card-fingerprint salt", async () => { const vault = await freshVault("card-meta-pass-12345"); await vault.declineCardSaveOffer("4242", "12", "2029"); const blob = persistedSettings(); expect(blob.manualSummonDomains).toBeUndefined(); expect(blob.manualSummonSalt).toBeUndefined(); expect(blob.cardFingerprintSalt).toBeUndefined(); expect(blob.declinedCardFingerprints).toHaveLength(1); }); // The enumeration attack, run with nothing but the contents of storage. The // attacker enumerates every last-four value for the card's real expiry and // keys the digest with whatever storage offers — the stored salt under the // old scheme, nothing at all under the new one. // // Bug this catches: restoring a stored `cardFingerprintSalt` next to the // records makes this loop terminate on "4242", which is exactly the // ~1.2M-candidate offline recovery the finding is about. it("a declined card cannot be recovered by brute force from storage alone", async () => { const vault = await freshVault("card-brute-pass-12345"); await vault.declineCardSaveOffer("4242", "12", "2029"); const blob = persistedSettings(); const [stored] = blob.declinedCardFingerprints as string[]; const storedSalt = blob.cardFingerprintSalt as string | undefined; expect(stored).toBeDefined(); const digest = async (value: string, salt: string) => { const buf = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(`${salt}:${value}`), ); return Array.from(new Uint8Array(buf).slice(0, 16)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }; for (let candidate = 0; candidate < 10_000; candidate += 1) { const last4 = candidate.toString().padStart(4, "0"); const fingerprint = await legacyUnsaltedFingerprint(last4, "12", "2029"); const guess = storedSalt ? await digest(fingerprint, storedSalt) : fingerprint; expect(guess).not.toBe(stored); } }); // Same card, different vault (therefore different root key) — the record is // a function of secret material, not of the card. it("the same card records a different value under a different root key", async () => { const first = await freshVault("card-key-a-pass-12345"); await first.declineCardSaveOffer("4242", "12", "2029"); const firstRecord = (persistedSettings().declinedCardFingerprints as string[])[0]; vi.resetModules(); storage = {}; sessionStorage = {}; installChromeMock(storage, sessionStorage); const second = await freshVault("card-key-b-pass-12345"); await second.declineCardSaveOffer("4242", "12", "2029"); const secondRecord = (persistedSettings().declinedCardFingerprints as string[])[0]; expect(firstRecord).toBeDefined(); expect(secondRecord).toBeDefined(); expect(secondRecord).not.toBe(firstRecord); }); it("a legacy unsalted record is no longer accepted", async () => { const vault = await freshVault("card-legacy-pass-12345"); // Materialise the settings blob with an unrelated decline, then plant a // legacy-format record the way an upgraded profile would carry one. await vault.declineCardSaveOffer("1111", "01", "2027"); const legacy = await legacyUnsaltedFingerprint("4242", "12", "2029"); (persistedSettings().declinedCardFingerprints as string[]).push(legacy); expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false); }); it("a legacy unsalted record is pruned on the next decline", async () => { const vault = await freshVault("card-prune-pass-12345"); const legacy = await legacyUnsaltedFingerprint("1111", "01", "2027"); await vault.declineCardSaveOffer("4242", "12", "2029"); (persistedSettings().declinedCardFingerprints as string[]).unshift(legacy); await vault.declineCardSaveOffer("9999", "06", "2030"); const records = persistedSettings().declinedCardFingerprints as string[]; expect(records).not.toContain(legacy); expect(records).toHaveLength(2); // The genuine declines survive the prune. expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true); expect(await vault.isCardDeclined("9999", "06", "2030")).toBe(true); }); // Locked means the root key is gone, so no record can be checked. Offers are // not shown while locked, so this is not a functional regression. it("declines cannot be evaluated while the vault is locked", async () => { const vault = await freshVault("card-locked-pass-12345"); await vault.declineCardSaveOffer("4242", "12", "2029"); expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true); vault.lock(); expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false); }); // The feature itself must still work: a declined card stays declined for // exactly that card. it("still distinguishes cards by last four and expiry", async () => { const vault = await freshVault("card-distinct-pass-12345"); await vault.declineCardSaveOffer("4242", "12", "2029"); expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true); expect(await vault.isCardDeclined("4242", "11", "2029")).toBe(false); expect(await vault.isCardDeclined("1111", "12", "2029")).toBe(false); }); });