vault.test.ts

50.4 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { decrypt } from "./aes";3import { deriveKey } from "../crypto/argon2";4import type { StoredVault } from "../shared/types";56type StorageShape = Record<string, unknown>;78describe("background vault", () => {9  let storage: StorageShape;10  let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1112  beforeEach(() => {13    vi.resetModules();14    storage = {};15    alarms = { created: [], cleared: [] };1617    const chromeMock = {18      storage: {19        local: {20          get: vi.fn(async (key?: string | string[]) => {21            if (!key) return storage;22            if (typeof key === "string") {23              return { [key]: storage[key] };24            }2526            return key.reduce<Record<string, unknown>>((result, entry) => {27              result[entry] = storage[entry];28              return result;29            }, {});30          }),31          set: vi.fn(async (next: Record<string, unknown>) => {32            Object.assign(storage, next);33          }),34          remove: vi.fn(async (keys: string | string[]) => {35            const list = Array.isArray(keys) ? keys : [keys];36            for (const key of list) {37              delete storage[key];38            }39          }),40        },41      },42      alarms: {43        clear: vi.fn((name: string) => {44          alarms.cleared.push(name);45        }),46        create: vi.fn((name: string, info: { delayInMinutes: number }) => {47          alarms.created.push({ name, delayInMinutes: info.delayInMinutes });48        }),49        onAlarm: { addListener: vi.fn() },50      },51      idle: {52        setDetectionInterval: vi.fn(),53        onStateChanged: { addListener: vi.fn() },54      },55    } as unknown as typeof chrome;5657    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5859    // Prevent fire-and-forget breach-check fetches from leaking into other tests.60    vi.stubGlobal("fetch", vi.fn(async () => {61      throw new Error("network disabled in tests");62    }));63  });6465  it("creates a vault and stores encrypted payload", async () => {66    const vault = await import("./vault");6768    await vault.createVault("correct horse battery staple");6970    expect(storage.peach_vault_a).toBeDefined();71    expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();72    expect(vault.getEntries()).toEqual([]);73    expect(alarms.created).toContainEqual({74      name: "autolock",75      delayInMinutes: 5,76    });77  });7879  it("writes both slots A and B on vault creation", async () => {80    const vault = await import("./vault");81    await vault.createVault("correct horse battery staple");8283    expect(storage.peach_vault_a).toBeDefined();84    expect(storage.peach_vault_b).toBeDefined();85    expect(storage.peach_duress).toBeUndefined();86    expect(storage.peach_vault).toBeUndefined();87    expect(storage.peach_decoy_vault).toBeUndefined();88  });8990  it("wraps the derived vault key for PIN unlock and invalidates it on password change", async () => {91    const vault = await import("./vault");92    const password = "correct horse battery staple";9394    await vault.createVault(password);95    expect(await vault.setupPinUnlock(password, "123")).toBe(false);96    expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);97    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);98    expect((storage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();99100    vault.lock();101    expect(await vault.unlockWithPin("000000")).toBe(false);102    expect(await vault.unlockWithPin("1234")).toBe(true);103104    expect(await vault.changePassword(password, "new correct horse battery"))105      .toBe(true);106    vault.lock();107    expect(await vault.unlockWithPin("1234")).toBe(false);108    expect(await vault.unlock("new correct horse battery")).toBe(true);109  });110111  it("slot A and slot B have matching ciphertext size at fresh install", async () => {112    const vault = await import("./vault");113    await vault.createVault("correct horse battery staple");114115    const a = storage.peach_vault_a as StoredVault;116    const b = storage.peach_vault_b as StoredVault;117    expect(a.ciphertext.length).toBe(b.ciphertext.length);118  });119120  it("does not expose a plaintext duress flag after setupDuress", async () => {121    const vault = await import("./vault");122    await vault.createVault("real-password-12345");123    await vault.setupDuress("decoy-password-67890");124125    expect(storage.peach_duress).toBeUndefined();126    expect(storage.peach_decoy_vault).toBeUndefined();127    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));128    // Slot-neutral keys + optional settings + vault history (snapshot of the129    // pre-setupDuress real-mode blob). No exposed duress flag.130    for (const key of keys) {131      expect(132        [133          "peach_vault_a",134          "peach_vault_b",135          "peach_settings",136          "peach_vault_history",137          "peach_last_modified_at",138        ]139      ).toContain(key);140    }141    expect(keys).toContain("peach_vault_a");142    expect(keys).toContain("peach_vault_b");143  });144145  it("unlocks with real password and loads real entries", async () => {146    const vault = await import("./vault");147    await vault.createVault("real-password-12345");148    await vault.addEntry({149      title: "GitHub",150      username: "june",151      password: "realsecret",152      url: "github.com",153    });154    vault.lock();155156    const ok = await vault.unlock("real-password-12345");157    expect(ok).toBe(true);158    expect(vault.getEntries()).toHaveLength(1);159    expect(vault.getEntries()[0].title).toBe("GitHub");160  });161162  it("unlocks with duress password and loads empty entries", async () => {163    const vault = await import("./vault");164    await vault.createVault("real-password-12345");165    await vault.addEntry({166      title: "GitHub",167      username: "june",168      password: "realsecret",169      url: "github.com",170    });171    await vault.setupDuress("decoy-password-67890");172    vault.lock();173174    const ok = await vault.unlock("decoy-password-67890");175    expect(ok).toBe(true);176    expect(vault.getEntries()).toEqual([]);177  });178179  it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {180    const vault = await import("./vault");181    await vault.createVault("real-password-12345");182    await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });183    await vault.setupDuress("decoy-password-67890");184185    // Baseline: the real vault does have a sync identity.186    expect(vault.getRootKeyHex()).toBeTruthy();187    vault.lock();188189    expect(await vault.unlock("decoy-password-67890")).toBe(true);190    // A second vault_id would be a duress indicator: a coerced user opening the191    // decoy would make sync visibly fail on their other devices. The decoy path192    // never derives a root key, so there is nothing to derive one from.193    expect(vault.getRootKeyHex()).toBeNull();194195    // Every sync adapter operation therefore fails closed rather than syncing,196    // manifesting, or revealing an entry count.197    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");198    const adapter = createSyncVaultAdapter();199    await expect(adapter.computeVaultId()).rejects.toThrow();200    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();201    vault.lock();202  });203204  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {205    // The unlock path must derive keys for both slots unconditionally so a206    // real-mode unlock and a decoy-mode unlock both spend roughly the same207    // wall-clock time. Verified here by spying on deriveKey and asserting it208    // is called twice (once per slot) on every unlock attempt.209    const argon2 = await import("../crypto/argon2");210    const deriveSpy = vi.spyOn(argon2, "deriveKey");211212    const vault = await import("./vault");213    await vault.createVault("real-password-12345");214    await vault.setupDuress("decoy-password-67890");215    vault.lock();216217    deriveSpy.mockClear();218    await vault.unlock("real-password-12345");219    expect(deriveSpy).toHaveBeenCalledTimes(2);220    vault.lock();221222    deriveSpy.mockClear();223    await vault.unlock("decoy-password-67890");224    expect(deriveSpy).toHaveBeenCalledTimes(2);225    vault.lock();226227    deriveSpy.mockClear();228    await vault.unlock("wrong-password-xxxxx");229    expect(deriveSpy).toHaveBeenCalledTimes(2);230231    deriveSpy.mockRestore();232  });233234  it("rejects wrong passwords", async () => {235    const vault = await import("./vault");236    await vault.createVault("real-password-12345");237    vault.lock();238239    const ok = await vault.unlock("nope-nope-nope");240    expect(ok).toBe(false);241  });242243  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {244    const vault = await import("./vault");245    await vault.createVault("real-password-12345");246    await vault.setupDuress("decoy-password-67890");247    vault.lock();248249    await vault.unlock("real-password-12345");250    expect(await vault.isDuressEnabled()).toBe(true);251252    await vault.disableDuress();253    expect(await vault.isDuressEnabled()).toBe(false);254    vault.lock();255256    await vault.unlock("real-password-12345");257    expect(await vault.isDuressEnabled()).toBe(false);258  });259260  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {261    const vault = await import("./vault");262263    await vault.createVault("correct horse battery staple");264    const entry = await vault.addEntry({265      title: "GitHub",266      username: "june",267      password: "secret123",268      url: "github.com",269    });270271    await vault.deleteEntry(entry.id);272273    expect(vault.getEntries()).toEqual([]);274275    const stored = storage.peach_vault_a as StoredVault;276    const salt = Uint8Array.from(277      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))278    );279    const key = await deriveKey("correct horse battery staple", salt);280    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");281    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };282283    expect(parsed.entries).toHaveLength(1);284    expect(parsed.entries[0].id).toBe(entry.id);285    expect(typeof parsed.entries[0].deletedAt).toBe("number");286  });287288  it("accepts imported entries without a username or url", async () => {289    const vault = await import("./vault");290291    await vault.createVault("correct horse battery staple");292    const entry = await vault.addEntry({293      title: "Imported Login",294      username: "",295      password: "secret123",296      url: "",297      notes: "migrated from another manager",298    });299300    expect(entry.username).toBe("");301    expect(entry.url).toBe("");302    expect(vault.getEntries()).toHaveLength(1);303  });304305  it("changePassword happy path — new password unlocks, old is rejected", async () => {306    const vault = await import("./vault");307308    await vault.createVault("old-pass-abc123");309    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });310    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });311312    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);313314    vault.lock();315    expect(await vault.unlock("new-pass-xyz789")).toBe(true);316    expect(vault.getEntries()).toHaveLength(2);317318    vault.lock();319    expect(await vault.unlock("old-pass-abc123")).toBe(false);320  });321322  it("stamps peach_last_modified_at on createVault", async () => {323    const vault = await import("./vault");324    const before = Date.now();325    await new Promise((resolve) => setTimeout(resolve, 1));326327    await vault.createVault("stamp-create-pass");328329    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);330  });331332  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {333    const vault = await import("./vault");334    await vault.createVault("stamp-entry-pass");335336    let previous = Number(storage.peach_last_modified_at);337    await new Promise((resolve) => setTimeout(resolve, 1));338    const entry = await vault.addEntry({339      title: "Stamp",340      username: "",341      password: "p",342      url: "",343    });344    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);345346    previous = Number(storage.peach_last_modified_at);347    await new Promise((resolve) => setTimeout(resolve, 1));348    await vault.updateEntry(entry.id, { title: "Stamped" });349    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);350351    previous = Number(storage.peach_last_modified_at);352    await new Promise((resolve) => setTimeout(resolve, 1));353    await vault.deleteEntry(entry.id);354    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);355  });356357  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {358    const vault = await import("./vault");359    await vault.createVault("stamp-real-pass");360361    let previous = Number(storage.peach_last_modified_at);362    await new Promise((resolve) => setTimeout(resolve, 1));363    await vault.setupDuress("stamp-decoy-pass");364    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);365366    previous = Number(storage.peach_last_modified_at);367    await new Promise((resolve) => setTimeout(resolve, 1));368    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);369    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);370  });371372  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {373    const vault = await import("./vault");374375    await vault.createVault("correct-pass-abcde");376    const slotABefore = JSON.stringify(storage.peach_vault_a);377378    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);379    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);380381    vault.lock();382    expect(await vault.unlock("correct-pass-abcde")).toBe(true);383  });384385  it("changePassword with duress configured leaves slot B byte-identical", async () => {386    const vault = await import("./vault");387388    await vault.createVault("real-pass-abcde");389    await vault.setupDuress("duress-pass-xyzzz");390391    const slotBBefore = JSON.stringify(storage.peach_vault_b);392    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);393    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);394395    // Duress path still works with the old decoy password.396    vault.lock();397    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);398    expect(vault.getEntries()).toEqual([]);399  });400401  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {402    const vault = await import("./vault");403404    await vault.createVault("pass-abcdef");405    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });406    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });407    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });408409    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(410      /refuse to replace with empty entry set/i411    );412    expect(vault.getEntries()).toHaveLength(3);413  });414415  it("changePassword swaps the active key before Slot B is regenerated", async () => {416    const vault = await import("./vault");417418    await vault.createVault("correct-pass-12345");419    const entry = await vault.addEntry({420      title: "GitHub",421      username: "june",422      password: "secret123",423      url: "https://github.com",424    });425426    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;427    const originalSet = setMock.getMockImplementation();428    let injectedDuringSlotBWrite = false;429430    setMock.mockImplementation(async (next: Record<string, unknown>) => {431      Object.assign(storage, next);432433      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {434        injectedDuringSlotBWrite = true;435        await vault.updateEntry(entry.id, { notes: "updated during password change" });436      }437    });438439    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);440441    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {442      Object.assign(storage, next);443    }));444445    vault.lock();446    expect(await vault.unlock("new-pass-67890")).toBe(true);447    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");448449    vault.lock();450    expect(await vault.unlock("correct-pass-12345")).toBe(false);451  });452453  it("restoreEntries replace mode replaces existing entries", async () => {454    const vault = await import("./vault");455456    await vault.createVault("replace-pass-12345");457    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });458    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });459460    const incoming = [461      {462        id: "entry-c",463        type: "login" as const,464        title: "C",465        username: "june",466        password: "three",467        url: "https://c.test",468        createdAt: 10,469        modifiedAt: 10,470      },471      {472        id: "entry-d",473        type: "login" as const,474        title: "D",475        username: "june",476        password: "four",477        url: "https://d.test",478        createdAt: 20,479        modifiedAt: 20,480      },481    ];482483    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({484      added: 2,485      updated: 0,486      skipped: 0,487    });488489    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);490  });491492  it("restoreEntries merge mode lets newer incoming entries win", async () => {493    const vault = await import("./vault");494495    await vault.createVault("merge-pass-12345");496    const existing = await vault.addEntry({497      title: "GitHub",498      username: "june",499      password: "old-secret",500      url: "https://github.com",501    });502503    await expect(504      vault.restoreEntries(505        [506          {507            ...existing,508            password: "new-secret",509            modifiedAt: existing.modifiedAt + 100,510          },511        ],512        "merge"513      )514    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });515516    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");517  });518519  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {520    const vault = await import("./vault");521522    await vault.createVault("merge-pass-67890");523    const existing = await vault.addEntry({524      title: "GitHub",525      username: "june",526      password: "current-secret",527      url: "https://github.com",528    });529530    await expect(531      vault.restoreEntries(532        [533          {534            ...existing,535            password: "stale-secret",536            modifiedAt: existing.modifiedAt - 100,537          },538        ],539        "merge"540      )541    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });542543    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");544  });545546  it("clearVaultEntries without explicit confirmation throws", async () => {547    const vault = await import("./vault");548549    await vault.createVault("clear-pass-12345");550    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });551552    const clearVaultEntries = vault.clearVaultEntries as unknown as (553      confirmation?: unknown554    ) => Promise<void>;555556    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);557    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);558    expect(vault.getEntries()).toHaveLength(1);559  });560561  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {562    const vault = await import("./vault");563564    await vault.createVault("clear-pass-67890");565    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });566    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });567    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });568569    await vault.clearVaultEntries("REPLACE_ALL");570571    expect(vault.getEntries()).toEqual([]);572    expect(vault.getDeletedEntries()).toHaveLength(3);573    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);574  });575576  it("deduplicateEntries throws in decoy mode", async () => {577    const vault = await import("./vault");578579    await vault.createVault("real-pass-12345");580    await vault.addEntry({581      title: "GitHub",582      username: "june",583      password: "secret123",584      url: "https://github.com",585    });586    await vault.setupDuress("duress-pass-12345");587    vault.lock();588589    expect(await vault.unlock("duress-pass-12345")).toBe(true);590    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);591  });592593  it("deduplicateEntries removes older duplicates in real mode", async () => {594    const vault = await import("./vault");595596    await vault.createVault("dedupe-pass-12345");597    await vault.addEntry({598      title: "GitHub",599      username: "june",600      password: "old-secret",601      url: "https://github.com",602    });603    await new Promise<void>((resolve) => setTimeout(resolve, 5));604    const newer = await vault.addEntry({605      title: "GitHub",606      username: "june",607      password: "new-secret",608      url: "https://github.com",609    });610611    expect(await vault.deduplicateEntries()).toBe(1);612    expect(vault.getEntries()).toHaveLength(1);613    expect(vault.getEntries()[0].id).toBe(newer.id);614    expect(vault.getDeletedEntries()).toHaveLength(1);615  });616617  it("updateEntry with an empty password on a login entry clears the password", async () => {618    const vault = await import("./vault");619620    await vault.createVault("update-pass-12345");621    const entry = await vault.addEntry({622      title: "GitHub",623      username: "june",624      password: "secret123",625      url: "https://github.com",626    });627628    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();629    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();630  });631632  it("updateEntry with an empty password on a note entry is allowed", async () => {633    const vault = await import("./vault");634635    await vault.createVault("note-pass-12345");636    const entry = await vault.addEntry({637      type: "note",638      title: "Secure note",639      notes: "keep me",640    });641642    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();643    expect(vault.getEntryById(entry.id)).toMatchObject({644      type: "note",645      notes: "updated",646    });647  });648649  it("lock zeroes the derived key and resets unlock state", async () => {650    const aes = await import("./aes");651    const zeroKeySpy = vi.spyOn(aes, "zeroKey");652    const vault = await import("./vault");653654    await vault.createVault("lock-pass-12345");655    expect(vault.isUnlocked()).toBe(true);656657    vault.lock();658659    expect(zeroKeySpy).toHaveBeenCalled();660    expect(vault.isUnlocked()).toBe(false);661    zeroKeySpy.mockRestore();662  });663664  it("lock clears in-memory entries", async () => {665    const vault = await import("./vault");666667    await vault.createVault("lock-pass-67890");668    const entry = await vault.addEntry({669      title: "GitHub",670      username: "june",671      password: "secret123",672      url: "https://github.com",673    });674675    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");676    vault.lock();677    expect(vault.getEntryById(entry.id)).toBeUndefined();678  });679680  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {681    const vault = await import("./vault");682683    await vault.createVault("real-pass-67890");684    vault.lock();685686    expect(await vault.unlock("totally-wrong-password")).toBe(false);687    expect(vault.isUnlocked()).toBe(false);688  });689690  it("isUnlocked tracks the full lock lifecycle", async () => {691    const vault = await import("./vault");692693    expect(vault.isUnlocked()).toBe(false);694    await vault.createVault("unlock-pass-12345");695    expect(vault.isUnlocked()).toBe(true);696    vault.lock();697    expect(vault.isUnlocked()).toBe(false);698    expect(await vault.unlock("unlock-pass-12345")).toBe(true);699    expect(vault.isUnlocked()).toBe(true);700    vault.lock();701    expect(vault.isUnlocked()).toBe(false);702  });703704  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {705    const vault = await import("./vault");706707    await vault.createVault("quota-pass-12345");708709    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;710    setMock.mockImplementation(async (next: Record<string, unknown>) => {711      if ("peach_vault_a" in next || "peach_vault_b" in next) {712        throw new Error("QUOTA_BYTES quota reached");713      }714      Object.assign(storage, next);715    });716717    await expect(718      vault.addEntry({719        title: "GitHub",720        username: "june",721        password: "secret123",722        url: "https://github.com",723      })724    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");725  });726727  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {728    const vault = await import("./vault");729730    await vault.createVault("settings-pass-12345");731732    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;733    setMock.mockImplementation(async (next: Record<string, unknown>) => {734      if ("peach_settings" in next) {735        throw new Error("QUOTA_BYTES quota reached");736      }737      Object.assign(storage, next);738    });739740    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(741      "STORAGE_QUOTA_EXCEEDED"742    );743  });744745  it("card entry round-trips through vault — all fields preserved", async () => {746    const vault = await import("./vault");747748    await vault.createVault("card-test-pass-12345");749    const entry = await vault.addEntry({750      type: "card",751      title: "Personal Visa",752      cardholderName: "Jane Smith",753      cardNumber: "4111111111111111",754      expMonth: "05",755      expYear: "2028",756      cvc: "123",757      cardNickname: "Personal Visa",758      network: "visa",759      last4: "1111",760    });761762    expect(entry.type).toBe("card");763    expect(entry.cardholderName).toBe("Jane Smith");764    expect(entry.cardNumber).toBe("4111111111111111");765    expect(entry.expMonth).toBe("05");766    expect(entry.expYear).toBe("2028");767    expect(entry.cvc).toBe("123");768    expect(entry.network).toBe("visa");769    expect(entry.last4).toBe("1111");770771    vault.lock();772    await vault.unlock("card-test-pass-12345");773    const entries = vault.getEntries();774    expect(entries).toHaveLength(1);775    expect(entries[0].cardNumber).toBe("4111111111111111");776    expect(entries[0].cvc).toBe("123");777  });778779  it("updateEntry — cannot clear card number on card entry", async () => {780    const vault = await import("./vault");781782    await vault.createVault("card-guard-pass-12345");783    const entry = await vault.addEntry({784      type: "card",785      title: "My Card",786      cardholderName: "Jane Smith",787      cardNumber: "4111111111111111",788      expMonth: "05",789      expYear: "2028",790      cvc: "123",791    });792793    await expect(794      vault.updateEntry(entry.id, { cardNumber: "" })795    ).rejects.toThrow("card number is required for card entries");796797    const entries = vault.getEntries();798    expect(entries[0].cardNumber).toBe("4111111111111111");799  });800801  it("declineCardSaveOffer — stores fingerprint in settings", async () => {802    const vault = await import("./vault");803804    await vault.createVault("decline-pass-12345");805    await vault.declineCardSaveOffer("1111", "05", "2028");806807    const settings = vault.getSettings();808    expect(settings.declinedCardFingerprints).toHaveLength(1);809  });810811  it("isCardDeclined — returns true for previously declined card", async () => {812    const vault = await import("./vault");813814    await vault.createVault("declined-check-pass-12345");815    await vault.declineCardSaveOffer("1111", "05", "2028");816817    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);818  });819820  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {821    const vault = await import("./vault");822823    await vault.createVault("declined-diff-pass-12345");824    await vault.declineCardSaveOffer("1111", "05", "2028");825826    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);827  });828829  it("queryDomain matches an entry against its additionalUrls", async () => {830    const vault = await import("./vault");831832    await vault.createVault("multi-domain-pass-12345");833    const entry = await vault.addEntry({834      type: "login",835      title: "Foo",836      username: "june",837      password: "secret",838      url: "https://foo.com",839      additionalUrls: ["https://bar.io", "https://baz.org"],840    });841842    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only843    // add new buckets when the eTLD+1 is genuinely different from the primary.844    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);845    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);846    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);847    expect(vault.queryDomain("unrelated.com")).toEqual([]);848  });849850  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {851    const vault = await import("./vault");852853    await vault.createVault("dedupe-additional-pass-12345");854    const entry = await vault.addEntry({855      type: "login",856      title: "Foo",857      username: "june",858      password: "secret",859      url: "https://foo.com",860    });861862    await vault.updateEntry(entry.id, {863      additionalUrls: [864        "  https://login.foo.com  ",865        "https://login.foo.com",866        "",867        "  ",868        "https://accounts.foo.com",869      ],870    });871872    const stored = vault.getEntryById(entry.id);873    expect(stored?.additionalUrls).toEqual([874      "https://login.foo.com",875      "https://accounts.foo.com",876    ]);877878    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });879    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();880  });881882  it("preserves email field through lock/unlock cycle", async () => {883    const vault = await import("./vault");884    await vault.createVault("preserve-email-pass-12345");885    await vault.addEntry({886      type: "login",887      title: "GitHub",888      username: "june",889      email: "[email protected]",890      password: "secret",891      url: "https://github.com",892    });893    vault.lock();894895    const ok = await vault.unlock("preserve-email-pass-12345");896    expect(ok).toBe(true);897    const entry = vault.getEntries().find((e) => e.title === "GitHub");898    expect(entry?.email).toBe("[email protected]");899  });900901  it("preserves additionalUrls through lock/unlock cycle", async () => {902    const vault = await import("./vault");903    await vault.createVault("preserve-urls-pass-12345");904    await vault.addEntry({905      type: "login",906      title: "Example",907      username: "june",908      password: "secret",909      url: "https://example.com",910      additionalUrls: [911        "https://beta.example.com",912        "https://staging.example.com",913      ],914    });915    vault.lock();916917    const ok = await vault.unlock("preserve-urls-pass-12345");918    expect(ok).toBe(true);919    const entry = vault.getEntries().find((e) => e.title === "Example");920    expect(entry?.additionalUrls).toEqual([921      "https://beta.example.com",922      "https://staging.example.com",923    ]);924  });925926  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {927    const vault = await import("./vault");928    await vault.createVault("pwchanged-preserve-pass");929    const entry = await vault.addEntry({930      title: "Aged",931      username: "june",932      password: "secret",933      url: "https://aged.test",934    });935    // addEntry stamps passwordChangedAt at creation; capture it and prove it936    // survives the encode/decode round-trip unchanged (was dropped pre-fix).937    const ts = entry.passwordChangedAt;938    expect(typeof ts).toBe("number");939    vault.lock();940941    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);942    const restored = vault.getEntries().find((e) => e.id === entry.id);943    expect(restored?.passwordChangedAt).toBe(ts);944  });945946  it("normalizeEntry handles entries without passwordChangedAt", async () => {947    const vault = await import("./vault");948    await vault.createVault("pwchanged-absent-pass");949    // A note carries no password and therefore no passwordChangedAt stamp.950    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });951    vault.lock();952953    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);954    const restored = vault.getEntries().find((e) => e.id === note.id);955    expect(restored?.passwordChangedAt).toBeUndefined();956  });957958  it("applyMergedRemoteEntries does not advance the vault version", async () => {959    const vault = await import("./vault");960    await vault.createVault("syncver-pass-aaaaa");961    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });962963    const before = await vault.getVaultVersion();964    await new Promise((r) => setTimeout(r, 2));965    await vault.applyMergedRemoteEntries(966      [967        {968          id: "remote-1",969          type: "login",970          title: "Remote",971          username: "r",972          password: "x",973          url: "https://r.test",974          createdAt: Date.now(),975          modifiedAt: Date.now(),976        },977      ],978      {},979    );980981    expect(await vault.getVaultVersion()).toBe(before);982  });983984  it("addEntry advances the vault version", async () => {985    const vault = await import("./vault");986    await vault.createVault("syncver-pass-bbbbb");987988    const before = await vault.getVaultVersion();989    await new Promise((r) => setTimeout(r, 2));990    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });991992    expect(await vault.getVaultVersion()).toBeGreaterThan(before);993  });994995  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {996    const vault = await import("./vault");997    await vault.createVault("monotonic-pass-aaaaa");998    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });999    const before = vault.getEntryById(e1.id)!.modifiedAt;10001001    // Simulate the clock jumping 10s backwards (NTP step / manual change).1002    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1003    await vault.updateEntry(e1.id, { password: "p2" });1004    const after = vault.getEntryById(e1.id)!.modifiedAt;1005    expect(after).toBeGreaterThan(before);1006    spy.mockRestore();1007  });10081009  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1010    const vault = await import("./vault");1011    await vault.createVault("monotonic-pass-bbbbb");1012    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1013    const modAt = vault.getEntryById(e.id)!.modifiedAt;1014    await vault.deleteEntry(e.id);1015    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1016    expect(tomb.deletedAt!).toBeGreaterThan(modAt);1017  });10181019  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1020    const vault = await import("./vault");1021    await vault.createVault("rollback-pass-aaaaa");1022    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10231024    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1025    // Fail only the slot-A write (the history snapshot write is swallowed by design).1026    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1027      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1028      Object.assign(storage, next);1029    });10301031    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10321033    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1034    // In-memory entry must still hold the original password, not the failed edit.1035    expect(vault.getEntryById(e.id)!.password).toBe("original");1036  });10371038  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1039    const vault = await import("./vault");1040    await vault.createVault("rootkey-pass-aaaaa");1041    const before = vault.getRootKeyHex();1042    expect(before).toMatch(/^[0-9a-f]{64}$/);10431044    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1045    expect(vault.getRootKeyHex()).toBe(before);10461047    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1048    vault.lock();1049    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1050    expect(vault.getRootKeyHex()).toBe(before);1051  });10521053  it("getRootKeyHex returns null when the vault is locked", async () => {1054    const vault = await import("./vault");1055    await vault.createVault("rootkey-pass-ccccc");1056    vault.lock();1057    expect(vault.getRootKeyHex()).toBeNull();1058  });10591060  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1061    const vault = await import("./vault");1062    await vault.createVault("biometric-pass-12345");1063    const rootBefore = vault.getRootKeyHex();1064    const keyHex = vault.getDerivedKeyHex();1065    expect(rootBefore).not.toBeNull();1066    vault.lock();1067    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1068    const r = await vault.unlockWithKey(keyHex);1069    expect(r.ok).toBe(true);1070    expect(vault.getRootKeyHex()).toBe(rootBefore);1071  });10721073  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1074    const vault = await import("./vault");1075    await vault.createVault("duress-real-pass-aaaa");1076    await vault.setupDuress("duress-decoy-pass-bbbb");10771078    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1079    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;10801081    // Equal immediately after setup…1082    expect(lenA()).toBe(lenB());10831084    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1085    // bucket while the frozen decoy stayed put, leaking that duress exists).1086    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1087    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1088    expect(lenA()).toBe(lenB());1089  });10901091  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1092    const vault = await import("./vault");1093    await vault.createVault("rollback-pass-bbbbb");1094    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });10951096    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1097    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1098      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1099      Object.assign(storage, next);1100    });11011102    await expect(vault.deleteEntry(e.id)).rejects.toThrow();11031104    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1105    // Entry must still be live (not tombstoned) since the delete never persisted.1106    expect(vault.getEntryById(e.id)).toBeDefined();1107    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1108  });11091110  describe("password history", () => {1111    it("captures the old password on change, newest first", async () => {1112      const vault = await import("./vault");1113      await vault.createVault("history-pass-12345");1114      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1115      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11161117      await vault.updateEntry(e.id, { password: "second" });1118      await vault.updateEntry(e.id, { password: "third" });11191120      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1121      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1122      expect(vault.getEntryById(e.id)?.password).toBe("third");1123    });11241125    it("does not record history on a metadata-only update", async () => {1126      const vault = await import("./vault");1127      await vault.createVault("history-meta-12345");1128      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11291130      await vault.updateEntry(e.id, { title: "Renamed" });1131      await vault.updateEntry(e.id, { username: "newuser" });11321133      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1134    });11351136    it("records each history item with the age the password carried", async () => {1137      const vault = await import("./vault");1138      await vault.createVault("history-stamp-12345");1139      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1140      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1141      expect(firstChangedAt).toBeGreaterThan(0);11421143      await new Promise((resolve) => setTimeout(resolve, 2));1144      await vault.updateEntry(e.id, { password: "v2" });11451146      const hist = vault.getEntryById(e.id)!.passwordHistory!;1147      // The retired "v1" entry carries the timestamp it held while current.1148      expect(hist[0].changedAt).toBe(firstChangedAt);1149    });11501151    it("skips capture when re-saving the identical password", async () => {1152      const vault = await import("./vault");1153      await vault.createVault("history-noop-12345");1154      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11551156      await vault.updateEntry(e.id, { password: "same" });11571158      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1159    });11601161    it("does not capture an empty prior password (clear then set)", async () => {1162      const vault = await import("./vault");1163      await vault.createVault("history-empty-12345");1164      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });11651166      await vault.updateEntry(e.id, { password: "now-has-one" });11671168      // Nothing worth keeping when the prior value was empty.1169      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1170    });11711172    it("restoring an old password re-records the swapped-out current one", async () => {1173      const vault = await import("./vault");1174      await vault.createVault("history-restore-12345");1175      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1176      await vault.updateEntry(e.id, { password: "new" });11771178      // Restore "old" — the editor just saves the old value as the current one.1179      await vault.updateEntry(e.id, { password: "old" });11801181      const hist = vault.getEntryById(e.id)!.passwordHistory!;1182      expect(vault.getEntryById(e.id)?.password).toBe("old");1183      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1184    });11851186    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1187      const vault = await import("./vault");1188      await vault.createVault("history-persist-12345");1189      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1190      await vault.updateEntry(e.id, { password: "p2" });11911192      vault.lock();1193      expect(await vault.unlock("history-persist-12345")).toBe(true);11941195      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1196      expect(hist.map((h) => h.password)).toEqual(["p1"]);1197    });11981199    it("caps history at 50 entries, dropping the oldest", async () => {1200      const vault = await import("./vault");1201      await vault.createVault("history-cap-12345");1202      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12031204      for (let i = 1; i <= 55; i++) {1205        await vault.updateEntry(e.id, { password: `pw-${i}` });1206      }12071208      const hist = vault.getEntryById(e.id)!.passwordHistory!;1209      expect(hist).toHaveLength(50);1210      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1211      expect(hist[0].password).toBe("pw-54");1212      expect(hist[hist.length - 1].password).toBe("pw-5");1213    });1214  });12151216  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1217    it("drops unknown/attacker keys from a restored entry", async () => {1218      const vault = await import("./vault");1219      await vault.createVault("correct horse battery staple");1220      await vault.restoreEntries(1221        [1222          {1223            id: "e1",1224            type: "login",1225            title: "Bank",1226            username: "june",1227            password: "pw",1228            createdAt: 1,1229            modifiedAt: 2,1230            evil: "payload",1231            isAdmin: true,1232            nested: { junk: 1 },1233          } as unknown as import("../shared/types").VaultEntry,1234        ],1235        "replace",1236      );1237      const restored = vault.getEntries().find((e) => e.id === "e1")!;1238      expect(restored.password).toBe("pw");1239      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1240      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1241      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1242    });12431244    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1245      const vault = await import("./vault");1246      await vault.createVault("correct horse battery staple");1247      await vault.restoreEntries(1248        [1249          {1250            id: "e2",1251            type: "login",1252            title: "Bank",1253            password: "pw",1254            createdAt: 1,1255            modifiedAt: 2,1256            canary: { webhookUrl: "http://attacker.example.com/x" },1257          } as unknown as import("../shared/types").VaultEntry,1258        ],1259        "replace",1260      );1261      const restored = vault.getEntries().find((e) => e.id === "e2")!;1262      expect(restored).toBeDefined();1263      expect(restored.canary).toBeUndefined();1264    });12651266    it("preserves a well-formed HTTPS canary webhook", async () => {1267      const vault = await import("./vault");1268      await vault.createVault("correct horse battery staple");1269      await vault.restoreEntries(1270        [1271          {1272            id: "e3",1273            type: "login",1274            title: "Bank",1275            password: "pw",1276            createdAt: 1,1277            modifiedAt: 2,1278            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1279          } as unknown as import("../shared/types").VaultEntry,1280        ],1281        "replace",1282      );1283      const restored = vault.getEntries().find((e) => e.id === "e3")!;1284      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1285      expect(restored.canary?.label).toBe("watch");1286    });12871288    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1289      const vault = await import("./vault");1290      await vault.createVault("correct horse battery staple");1291      await vault.restoreEntries(1292        [1293          {1294            id: "e4",1295            type: "login",1296            title: "Has bad passkey",1297            createdAt: 1,1298            modifiedAt: 2,1299            passkey: { rpId: "example.com", publicKeyCose: "x" },1300          } as unknown as import("../shared/types").VaultEntry,1301        ],1302        "replace",1303      );1304      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1305    });13061307  });13081309  describe("MV-8 rotateSyncIdentity", () => {1310    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1311      const vault = await import("./vault");1312      await vault.createVault("correct horse battery staple");1313      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1314      const { upsertPairedDevice, listPairedDevices } = await import(1315        "./paired-devices"1316      );1317      await upsertPairedDevice({1318        deviceId: "d1",1319        deviceName: "Phone",1320        deviceType: "android",1321        longTermPublicKeyHex: "a".repeat(64),1322        signingPublicKeyHex: "b".repeat(64),1323        pairedAt: 1,1324        trustSource: "direct",1325      });13261327      const rootBefore = vault.getRootKeyHex();1328      const res = await vault.rotateSyncIdentity();1329      expect(res.ok).toBe(true);1330      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1331      expect(await listPairedDevices()).toHaveLength(0);1332      // Entries survive — they are sealed under the password key, not the root.1333      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13341335      vault.lock();1336      expect(await vault.unlock("correct horse battery staple")).toBe(true);1337      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1338    });13391340    it("invalidates the old recovery words and flags recovery-pending", async () => {1341      const vault = await import("./vault");1342      await vault.createVault("correct horse battery staple");1343      const wordsBefore = (await vault.getRecoveryWords()).join(" ");13441345      await vault.rotateSyncIdentity();13461347      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1348      expect(wordsAfter).not.toBe(wordsBefore);1349      const { isRecoveryConfirmPending } = await import("./recovery-pending");1350      expect(await isRecoveryConfirmPending()).toBe(true);1351    });13521353    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1354      const vault = await import("./vault");1355      await vault.createVault("correct horse battery staple");1356      const { createSyncVaultAdapter } = await import(1357        "./sync-vault-adapter"1358      );1359      const adapter = createSyncVaultAdapter();1360      const vidBefore = await adapter.computeVaultId();13611362      await vault.rotateSyncIdentity();13631364      const vidAfter = await adapter.computeVaultId();1365      expect(vidAfter).not.toBe(vidBefore);1366    });1367  });13681369  describe("LV-1 NFC master password normalization", () => {1370    const CAFE_BASE = "café";1371    const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1372    const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;13731374    it("a vault created with the NFD form unlocks with the NFC form", async () => {1375      const vault = await import("./vault");1376      expect(PW_NFD).not.toBe(PW_NFC);1377      await vault.createVault(PW_NFD);1378      vault.lock();1379      expect(await vault.unlock(PW_NFC)).toBe(true);1380    });13811382  });13831384  describe("LV-7 updateSettings proto-pollution filter", () => {1385    it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1386      const vault = await import("./vault");1387      await vault.createVault("correct horse battery staple");13881389      await vault.updateSettings({1390        theme: "dark",1391        ["__proto__"]: { polluted: true },1392        constructor: { polluted: true },1393      } as never);13941395      expect(({} as Record<string, unknown>).polluted).toBeUndefined();1396      expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1397      expect(vault.getSettings().theme).toBe("dark");1398    });1399  });1400});