vault.test.ts

53.3 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("slot A and slot B have matching ciphertext size at fresh install", async () => {91    const vault = await import("./vault");92    await vault.createVault("correct horse battery staple");9394    const a = storage.peach_vault_a as StoredVault;95    const b = storage.peach_vault_b as StoredVault;96    expect(a.ciphertext.length).toBe(b.ciphertext.length);97  });9899  it("does not expose a plaintext duress flag after setupDuress", async () => {100    const vault = await import("./vault");101    await vault.createVault("real-password-12345");102    await vault.setupDuress("decoy-password-67890");103104    expect(storage.peach_duress).toBeUndefined();105    expect(storage.peach_decoy_vault).toBeUndefined();106    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));107    // Slot-neutral keys + optional settings + vault history (snapshot of the108    // pre-setupDuress real-mode blob). No exposed duress flag.109    for (const key of keys) {110      expect(111        [112          "peach_vault_a",113          "peach_vault_b",114          "peach_settings",115          "peach_vault_history",116          "peach_last_modified_at",117        ]118      ).toContain(key);119    }120    expect(keys).toContain("peach_vault_a");121    expect(keys).toContain("peach_vault_b");122  });123124  it("unlocks with real password and loads real entries", async () => {125    const vault = await import("./vault");126    await vault.createVault("real-password-12345");127    await vault.addEntry({128      title: "GitHub",129      username: "june",130      password: "realsecret",131      url: "github.com",132    });133    vault.lock();134135    const ok = await vault.unlock("real-password-12345");136    expect(ok).toBe(true);137    expect(vault.getEntries()).toHaveLength(1);138    expect(vault.getEntries()[0].title).toBe("GitHub");139  });140141  it("unlocks with duress password and loads empty entries", async () => {142    const vault = await import("./vault");143    await vault.createVault("real-password-12345");144    await vault.addEntry({145      title: "GitHub",146      username: "june",147      password: "realsecret",148      url: "github.com",149    });150    await vault.setupDuress("decoy-password-67890");151    vault.lock();152153    const ok = await vault.unlock("decoy-password-67890");154    expect(ok).toBe(true);155    expect(vault.getEntries()).toEqual([]);156  });157158  it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {159    const vault = await import("./vault");160    await vault.createVault("real-password-12345");161    await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });162    await vault.setupDuress("decoy-password-67890");163164    // Baseline: the real vault does have a sync identity.165    expect(vault.getRootKeyHex()).toBeTruthy();166    vault.lock();167168    expect(await vault.unlock("decoy-password-67890")).toBe(true);169    // A second vault_id would be a duress indicator: a coerced user opening the170    // decoy would make sync visibly fail on their other devices. The decoy path171    // never derives a root key, so there is nothing to derive one from.172    expect(vault.getRootKeyHex()).toBeNull();173174    // Every sync adapter operation therefore fails closed rather than syncing,175    // manifesting, or revealing an entry count.176    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");177    const adapter = createSyncVaultAdapter();178    await expect(adapter.computeVaultId()).rejects.toThrow();179    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();180    vault.lock();181  });182183  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {184    // The unlock path must derive keys for both slots unconditionally so a185    // real-mode unlock and a decoy-mode unlock both spend roughly the same186    // wall-clock time. Verified here by spying on deriveKey and asserting it187    // is called twice (once per slot) on every unlock attempt.188    const argon2 = await import("../crypto/argon2");189    const deriveSpy = vi.spyOn(argon2, "deriveKey");190191    const vault = await import("./vault");192    await vault.createVault("real-password-12345");193    await vault.setupDuress("decoy-password-67890");194    vault.lock();195196    deriveSpy.mockClear();197    await vault.unlock("real-password-12345");198    expect(deriveSpy).toHaveBeenCalledTimes(2);199    vault.lock();200201    deriveSpy.mockClear();202    await vault.unlock("decoy-password-67890");203    expect(deriveSpy).toHaveBeenCalledTimes(2);204    vault.lock();205206    deriveSpy.mockClear();207    await vault.unlock("wrong-password-xxxxx");208    expect(deriveSpy).toHaveBeenCalledTimes(2);209210    deriveSpy.mockRestore();211  });212213  it("rejects wrong passwords", async () => {214    const vault = await import("./vault");215    await vault.createVault("real-password-12345");216    vault.lock();217218    const ok = await vault.unlock("nope-nope-nope");219    expect(ok).toBe(false);220  });221222  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {223    const vault = await import("./vault");224    await vault.createVault("real-password-12345");225    await vault.setupDuress("decoy-password-67890");226    vault.lock();227228    await vault.unlock("real-password-12345");229    expect(await vault.isDuressEnabled()).toBe(true);230231    await vault.disableDuress();232    expect(await vault.isDuressEnabled()).toBe(false);233    vault.lock();234235    await vault.unlock("real-password-12345");236    expect(await vault.isDuressEnabled()).toBe(false);237  });238239  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {240    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).241    const firstVault = await import("./vault");242    await firstVault.createVault("legacy-password-abcde");243    const legacySlotA = storage.peach_vault_a as StoredVault;244    storage.peach_vault = legacySlotA;245    delete storage.peach_vault_a;246    delete storage.peach_vault_b;247    storage.peach_duress = { enabled: true };248    storage.peach_decoy_vault = legacySlotA; // reuse shape for test249    vi.resetModules();250251    // Act: fresh module load triggers migration on next vault access.252    const vault = await import("./vault");253    await vault.hasVault();254255    // Assert: legacy keys removed, new keys present.256    expect(storage.peach_vault).toBeUndefined();257    expect(storage.peach_decoy_vault).toBeUndefined();258    expect(storage.peach_duress).toBeUndefined();259    expect(storage.peach_vault_a).toBeDefined();260    expect(storage.peach_vault_b).toBeDefined();261  });262263  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {264    const vault = await import("./vault");265266    await vault.createVault("correct horse battery staple");267    const entry = await vault.addEntry({268      title: "GitHub",269      username: "june",270      password: "secret123",271      url: "github.com",272    });273274    await vault.deleteEntry(entry.id);275276    expect(vault.getEntries()).toEqual([]);277278    const stored = storage.peach_vault_a as StoredVault;279    const salt = Uint8Array.from(280      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))281    );282    const key = await deriveKey("correct horse battery staple", salt);283    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");284    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };285286    expect(parsed.entries).toHaveLength(1);287    expect(parsed.entries[0].id).toBe(entry.id);288    expect(typeof parsed.entries[0].deletedAt).toBe("number");289  });290291  it("accepts imported entries without a username or url", async () => {292    const vault = await import("./vault");293294    await vault.createVault("correct horse battery staple");295    const entry = await vault.addEntry({296      title: "Imported Login",297      username: "",298      password: "secret123",299      url: "",300      notes: "migrated from another manager",301    });302303    expect(entry.username).toBe("");304    expect(entry.url).toBe("");305    expect(vault.getEntries()).toHaveLength(1);306  });307308  it("changePassword happy path — new password unlocks, old is rejected", async () => {309    const vault = await import("./vault");310311    await vault.createVault("old-pass-abc123");312    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });313    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });314315    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);316317    vault.lock();318    expect(await vault.unlock("new-pass-xyz789")).toBe(true);319    expect(vault.getEntries()).toHaveLength(2);320321    vault.lock();322    expect(await vault.unlock("old-pass-abc123")).toBe(false);323  });324325  it("stamps peach_last_modified_at on createVault", async () => {326    const vault = await import("./vault");327    const before = Date.now();328    await new Promise((resolve) => setTimeout(resolve, 1));329330    await vault.createVault("stamp-create-pass");331332    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);333  });334335  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {336    const vault = await import("./vault");337    await vault.createVault("stamp-entry-pass");338339    let previous = Number(storage.peach_last_modified_at);340    await new Promise((resolve) => setTimeout(resolve, 1));341    const entry = await vault.addEntry({342      title: "Stamp",343      username: "",344      password: "p",345      url: "",346    });347    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348349    previous = Number(storage.peach_last_modified_at);350    await new Promise((resolve) => setTimeout(resolve, 1));351    await vault.updateEntry(entry.id, { title: "Stamped" });352    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);353354    previous = Number(storage.peach_last_modified_at);355    await new Promise((resolve) => setTimeout(resolve, 1));356    await vault.deleteEntry(entry.id);357    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);358  });359360  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {361    const vault = await import("./vault");362    await vault.createVault("stamp-real-pass");363364    let previous = Number(storage.peach_last_modified_at);365    await new Promise((resolve) => setTimeout(resolve, 1));366    await vault.setupDuress("stamp-decoy-pass");367    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);368369    previous = Number(storage.peach_last_modified_at);370    await new Promise((resolve) => setTimeout(resolve, 1));371    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);372    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);373  });374375  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {376    const vault = await import("./vault");377378    await vault.createVault("correct-pass-abcde");379    const slotABefore = JSON.stringify(storage.peach_vault_a);380381    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);382    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);383384    vault.lock();385    expect(await vault.unlock("correct-pass-abcde")).toBe(true);386  });387388  it("changePassword with duress configured leaves slot B byte-identical", async () => {389    const vault = await import("./vault");390391    await vault.createVault("real-pass-abcde");392    await vault.setupDuress("duress-pass-xyzzz");393394    const slotBBefore = JSON.stringify(storage.peach_vault_b);395    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);396    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);397398    // Duress path still works with the old decoy password.399    vault.lock();400    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);401    expect(vault.getEntries()).toEqual([]);402  });403404  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {405    const vault = await import("./vault");406407    await vault.createVault("pass-abcdef");408    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });409    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });410    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });411412    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(413      /refuse to replace with empty entry set/i414    );415    expect(vault.getEntries()).toHaveLength(3);416  });417418  it("changePassword swaps the active key before Slot B is regenerated", async () => {419    const vault = await import("./vault");420421    await vault.createVault("correct-pass-12345");422    const entry = await vault.addEntry({423      title: "GitHub",424      username: "june",425      password: "secret123",426      url: "https://github.com",427    });428429    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;430    const originalSet = setMock.getMockImplementation();431    let injectedDuringSlotBWrite = false;432433    setMock.mockImplementation(async (next: Record<string, unknown>) => {434      Object.assign(storage, next);435436      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {437        injectedDuringSlotBWrite = true;438        await vault.updateEntry(entry.id, { notes: "updated during password change" });439      }440    });441442    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);443444    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {445      Object.assign(storage, next);446    }));447448    vault.lock();449    expect(await vault.unlock("new-pass-67890")).toBe(true);450    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");451452    vault.lock();453    expect(await vault.unlock("correct-pass-12345")).toBe(false);454  });455456  it("restoreEntries replace mode replaces existing entries", async () => {457    const vault = await import("./vault");458459    await vault.createVault("replace-pass-12345");460    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });461    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });462463    const incoming = [464      {465        id: "entry-c",466        type: "login" as const,467        title: "C",468        username: "june",469        password: "three",470        url: "https://c.test",471        createdAt: 10,472        modifiedAt: 10,473      },474      {475        id: "entry-d",476        type: "login" as const,477        title: "D",478        username: "june",479        password: "four",480        url: "https://d.test",481        createdAt: 20,482        modifiedAt: 20,483      },484    ];485486    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({487      added: 2,488      updated: 0,489      skipped: 0,490    });491492    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);493  });494495  it("restoreEntries merge mode lets newer incoming entries win", async () => {496    const vault = await import("./vault");497498    await vault.createVault("merge-pass-12345");499    const existing = await vault.addEntry({500      title: "GitHub",501      username: "june",502      password: "old-secret",503      url: "https://github.com",504    });505506    await expect(507      vault.restoreEntries(508        [509          {510            ...existing,511            password: "new-secret",512            modifiedAt: existing.modifiedAt + 100,513          },514        ],515        "merge"516      )517    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });518519    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");520  });521522  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {523    const vault = await import("./vault");524525    await vault.createVault("merge-pass-67890");526    const existing = await vault.addEntry({527      title: "GitHub",528      username: "june",529      password: "current-secret",530      url: "https://github.com",531    });532533    await expect(534      vault.restoreEntries(535        [536          {537            ...existing,538            password: "stale-secret",539            modifiedAt: existing.modifiedAt - 100,540          },541        ],542        "merge"543      )544    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });545546    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");547  });548549  it("clearVaultEntries without explicit confirmation throws", async () => {550    const vault = await import("./vault");551552    await vault.createVault("clear-pass-12345");553    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });554555    const clearVaultEntries = vault.clearVaultEntries as unknown as (556      confirmation?: unknown557    ) => Promise<void>;558559    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);560    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);561    expect(vault.getEntries()).toHaveLength(1);562  });563564  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {565    const vault = await import("./vault");566567    await vault.createVault("clear-pass-67890");568    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });569    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });570    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });571572    await vault.clearVaultEntries("REPLACE_ALL");573574    expect(vault.getEntries()).toEqual([]);575    expect(vault.getDeletedEntries()).toHaveLength(3);576    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);577  });578579  it("deduplicateEntries throws in decoy mode", async () => {580    const vault = await import("./vault");581582    await vault.createVault("real-pass-12345");583    await vault.addEntry({584      title: "GitHub",585      username: "june",586      password: "secret123",587      url: "https://github.com",588    });589    await vault.setupDuress("duress-pass-12345");590    vault.lock();591592    expect(await vault.unlock("duress-pass-12345")).toBe(true);593    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);594  });595596  it("deduplicateEntries removes older duplicates in real mode", async () => {597    const vault = await import("./vault");598599    await vault.createVault("dedupe-pass-12345");600    await vault.addEntry({601      title: "GitHub",602      username: "june",603      password: "old-secret",604      url: "https://github.com",605    });606    await new Promise<void>((resolve) => setTimeout(resolve, 5));607    const newer = await vault.addEntry({608      title: "GitHub",609      username: "june",610      password: "new-secret",611      url: "https://github.com",612    });613614    expect(await vault.deduplicateEntries()).toBe(1);615    expect(vault.getEntries()).toHaveLength(1);616    expect(vault.getEntries()[0].id).toBe(newer.id);617    expect(vault.getDeletedEntries()).toHaveLength(1);618  });619620  it("updateEntry with an empty password on a login entry clears the password", async () => {621    const vault = await import("./vault");622623    await vault.createVault("update-pass-12345");624    const entry = await vault.addEntry({625      title: "GitHub",626      username: "june",627      password: "secret123",628      url: "https://github.com",629    });630631    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();632    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();633  });634635  it("updateEntry with an empty password on a note entry is allowed", async () => {636    const vault = await import("./vault");637638    await vault.createVault("note-pass-12345");639    const entry = await vault.addEntry({640      type: "note",641      title: "Secure note",642      notes: "keep me",643    });644645    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();646    expect(vault.getEntryById(entry.id)).toMatchObject({647      type: "note",648      notes: "updated",649    });650  });651652  it("lock zeroes the derived key and resets unlock state", async () => {653    const aes = await import("./aes");654    const zeroKeySpy = vi.spyOn(aes, "zeroKey");655    const vault = await import("./vault");656657    await vault.createVault("lock-pass-12345");658    expect(vault.isUnlocked()).toBe(true);659660    vault.lock();661662    expect(zeroKeySpy).toHaveBeenCalled();663    expect(vault.isUnlocked()).toBe(false);664    zeroKeySpy.mockRestore();665  });666667  it("lock clears in-memory entries", async () => {668    const vault = await import("./vault");669670    await vault.createVault("lock-pass-67890");671    const entry = await vault.addEntry({672      title: "GitHub",673      username: "june",674      password: "secret123",675      url: "https://github.com",676    });677678    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");679    vault.lock();680    expect(vault.getEntryById(entry.id)).toBeUndefined();681  });682683  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {684    const vault = await import("./vault");685686    await vault.createVault("real-pass-67890");687    vault.lock();688689    expect(await vault.unlock("totally-wrong-password")).toBe(false);690    expect(vault.isUnlocked()).toBe(false);691  });692693  it("isUnlocked tracks the full lock lifecycle", async () => {694    const vault = await import("./vault");695696    expect(vault.isUnlocked()).toBe(false);697    await vault.createVault("unlock-pass-12345");698    expect(vault.isUnlocked()).toBe(true);699    vault.lock();700    expect(vault.isUnlocked()).toBe(false);701    expect(await vault.unlock("unlock-pass-12345")).toBe(true);702    expect(vault.isUnlocked()).toBe(true);703    vault.lock();704    expect(vault.isUnlocked()).toBe(false);705  });706707  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {708    const vault = await import("./vault");709710    await vault.createVault("quota-pass-12345");711712    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;713    setMock.mockImplementation(async (next: Record<string, unknown>) => {714      if ("peach_vault_a" in next || "peach_vault_b" in next) {715        throw new Error("QUOTA_BYTES quota reached");716      }717      Object.assign(storage, next);718    });719720    await expect(721      vault.addEntry({722        title: "GitHub",723        username: "june",724        password: "secret123",725        url: "https://github.com",726      })727    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");728  });729730  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {731    const vault = await import("./vault");732733    await vault.createVault("settings-pass-12345");734735    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;736    setMock.mockImplementation(async (next: Record<string, unknown>) => {737      if ("peach_settings" in next) {738        throw new Error("QUOTA_BYTES quota reached");739      }740      Object.assign(storage, next);741    });742743    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(744      "STORAGE_QUOTA_EXCEEDED"745    );746  });747748  it("card entry round-trips through vault — all fields preserved", async () => {749    const vault = await import("./vault");750751    await vault.createVault("card-test-pass-12345");752    const entry = await vault.addEntry({753      type: "card",754      title: "Personal Visa",755      cardholderName: "Jane Smith",756      cardNumber: "4111111111111111",757      expMonth: "05",758      expYear: "2028",759      cvc: "123",760      cardNickname: "Personal Visa",761      network: "visa",762      last4: "1111",763    });764765    expect(entry.type).toBe("card");766    expect(entry.cardholderName).toBe("Jane Smith");767    expect(entry.cardNumber).toBe("4111111111111111");768    expect(entry.expMonth).toBe("05");769    expect(entry.expYear).toBe("2028");770    expect(entry.cvc).toBe("123");771    expect(entry.network).toBe("visa");772    expect(entry.last4).toBe("1111");773774    vault.lock();775    await vault.unlock("card-test-pass-12345");776    const entries = vault.getEntries();777    expect(entries).toHaveLength(1);778    expect(entries[0].cardNumber).toBe("4111111111111111");779    expect(entries[0].cvc).toBe("123");780  });781782  it("updateEntry — cannot clear card number on card entry", async () => {783    const vault = await import("./vault");784785    await vault.createVault("card-guard-pass-12345");786    const entry = await vault.addEntry({787      type: "card",788      title: "My Card",789      cardholderName: "Jane Smith",790      cardNumber: "4111111111111111",791      expMonth: "05",792      expYear: "2028",793      cvc: "123",794    });795796    await expect(797      vault.updateEntry(entry.id, { cardNumber: "" })798    ).rejects.toThrow("card number is required for card entries");799800    const entries = vault.getEntries();801    expect(entries[0].cardNumber).toBe("4111111111111111");802  });803804  it("declineCardSaveOffer — stores fingerprint in settings", async () => {805    const vault = await import("./vault");806807    await vault.createVault("decline-pass-12345");808    await vault.declineCardSaveOffer("1111", "05", "2028");809810    const settings = vault.getSettings();811    expect(settings.declinedCardFingerprints).toHaveLength(1);812  });813814  it("isCardDeclined — returns true for previously declined card", async () => {815    const vault = await import("./vault");816817    await vault.createVault("declined-check-pass-12345");818    await vault.declineCardSaveOffer("1111", "05", "2028");819820    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);821  });822823  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {824    const vault = await import("./vault");825826    await vault.createVault("declined-diff-pass-12345");827    await vault.declineCardSaveOffer("1111", "05", "2028");828829    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830  });831832  it("queryDomain matches an entry against its additionalUrls", async () => {833    const vault = await import("./vault");834835    await vault.createVault("multi-domain-pass-12345");836    const entry = await vault.addEntry({837      type: "login",838      title: "Foo",839      username: "june",840      password: "secret",841      url: "https://foo.com",842      additionalUrls: ["https://bar.io", "https://baz.org"],843    });844845    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only846    // add new buckets when the eTLD+1 is genuinely different from the primary.847    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);848    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);849    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);850    expect(vault.queryDomain("unrelated.com")).toEqual([]);851  });852853  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {854    const vault = await import("./vault");855856    await vault.createVault("dedupe-additional-pass-12345");857    const entry = await vault.addEntry({858      type: "login",859      title: "Foo",860      username: "june",861      password: "secret",862      url: "https://foo.com",863    });864865    await vault.updateEntry(entry.id, {866      additionalUrls: [867        "  https://login.foo.com  ",868        "https://login.foo.com",869        "",870        "  ",871        "https://accounts.foo.com",872      ],873    });874875    const stored = vault.getEntryById(entry.id);876    expect(stored?.additionalUrls).toEqual([877      "https://login.foo.com",878      "https://accounts.foo.com",879    ]);880881    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });882    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();883  });884885  it("preserves email field through lock/unlock cycle", async () => {886    const vault = await import("./vault");887    await vault.createVault("preserve-email-pass-12345");888    await vault.addEntry({889      type: "login",890      title: "GitHub",891      username: "june",892      email: "[email protected]",893      password: "secret",894      url: "https://github.com",895    });896    vault.lock();897898    const ok = await vault.unlock("preserve-email-pass-12345");899    expect(ok).toBe(true);900    const entry = vault.getEntries().find((e) => e.title === "GitHub");901    expect(entry?.email).toBe("[email protected]");902  });903904  it("preserves additionalUrls through lock/unlock cycle", async () => {905    const vault = await import("./vault");906    await vault.createVault("preserve-urls-pass-12345");907    await vault.addEntry({908      type: "login",909      title: "Example",910      username: "june",911      password: "secret",912      url: "https://example.com",913      additionalUrls: [914        "https://beta.example.com",915        "https://staging.example.com",916      ],917    });918    vault.lock();919920    const ok = await vault.unlock("preserve-urls-pass-12345");921    expect(ok).toBe(true);922    const entry = vault.getEntries().find((e) => e.title === "Example");923    expect(entry?.additionalUrls).toEqual([924      "https://beta.example.com",925      "https://staging.example.com",926    ]);927  });928929  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {930    const vault = await import("./vault");931    await vault.createVault("pwchanged-preserve-pass");932    const entry = await vault.addEntry({933      title: "Aged",934      username: "june",935      password: "secret",936      url: "https://aged.test",937    });938    // addEntry stamps passwordChangedAt at creation; capture it and prove it939    // survives the encode/decode round-trip unchanged (was dropped pre-fix).940    const ts = entry.passwordChangedAt;941    expect(typeof ts).toBe("number");942    vault.lock();943944    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);945    const restored = vault.getEntries().find((e) => e.id === entry.id);946    expect(restored?.passwordChangedAt).toBe(ts);947  });948949  it("normalizeEntry handles entries without passwordChangedAt", async () => {950    const vault = await import("./vault");951    await vault.createVault("pwchanged-absent-pass");952    // A note carries no password and therefore no passwordChangedAt stamp.953    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });954    vault.lock();955956    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);957    const restored = vault.getEntries().find((e) => e.id === note.id);958    expect(restored?.passwordChangedAt).toBeUndefined();959  });960961  it("applyMergedRemoteEntries does not advance the vault version", async () => {962    const vault = await import("./vault");963    await vault.createVault("syncver-pass-aaaaa");964    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });965966    const before = await vault.getVaultVersion();967    await new Promise((r) => setTimeout(r, 2));968    await vault.applyMergedRemoteEntries(969      [970        {971          id: "remote-1",972          type: "login",973          title: "Remote",974          username: "r",975          password: "x",976          url: "https://r.test",977          createdAt: Date.now(),978          modifiedAt: Date.now(),979        },980      ],981      {},982    );983984    expect(await vault.getVaultVersion()).toBe(before);985  });986987  it("addEntry advances the vault version", async () => {988    const vault = await import("./vault");989    await vault.createVault("syncver-pass-bbbbb");990991    const before = await vault.getVaultVersion();992    await new Promise((r) => setTimeout(r, 2));993    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });994995    expect(await vault.getVaultVersion()).toBeGreaterThan(before);996  });997998  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {999    const vault = await import("./vault");1000    await vault.createVault("monotonic-pass-aaaaa");1001    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1002    const before = vault.getEntryById(e1.id)!.modifiedAt;10031004    // Simulate the clock jumping 10s backwards (NTP step / manual change).1005    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1006    await vault.updateEntry(e1.id, { password: "p2" });1007    const after = vault.getEntryById(e1.id)!.modifiedAt;1008    expect(after).toBeGreaterThan(before);1009    spy.mockRestore();1010  });10111012  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1013    const vault = await import("./vault");1014    await vault.createVault("monotonic-pass-bbbbb");1015    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1016    const modAt = vault.getEntryById(e.id)!.modifiedAt;1017    await vault.deleteEntry(e.id);1018    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1019    expect(tomb.deletedAt!).toBeGreaterThan(modAt);1020  });10211022  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1023    const vault = await import("./vault");1024    await vault.createVault("rollback-pass-aaaaa");1025    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10261027    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1028    // Fail only the slot-A write (the history snapshot write is swallowed by design).1029    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1030      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1031      Object.assign(storage, next);1032    });10331034    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10351036    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1037    // In-memory entry must still hold the original password, not the failed edit.1038    expect(vault.getEntryById(e.id)!.password).toBe("original");1039  });10401041  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1042    const vault = await import("./vault");1043    await vault.createVault("rootkey-pass-aaaaa");1044    const before = vault.getRootKeyHex();1045    expect(before).toMatch(/^[0-9a-f]{64}$/);10461047    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1048    expect(vault.getRootKeyHex()).toBe(before);10491050    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1051    vault.lock();1052    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1053    expect(vault.getRootKeyHex()).toBe(before);1054  });10551056  it("getRootKeyHex returns null when the vault is locked", async () => {1057    const vault = await import("./vault");1058    await vault.createVault("rootkey-pass-ccccc");1059    vault.lock();1060    expect(vault.getRootKeyHex()).toBeNull();1061  });10621063  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1064    const vault = await import("./vault");1065    await vault.createVault("biometric-pass-12345");1066    const rootBefore = vault.getRootKeyHex();1067    const keyHex = vault.getDerivedKeyHex();1068    expect(rootBefore).not.toBeNull();1069    vault.lock();1070    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1071    const r = await vault.unlockWithKey(keyHex);1072    expect(r.ok).toBe(true);1073    expect(vault.getRootKeyHex()).toBe(rootBefore);1074  });10751076  it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1077    const vault = await import("./vault");1078    await vault.createVault("legacy-pass-12345");1079    const keyHex = vault.getDerivedKeyHex();1080    vault.lock();1081    // Strip the recovery escrow to mimic a pre-v2.12 slot — unlock must still1082    // succeed (non-fatal), leaving rootKey null.1083    const slot = storage.peach_vault_a as Record<string, unknown>;1084    delete slot.recovery_salt;1085    delete slot.recovery_wrapped_root_key;1086    delete slot.pw_root_ciphertext;1087    delete slot.pw_root_nonce;1088    delete slot.pw_root_tag;1089    const r = await vault.unlockWithKey(keyHex);1090    expect(r.ok).toBe(true);1091    expect(vault.getRootKeyHex()).toBeNull();1092  });10931094  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1095    const vault = await import("./vault");1096    await vault.createVault("duress-real-pass-aaaa");1097    await vault.setupDuress("duress-decoy-pass-bbbb");10981099    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1100    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11011102    // Equal immediately after setup…1103    expect(lenA()).toBe(lenB());11041105    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1106    // bucket while the frozen decoy stayed put, leaking that duress exists).1107    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1108    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1109    expect(lenA()).toBe(lenB());1110  });11111112  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1113    const vault = await import("./vault");1114    await vault.createVault("rollback-pass-bbbbb");1115    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11161117    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1118    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1119      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1120      Object.assign(storage, next);1121    });11221123    await expect(vault.deleteEntry(e.id)).rejects.toThrow();11241125    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1126    // Entry must still be live (not tombstoned) since the delete never persisted.1127    expect(vault.getEntryById(e.id)).toBeDefined();1128    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1129  });11301131  describe("password history", () => {1132    it("captures the old password on change, newest first", async () => {1133      const vault = await import("./vault");1134      await vault.createVault("history-pass-12345");1135      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1136      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11371138      await vault.updateEntry(e.id, { password: "second" });1139      await vault.updateEntry(e.id, { password: "third" });11401141      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1142      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1143      expect(vault.getEntryById(e.id)?.password).toBe("third");1144    });11451146    it("does not record history on a metadata-only update", async () => {1147      const vault = await import("./vault");1148      await vault.createVault("history-meta-12345");1149      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11501151      await vault.updateEntry(e.id, { title: "Renamed" });1152      await vault.updateEntry(e.id, { username: "newuser" });11531154      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1155    });11561157    it("records each history item with the age the password carried", async () => {1158      const vault = await import("./vault");1159      await vault.createVault("history-stamp-12345");1160      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1161      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1162      expect(firstChangedAt).toBeGreaterThan(0);11631164      await new Promise((resolve) => setTimeout(resolve, 2));1165      await vault.updateEntry(e.id, { password: "v2" });11661167      const hist = vault.getEntryById(e.id)!.passwordHistory!;1168      // The retired "v1" entry carries the timestamp it held while current.1169      expect(hist[0].changedAt).toBe(firstChangedAt);1170    });11711172    it("skips capture when re-saving the identical password", async () => {1173      const vault = await import("./vault");1174      await vault.createVault("history-noop-12345");1175      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11761177      await vault.updateEntry(e.id, { password: "same" });11781179      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1180    });11811182    it("does not capture an empty prior password (clear then set)", async () => {1183      const vault = await import("./vault");1184      await vault.createVault("history-empty-12345");1185      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });11861187      await vault.updateEntry(e.id, { password: "now-has-one" });11881189      // Nothing worth keeping when the prior value was empty.1190      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1191    });11921193    it("restoring an old password re-records the swapped-out current one", async () => {1194      const vault = await import("./vault");1195      await vault.createVault("history-restore-12345");1196      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1197      await vault.updateEntry(e.id, { password: "new" });11981199      // Restore "old" — the editor just saves the old value as the current one.1200      await vault.updateEntry(e.id, { password: "old" });12011202      const hist = vault.getEntryById(e.id)!.passwordHistory!;1203      expect(vault.getEntryById(e.id)?.password).toBe("old");1204      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1205    });12061207    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1208      const vault = await import("./vault");1209      await vault.createVault("history-persist-12345");1210      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1211      await vault.updateEntry(e.id, { password: "p2" });12121213      vault.lock();1214      expect(await vault.unlock("history-persist-12345")).toBe(true);12151216      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1217      expect(hist.map((h) => h.password)).toEqual(["p1"]);1218    });12191220    it("caps history at 50 entries, dropping the oldest", async () => {1221      const vault = await import("./vault");1222      await vault.createVault("history-cap-12345");1223      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12241225      for (let i = 1; i <= 55; i++) {1226        await vault.updateEntry(e.id, { password: `pw-${i}` });1227      }12281229      const hist = vault.getEntryById(e.id)!.passwordHistory!;1230      expect(hist).toHaveLength(50);1231      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1232      expect(hist[0].password).toBe("pw-54");1233      expect(hist[hist.length - 1].password).toBe("pw-5");1234    });1235  });12361237  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1238    it("drops unknown/attacker keys from a restored entry", async () => {1239      const vault = await import("./vault");1240      await vault.createVault("correct horse battery staple");1241      await vault.restoreEntries(1242        [1243          {1244            id: "e1",1245            type: "login",1246            title: "Bank",1247            username: "june",1248            password: "pw",1249            createdAt: 1,1250            modifiedAt: 2,1251            evil: "payload",1252            isAdmin: true,1253            nested: { junk: 1 },1254          } as unknown as import("../shared/types").VaultEntry,1255        ],1256        "replace",1257      );1258      const restored = vault.getEntries().find((e) => e.id === "e1")!;1259      expect(restored.password).toBe("pw");1260      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1261      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1262      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1263    });12641265    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1266      const vault = await import("./vault");1267      await vault.createVault("correct horse battery staple");1268      await vault.restoreEntries(1269        [1270          {1271            id: "e2",1272            type: "login",1273            title: "Bank",1274            password: "pw",1275            createdAt: 1,1276            modifiedAt: 2,1277            canary: { webhookUrl: "http://attacker.example.com/x" },1278          } as unknown as import("../shared/types").VaultEntry,1279        ],1280        "replace",1281      );1282      const restored = vault.getEntries().find((e) => e.id === "e2")!;1283      expect(restored).toBeDefined();1284      expect(restored.canary).toBeUndefined();1285    });12861287    it("preserves a well-formed HTTPS canary webhook", async () => {1288      const vault = await import("./vault");1289      await vault.createVault("correct horse battery staple");1290      await vault.restoreEntries(1291        [1292          {1293            id: "e3",1294            type: "login",1295            title: "Bank",1296            password: "pw",1297            createdAt: 1,1298            modifiedAt: 2,1299            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1300          } as unknown as import("../shared/types").VaultEntry,1301        ],1302        "replace",1303      );1304      const restored = vault.getEntries().find((e) => e.id === "e3")!;1305      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1306      expect(restored.canary?.label).toBe("watch");1307    });13081309    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1310      const vault = await import("./vault");1311      await vault.createVault("correct horse battery staple");1312      await vault.restoreEntries(1313        [1314          {1315            id: "e4",1316            type: "login",1317            title: "Has bad passkey",1318            createdAt: 1,1319            modifiedAt: 2,1320            passkey: { rpId: "example.com", publicKeyCose: "x" },1321          } as unknown as import("../shared/types").VaultEntry,1322        ],1323        "replace",1324      );1325      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1326    });13271328    it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1329      const vault = await import("./vault");1330      await vault.createVault("correct horse battery staple");1331      await vault.restoreEntries(1332        [1333          {1334            id: "e5",1335            type: "passkey",1336            title: "Legacy Passkey",1337            createdAt: 1,1338            modifiedAt: 2,1339            rpId: "example.com",1340            rpName: "Example",1341            credentialId: "cred-123",1342            publicKeyCose: "cose-blob",1343            userHandle: "uh",1344            userName: "june",1345            userDisplayName: "June",1346            algorithm: -7,1347          } as unknown as import("../shared/types").VaultEntry,1348        ],1349        "replace",1350      );1351      const restored = vault.getEntries().find((e) => e.id === "e5")!;1352      expect(restored).toBeDefined();1353      expect(restored.passkey?.credentialId).toBe("cred-123");1354      // Flat legacy fields are not kept as top-level keys.1355      expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1356    });1357  });13581359  describe("MV-8 rotateSyncIdentity", () => {1360    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1361      const vault = await import("./vault");1362      await vault.createVault("correct horse battery staple");1363      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1364      const { upsertPairedDevice, listPairedDevices } = await import(1365        "./paired-devices"1366      );1367      await upsertPairedDevice({1368        deviceId: "d1",1369        deviceName: "Phone",1370        deviceType: "android",1371        longTermPublicKeyHex: "a".repeat(64),1372        signingPublicKeyHex: "b".repeat(64),1373        pairedAt: 1,1374        trustSource: "direct",1375      });13761377      const rootBefore = vault.getRootKeyHex();1378      const res = await vault.rotateSyncIdentity();1379      expect(res.ok).toBe(true);1380      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1381      expect(await listPairedDevices()).toHaveLength(0);1382      // Entries survive — they are sealed under the password key, not the root.1383      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13841385      vault.lock();1386      expect(await vault.unlock("correct horse battery staple")).toBe(true);1387      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1388    });13891390    it("invalidates the old recovery words and flags recovery-pending", async () => {1391      const vault = await import("./vault");1392      await vault.createVault("correct horse battery staple");1393      const wordsBefore = (await vault.getRecoveryWords()).join(" ");13941395      await vault.rotateSyncIdentity();13961397      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1398      expect(wordsAfter).not.toBe(wordsBefore);1399      const { isRecoveryConfirmPending } = await import("./recovery-pending");1400      expect(await isRecoveryConfirmPending()).toBe(true);1401    });14021403    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1404      const vault = await import("./vault");1405      await vault.createVault("correct horse battery staple");1406      const { createSyncVaultAdapter } = await import(1407        "./sync-vault-adapter"1408      );1409      const adapter = createSyncVaultAdapter();1410      const vidBefore = await adapter.computeVaultId();14111412      await vault.rotateSyncIdentity();14131414      const vidAfter = await adapter.computeVaultId();1415      expect(vidAfter).not.toBe(vidBefore);1416    });1417  });14181419  describe("LV-1 NFC master password normalization", () => {1420    const CAFE_BASE = "café";1421    const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1422    const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14231424    it("a vault created with the NFD form unlocks with the NFC form", async () => {1425      const vault = await import("./vault");1426      expect(PW_NFD).not.toBe(PW_NFC);1427      await vault.createVault(PW_NFD);1428      vault.lock();1429      expect(await vault.unlock(PW_NFC)).toBe(true);1430    });14311432    it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1433      const vault = await import("./vault");1434      const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1435      await vault.createVault("base-password-12345");14361437      // Simulate a pre-LV-1 vault: re-seal slot A under the RAW (un-normalized)1438      // NFD bytes, which the NFC chokepoint would never reproduce.1439      const rawSalt = generateSalt();1440      const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1441      await vault.changePasswordWithNewKey(rawSalt, rawKey);1442      vault.lock();14431444      // Normal NFC derive can't open the raw seal — the legacy-raw fallback must.1445      expect(await vault.unlock(PW_NFD)).toBe(true);14461447      // Healed: the slot is now NFC-sealed, so a second unlock takes the1448      // normal path (and the NFC form opens it too).1449      vault.lock();1450      expect(await vault.unlock(PW_NFC)).toBe(true);1451    });1452  });14531454  describe("LV-7 updateSettings proto-pollution filter", () => {1455    it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1456      const vault = await import("./vault");1457      await vault.createVault("correct horse battery staple");14581459      await vault.updateSettings({1460        theme: "dark",1461        ["__proto__"]: { polluted: true },1462        constructor: { polluted: true },1463      } as never);14641465      expect(({} as Record<string, unknown>).polluted).toBeUndefined();1466      expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1467      expect(vault.getSettings().theme).toBe("dark");1468    });1469  });1470});