12345678910111213141516import { beforeEach, describe, expect, it, vi } from "vitest";1718type StorageShape = Record<string, unknown>;1920function installChromeMock(storage: StorageShape, sessionStorage: StorageShape) {21 const chrome = {22 storage: {23 local: {24 get: vi.fn(async (key?: string | string[]) => {25 if (!key) return { ...storage };26 const keys = Array.isArray(key) ? key : [key];27 return keys.reduce<StorageShape>((acc, k) => {28 acc[k] = storage[k];29 return acc;30 }, {});31 }),32 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),33 remove: vi.fn(async (keys: string | string[]) => {34 for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];35 }),36 },37 session: {38 get: vi.fn(async () => ({ ...sessionStorage })),39 set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),40 remove: vi.fn(async () => {}),41 },42 },43 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },44 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },45 runtime: {46 id: "test-extension-id",47 getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),48 },49 } as unknown as typeof globalThis.chrome;50 (globalThis as Record<string, unknown>).chrome = chrome;51}525354async function legacyUnsaltedFingerprint(last4: string, month: string, year: string): Promise<string> {55 const buf = await crypto.subtle.digest(56 "SHA-256",57 new TextEncoder().encode(`${last4}|${month}|${year}`),58 );59 return Array.from(new Uint8Array(buf).slice(0, 8))60 .map((b) => b.toString(16).padStart(2, "0"))61 .join("");62}6364describe("PB-26-108: declined-card metadata at rest", () => {65 let storage: StorageShape;66 let sessionStorage: StorageShape;6768 beforeEach(() => {69 vi.resetModules();70 storage = {};71 sessionStorage = {};72 installChromeMock(storage, sessionStorage);73 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));74 });7576 async function freshVault(password: string) {77 const vault = await import("./vault");78 await vault.createVault(password);79 return vault;80 }8182 function persistedSettings(): Record<string, unknown> {83 return storage.peach_settings as Record<string, unknown>;84 }8586 it("persists no plaintext domain ledger and no card-fingerprint salt", async () => {87 const vault = await freshVault("card-meta-pass-12345");88 await vault.declineCardSaveOffer("4242", "12", "2029");8990 const blob = persistedSettings();91 expect(blob.manualSummonDomains).toBeUndefined();92 expect(blob.manualSummonSalt).toBeUndefined();93 expect(blob.cardFingerprintSalt).toBeUndefined();94 expect(blob.declinedCardFingerprints).toHaveLength(1);95 });9697 98 99 100 101 102 103 104 105 it("a declined card cannot be recovered by brute force from storage alone", async () => {106 const vault = await freshVault("card-brute-pass-12345");107 await vault.declineCardSaveOffer("4242", "12", "2029");108109 const blob = persistedSettings();110 const [stored] = blob.declinedCardFingerprints as string[];111 const storedSalt = blob.cardFingerprintSalt as string | undefined;112 expect(stored).toBeDefined();113114 const digest = async (value: string, salt: string) => {115 const buf = await crypto.subtle.digest(116 "SHA-256",117 new TextEncoder().encode(`${salt}:${value}`),118 );119 return Array.from(new Uint8Array(buf).slice(0, 16))120 .map((b) => b.toString(16).padStart(2, "0"))121 .join("");122 };123124 for (let candidate = 0; candidate < 10_000; candidate += 1) {125 const last4 = candidate.toString().padStart(4, "0");126 const fingerprint = await legacyUnsaltedFingerprint(last4, "12", "2029");127 const guess = storedSalt ? await digest(fingerprint, storedSalt) : fingerprint;128 expect(guess).not.toBe(stored);129 }130 });131132 133 134 it("the same card records a different value under a different root key", async () => {135 const first = await freshVault("card-key-a-pass-12345");136 await first.declineCardSaveOffer("4242", "12", "2029");137 const firstRecord = (persistedSettings().declinedCardFingerprints as string[])[0];138139 vi.resetModules();140 storage = {};141 sessionStorage = {};142 installChromeMock(storage, sessionStorage);143 const second = await freshVault("card-key-b-pass-12345");144 await second.declineCardSaveOffer("4242", "12", "2029");145 const secondRecord = (persistedSettings().declinedCardFingerprints as string[])[0];146147 expect(firstRecord).toBeDefined();148 expect(secondRecord).toBeDefined();149 expect(secondRecord).not.toBe(firstRecord);150 });151152 it("a legacy unsalted record is no longer accepted", async () => {153 const vault = await freshVault("card-legacy-pass-12345");154 155 156 await vault.declineCardSaveOffer("1111", "01", "2027");157 const legacy = await legacyUnsaltedFingerprint("4242", "12", "2029");158 (persistedSettings().declinedCardFingerprints as string[]).push(legacy);159160 expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);161 });162163 it("a legacy unsalted record is pruned on the next decline", async () => {164 const vault = await freshVault("card-prune-pass-12345");165 const legacy = await legacyUnsaltedFingerprint("1111", "01", "2027");166 await vault.declineCardSaveOffer("4242", "12", "2029");167 (persistedSettings().declinedCardFingerprints as string[]).unshift(legacy);168169 await vault.declineCardSaveOffer("9999", "06", "2030");170171 const records = persistedSettings().declinedCardFingerprints as string[];172 expect(records).not.toContain(legacy);173 expect(records).toHaveLength(2);174 175 expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);176 expect(await vault.isCardDeclined("9999", "06", "2030")).toBe(true);177 });178179 180 181 it("declines cannot be evaluated while the vault is locked", async () => {182 const vault = await freshVault("card-locked-pass-12345");183 await vault.declineCardSaveOffer("4242", "12", "2029");184 expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);185186 vault.lock();187188 expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(false);189 });190191 192 193 it("still distinguishes cards by last four and expiry", async () => {194 const vault = await freshVault("card-distinct-pass-12345");195 await vault.declineCardSaveOffer("4242", "12", "2029");196197 expect(await vault.isCardDeclined("4242", "12", "2029")).toBe(true);198 expect(await vault.isCardDeclined("4242", "11", "2029")).toBe(false);199 expect(await vault.isCardDeclined("1111", "12", "2029")).toBe(false);200 });201});