vault.test.ts

56.2 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>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9  let resolve!: () => void;10  const promise = new Promise<void>((done) => { resolve = done; });11  return { promise, resolve };12}1314describe("background vault", () => {15  let storage: StorageShape;16  let sessionStorage: StorageShape;17  let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1819  beforeEach(() => {20    vi.resetModules();21    storage = {};22    sessionStorage = {};23    alarms = { created: [], cleared: [] };2425    const chromeMock = {26      storage: {27        local: {28          get: vi.fn(async (key?: string | string[]) => {29            if (!key) return storage;30            if (typeof key === "string") {31              return { [key]: storage[key] };32            }3334            return key.reduce<Record<string, unknown>>((result, entry) => {35              result[entry] = storage[entry];36              return result;37            }, {});38          }),39          set: vi.fn(async (next: Record<string, unknown>) => {40            Object.assign(storage, next);41          }),42          remove: vi.fn(async (keys: string | string[]) => {43            const list = Array.isArray(keys) ? keys : [keys];44            for (const key of list) {45              delete storage[key];46            }47          }),48        },49        session: {50          get: vi.fn(async (key?: string | string[]) => {51            if (!key) return sessionStorage;52            if (typeof key === "string") return { [key]: sessionStorage[key] };53            return key.reduce<Record<string, unknown>>((result, entry) => {54              result[entry] = sessionStorage[entry];55              return result;56            }, {});57          }),58          set: vi.fn(async (next: Record<string, unknown>) => {59            Object.assign(sessionStorage, next);60          }),61          remove: vi.fn(async (keys: string | string[]) => {62            const list = Array.isArray(keys) ? keys : [keys];63            for (const key of list) delete sessionStorage[key];64          }),65          clear: vi.fn(async () => {66            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];67          }),68        },69      },70      alarms: {71        clear: vi.fn((name: string) => {72          alarms.cleared.push(name);73        }),74        create: vi.fn((name: string, info: { delayInMinutes: number }) => {75          alarms.created.push({ name, delayInMinutes: info.delayInMinutes });76        }),77        onAlarm: { addListener: vi.fn() },78      },79      idle: {80        setDetectionInterval: vi.fn(),81        onStateChanged: { addListener: vi.fn() },82      },83    } as unknown as typeof chrome;8485    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8687    // Prevent fire-and-forget breach-check fetches from leaking into other tests.88    vi.stubGlobal("fetch", vi.fn(async () => {89      throw new Error("network disabled in tests");90    }));91  });9293  it("creates a vault and stores encrypted payload", async () => {94    const vault = await import("./vault");9596    await vault.createVault("correct horse battery staple");9798    expect(storage.peach_vault_a).toBeDefined();99    expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();100    expect(vault.getEntries()).toEqual([]);101    expect(alarms.created).toContainEqual({102      name: "autolock",103      delayInMinutes: 5,104    });105  });106107  it("writes both slots A and B on vault creation", async () => {108    const vault = await import("./vault");109    await vault.createVault("correct horse battery staple");110111    expect(storage.peach_vault_a).toBeDefined();112    expect(storage.peach_vault_b).toBeDefined();113    expect(storage.peach_duress).toBeUndefined();114    expect(storage.peach_vault).toBeUndefined();115    expect(storage.peach_decoy_vault).toBeUndefined();116  });117118  it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => {119    const vault = await import("./vault");120    const password = "correct horse battery staple";121122    await vault.createVault(password);123    expect(await vault.setupPinUnlock(password, "123")).toBe(false);124    expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);125    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);126    expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();127    expect(storage.peach_pin_wrap).toBeUndefined();128129    vault.lock();130    expect(await vault.unlockWithPin("000000")).toBe(false);131    expect(await vault.unlockWithPin("1234")).toBe(true);132133    expect(await vault.changePassword(password, "new correct horse battery"))134      .toBe(true);135    vault.lock();136    expect(await vault.unlockWithPin("1234")).toBe(false);137    expect(await vault.unlock("new correct horse battery")).toBe(true);138  });139140  it("requires the master password after three incorrect PIN attempts", async () => {141    const vault = await import("./vault");142    const password = "correct horse battery staple";143144    await vault.createVault(password);145    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);146    vault.lock();147148    expect(await vault.unlockWithPin("0000")).toBe(false);149    expect(await vault.unlockWithPin("0000")).toBe(false);150    expect(await vault.unlockWithPin("0000")).toBe(false);151    expect(await vault.unlockWithPin("1234")).toBe(false);152    expect(await vault.unlock(password)).toBe(true);153154    vault.lock();155    expect(await vault.unlockWithPin("1234")).toBe(true);156  });157158  it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => {159    const vault = await import("./vault");160    const password = "correct horse battery staple";161162    await vault.createVault(password);163    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);164    const copiedLocalStorage = { ...storage };165    await (chrome.storage.session as typeof chrome.storage.session).clear();166    vault.lock();167168    expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined();169    await expect(vault.unlockWithPin("1234")).resolves.toBe(false);170    await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({171      configured: true,172      available: false,173      attemptsRemaining: 0,174    });175  });176177  it("slot A and slot B have matching ciphertext size at fresh install", async () => {178    const vault = await import("./vault");179    await vault.createVault("correct horse battery staple");180181    const a = storage.peach_vault_a as StoredVault;182    const b = storage.peach_vault_b as StoredVault;183    expect(a.ciphertext.length).toBe(b.ciphertext.length);184  });185186  it("does not expose a plaintext duress flag after setupDuress", async () => {187    const vault = await import("./vault");188    await vault.createVault("real-password-12345");189    await vault.setupDuress("decoy-password-67890");190191    expect(storage.peach_duress).toBeUndefined();192    expect(storage.peach_decoy_vault).toBeUndefined();193    const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));194    // Slot-neutral keys + optional settings + vault history (snapshot of the195    // pre-setupDuress real-mode blob). No exposed duress flag.196    for (const key of keys) {197      expect(198        [199          "peach_vault_a",200          "peach_vault_b",201          "peach_settings",202          "peach_vault_history",203          "peach_last_modified_at",204        ]205      ).toContain(key);206    }207    expect(keys).toContain("peach_vault_a");208    expect(keys).toContain("peach_vault_b");209  });210211  it("unlocks with real password and loads real entries", async () => {212    const vault = await import("./vault");213    await vault.createVault("real-password-12345");214    await vault.addEntry({215      title: "GitHub",216      username: "june",217      password: "realsecret",218      url: "github.com",219    });220    vault.lock();221222    const ok = await vault.unlock("real-password-12345");223    expect(ok).toBe(true);224    expect(vault.getEntries()).toHaveLength(1);225    expect(vault.getEntries()[0].title).toBe("GitHub");226  });227228  it("unlocks with duress password and loads empty entries", async () => {229    const vault = await import("./vault");230    await vault.createVault("real-password-12345");231    await vault.addEntry({232      title: "GitHub",233      username: "june",234      password: "realsecret",235      url: "github.com",236    });237    await vault.setupDuress("decoy-password-67890");238    vault.lock();239240    const ok = await vault.unlock("decoy-password-67890");241    expect(ok).toBe(true);242    expect(vault.getEntries()).toEqual([]);243  });244245  it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {246    const vault = await import("./vault");247    await vault.createVault("real-password-12345");248    await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });249    await vault.setupDuress("decoy-password-67890");250251    // Baseline: the real vault does have a sync identity.252    expect(vault.getRootKeyHex()).toBeTruthy();253    vault.lock();254255    expect(await vault.unlock("decoy-password-67890")).toBe(true);256    // A second vault_id would be a duress indicator: a coerced user opening the257    // decoy would make sync visibly fail on their other devices. The decoy path258    // never derives a root key, so there is nothing to derive one from.259    expect(vault.getRootKeyHex()).toBeNull();260261    // Every sync adapter operation therefore fails closed rather than syncing,262    // manifesting, or revealing an entry count.263    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");264    const adapter = createSyncVaultAdapter();265    await expect(adapter.computeVaultId()).rejects.toThrow();266    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();267    vault.lock();268  });269270  it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {271    // The unlock path must derive keys for both slots unconditionally so a272    // real-mode unlock and a decoy-mode unlock both spend roughly the same273    // wall-clock time. Verified here by spying on deriveKey and asserting it274    // is called twice (once per slot) on every unlock attempt.275    const argon2 = await import("../crypto/argon2");276    const deriveSpy = vi.spyOn(argon2, "deriveKey");277278    const vault = await import("./vault");279    await vault.createVault("real-password-12345");280    await vault.setupDuress("decoy-password-67890");281    vault.lock();282283    deriveSpy.mockClear();284    await vault.unlock("real-password-12345");285    expect(deriveSpy).toHaveBeenCalledTimes(2);286    vault.lock();287288    deriveSpy.mockClear();289    await vault.unlock("decoy-password-67890");290    expect(deriveSpy).toHaveBeenCalledTimes(2);291    vault.lock();292293    deriveSpy.mockClear();294    await vault.unlock("wrong-password-xxxxx");295    expect(deriveSpy).toHaveBeenCalledTimes(2);296297    deriveSpy.mockRestore();298  });299300  it("rejects wrong passwords", async () => {301    const vault = await import("./vault");302    await vault.createVault("real-password-12345");303    vault.lock();304305    const ok = await vault.unlock("nope-nope-nope");306    expect(ok).toBe(false);307  });308309  it("persists duressConfigured across lock/unlock in encrypted payload", async () => {310    const vault = await import("./vault");311    await vault.createVault("real-password-12345");312    await vault.setupDuress("decoy-password-67890");313    vault.lock();314315    await vault.unlock("real-password-12345");316    expect(await vault.isDuressEnabled()).toBe(true);317318    await vault.disableDuress();319    expect(await vault.isDuressEnabled()).toBe(false);320    vault.lock();321322    await vault.unlock("real-password-12345");323    expect(await vault.isDuressEnabled()).toBe(false);324  });325326  it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {327    const vault = await import("./vault");328329    await vault.createVault("correct horse battery staple");330    const entry = await vault.addEntry({331      title: "GitHub",332      username: "june",333      password: "secret123",334      url: "github.com",335    });336337    await vault.deleteEntry(entry.id);338339    expect(vault.getEntries()).toEqual([]);340341    const stored = storage.peach_vault_a as StoredVault;342    const salt = Uint8Array.from(343      stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))344    );345    const key = await deriveKey("correct horse battery staple", salt);346    const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");347    const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };348349    expect(parsed.entries).toHaveLength(1);350    expect(parsed.entries[0].id).toBe(entry.id);351    expect(typeof parsed.entries[0].deletedAt).toBe("number");352  });353354  it("accepts imported entries without a username or url", async () => {355    const vault = await import("./vault");356357    await vault.createVault("correct horse battery staple");358    const entry = await vault.addEntry({359      title: "Imported Login",360      username: "",361      password: "secret123",362      url: "",363      notes: "migrated from another manager",364    });365366    expect(entry.username).toBe("");367    expect(entry.url).toBe("");368    expect(vault.getEntries()).toHaveLength(1);369  });370371  it("changePassword happy path — new password unlocks, old is rejected", async () => {372    const vault = await import("./vault");373374    await vault.createVault("old-pass-abc123");375    await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });376    await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });377378    expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);379380    vault.lock();381    expect(await vault.unlock("new-pass-xyz789")).toBe(true);382    expect(vault.getEntries()).toHaveLength(2);383384    vault.lock();385    expect(await vault.unlock("old-pass-abc123")).toBe(false);386  });387388  it("stamps peach_last_modified_at on createVault", async () => {389    const vault = await import("./vault");390    const before = Date.now();391    await new Promise((resolve) => setTimeout(resolve, 1));392393    await vault.createVault("stamp-create-pass");394395    expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);396  });397398  it("rejects a slot A persist that races with lock", async () => {399    const vault = await import("./vault");400    await vault.createVault("persist-epoch-pass");401    const slotBefore = JSON.stringify(storage.peach_vault_a);402    const readStarted = deferred();403    const releaseRead = deferred();404    const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;405    localGet.mockImplementation(async (key?: string | string[]) => {406      if (key === "peach_vault_a") {407        readStarted.resolve();408        await releaseRead.promise;409      }410      if (!key) return storage;411      const keys = Array.isArray(key) ? key : [key];412      return keys.reduce<StorageShape>((result, entry) => {413        result[entry] = storage[entry];414        return result;415      }, {});416    });417418    const saving = vault.addEntry({ title: "stale", username: "", password: "pw", url: "" });419    await readStarted.promise;420    await vault.lock();421    releaseRead.resolve();422423    await expect(saving).rejects.toThrow("vault_locked");424    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotBefore);425    expect(vault.isUnlocked()).toBe(false);426  });427428  it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {429    const vault = await import("./vault");430    await vault.createVault("stamp-entry-pass");431    let previous = Number(storage.peach_last_modified_at);432    await new Promise((resolve) => setTimeout(resolve, 1));433    const entry = await vault.addEntry({434      title: "Stamp",435      username: "",436      password: "p",437      url: "",438    });439    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);440441    previous = Number(storage.peach_last_modified_at);442    await new Promise((resolve) => setTimeout(resolve, 1));443    await vault.updateEntry(entry.id, { title: "Stamped" });444    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);445446    previous = Number(storage.peach_last_modified_at);447    await new Promise((resolve) => setTimeout(resolve, 1));448    await vault.deleteEntry(entry.id);449    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);450  });451452  it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {453    const vault = await import("./vault");454    await vault.createVault("stamp-real-pass");455456    let previous = Number(storage.peach_last_modified_at);457    await new Promise((resolve) => setTimeout(resolve, 1));458    await vault.setupDuress("stamp-decoy-pass");459    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);460461    previous = Number(storage.peach_last_modified_at);462    await new Promise((resolve) => setTimeout(resolve, 1));463    expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);464    expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);465  });466467  it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {468    const vault = await import("./vault");469470    await vault.createVault("correct-pass-abcde");471    const slotABefore = JSON.stringify(storage.peach_vault_a);472473    expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);474    expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);475476    vault.lock();477    expect(await vault.unlock("correct-pass-abcde")).toBe(true);478  });479480  it("changePassword with duress configured leaves slot B byte-identical", async () => {481    const vault = await import("./vault");482483    await vault.createVault("real-pass-abcde");484    await vault.setupDuress("duress-pass-xyzzz");485486    const slotBBefore = JSON.stringify(storage.peach_vault_b);487    expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);488    expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);489490    // Duress path still works with the old decoy password.491    vault.lock();492    expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);493    expect(vault.getEntries()).toEqual([]);494  });495496  it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {497    const vault = await import("./vault");498499    await vault.createVault("pass-abcdef");500    await vault.addEntry({ title: "One", username: "", password: "1", url: "" });501    await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });502    await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });503504    await expect(vault.restoreEntries([], "replace")).rejects.toThrow(505      /refuse to replace with empty entry set/i506    );507    expect(vault.getEntries()).toHaveLength(3);508  });509510  it("changePassword swaps the active key before Slot B is regenerated", async () => {511    const vault = await import("./vault");512513    await vault.createVault("correct-pass-12345");514    const entry = await vault.addEntry({515      title: "GitHub",516      username: "june",517      password: "secret123",518      url: "https://github.com",519    });520521    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;522    const originalSet = setMock.getMockImplementation();523    let injectedDuringSlotBWrite = false;524525    setMock.mockImplementation(async (next: Record<string, unknown>) => {526      Object.assign(storage, next);527528      if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {529        injectedDuringSlotBWrite = true;530        await vault.updateEntry(entry.id, { notes: "updated during password change" });531      }532    });533534    expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);535536    setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {537      Object.assign(storage, next);538    }));539540    vault.lock();541    expect(await vault.unlock("new-pass-67890")).toBe(true);542    expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");543544    vault.lock();545    expect(await vault.unlock("correct-pass-12345")).toBe(false);546  });547548  it("restoreEntries replace mode replaces existing entries", async () => {549    const vault = await import("./vault");550551    await vault.createVault("replace-pass-12345");552    await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });553    await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });554555    const incoming = [556      {557        id: "entry-c",558        type: "login" as const,559        title: "C",560        username: "june",561        password: "three",562        url: "https://c.test",563        createdAt: 10,564        modifiedAt: 10,565      },566      {567        id: "entry-d",568        type: "login" as const,569        title: "D",570        username: "june",571        password: "four",572        url: "https://d.test",573        createdAt: 20,574        modifiedAt: 20,575      },576    ];577578    await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({579      added: 2,580      updated: 0,581      skipped: 0,582    });583584    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);585  });586587  it("restoreEntries merge mode lets newer incoming entries win", async () => {588    const vault = await import("./vault");589590    await vault.createVault("merge-pass-12345");591    const existing = await vault.addEntry({592      title: "GitHub",593      username: "june",594      password: "old-secret",595      url: "https://github.com",596    });597598    await expect(599      vault.restoreEntries(600        [601          {602            ...existing,603            password: "new-secret",604            modifiedAt: existing.modifiedAt + 100,605          },606        ],607        "merge"608      )609    ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });610611    expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");612  });613614  it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {615    const vault = await import("./vault");616617    await vault.createVault("merge-pass-67890");618    const existing = await vault.addEntry({619      title: "GitHub",620      username: "june",621      password: "current-secret",622      url: "https://github.com",623    });624625    await expect(626      vault.restoreEntries(627        [628          {629            ...existing,630            password: "stale-secret",631            modifiedAt: existing.modifiedAt - 100,632          },633        ],634        "merge"635      )636    ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });637638    expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");639  });640641  it("clearVaultEntries without explicit confirmation throws", async () => {642    const vault = await import("./vault");643644    await vault.createVault("clear-pass-12345");645    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });646647    const clearVaultEntries = vault.clearVaultEntries as unknown as (648      confirmation?: unknown649    ) => Promise<void>;650651    await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);652    await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);653    expect(vault.getEntries()).toHaveLength(1);654  });655656  it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {657    const vault = await import("./vault");658659    await vault.createVault("clear-pass-67890");660    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });661    await vault.addEntry({ title: "B", username: "", password: "two", url: "" });662    await vault.addEntry({ title: "C", username: "", password: "three", url: "" });663664    await vault.clearVaultEntries("REPLACE_ALL");665666    expect(vault.getEntries()).toEqual([]);667    expect(vault.getDeletedEntries()).toHaveLength(3);668    expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);669  });670671  it("deduplicateEntries throws in decoy mode", async () => {672    const vault = await import("./vault");673674    await vault.createVault("real-pass-12345");675    await vault.addEntry({676      title: "GitHub",677      username: "june",678      password: "secret123",679      url: "https://github.com",680    });681    await vault.setupDuress("duress-pass-12345");682    vault.lock();683684    expect(await vault.unlock("duress-pass-12345")).toBe(true);685    await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);686  });687688  it("deduplicateEntries removes older duplicates in real mode", async () => {689    const vault = await import("./vault");690691    await vault.createVault("dedupe-pass-12345");692    await vault.addEntry({693      title: "GitHub",694      username: "june",695      password: "old-secret",696      url: "https://github.com",697    });698    await new Promise<void>((resolve) => setTimeout(resolve, 5));699    const newer = await vault.addEntry({700      title: "GitHub",701      username: "june",702      password: "new-secret",703      url: "https://github.com",704    });705706    expect(await vault.deduplicateEntries()).toBe(1);707    expect(vault.getEntries()).toHaveLength(1);708    expect(vault.getEntries()[0].id).toBe(newer.id);709    expect(vault.getDeletedEntries()).toHaveLength(1);710  });711712  it("updateEntry with an empty password on a login entry clears the password", async () => {713    const vault = await import("./vault");714715    await vault.createVault("update-pass-12345");716    const entry = await vault.addEntry({717      title: "GitHub",718      username: "june",719      password: "secret123",720      url: "https://github.com",721    });722723    await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();724    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();725  });726727  it("updateEntry with an empty password on a note entry is allowed", async () => {728    const vault = await import("./vault");729730    await vault.createVault("note-pass-12345");731    const entry = await vault.addEntry({732      type: "note",733      title: "Secure note",734      notes: "keep me",735    });736737    await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();738    expect(vault.getEntryById(entry.id)).toMatchObject({739      type: "note",740      notes: "updated",741    });742  });743744  it("lock zeroes the derived key and resets unlock state", async () => {745    const aes = await import("./aes");746    const zeroKeySpy = vi.spyOn(aes, "zeroKey");747    const vault = await import("./vault");748749    await vault.createVault("lock-pass-12345");750    expect(vault.isUnlocked()).toBe(true);751752    vault.lock();753754    expect(zeroKeySpy).toHaveBeenCalled();755    expect(vault.isUnlocked()).toBe(false);756    zeroKeySpy.mockRestore();757  });758759  it("lock clears in-memory entries", async () => {760    const vault = await import("./vault");761762    await vault.createVault("lock-pass-67890");763    const entry = await vault.addEntry({764      title: "GitHub",765      username: "june",766      password: "secret123",767      url: "https://github.com",768    });769770    expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");771    vault.lock();772    expect(vault.getEntryById(entry.id)).toBeUndefined();773  });774775  it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {776    const vault = await import("./vault");777778    await vault.createVault("real-pass-67890");779    vault.lock();780781    expect(await vault.unlock("totally-wrong-password")).toBe(false);782    expect(vault.isUnlocked()).toBe(false);783  });784785  it("isUnlocked tracks the full lock lifecycle", async () => {786    const vault = await import("./vault");787788    expect(vault.isUnlocked()).toBe(false);789    await vault.createVault("unlock-pass-12345");790    expect(vault.isUnlocked()).toBe(true);791    vault.lock();792    expect(vault.isUnlocked()).toBe(false);793    expect(await vault.unlock("unlock-pass-12345")).toBe(true);794    expect(vault.isUnlocked()).toBe(true);795    vault.lock();796    expect(vault.isUnlocked()).toBe(false);797  });798799  it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {800    const vault = await import("./vault");801802    await vault.createVault("quota-pass-12345");803804    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;805    setMock.mockImplementation(async (next: Record<string, unknown>) => {806      if ("peach_vault_a" in next || "peach_vault_b" in next) {807        throw new Error("QUOTA_BYTES quota reached");808      }809      Object.assign(storage, next);810    });811812    await expect(813      vault.addEntry({814        title: "GitHub",815        username: "june",816        password: "secret123",817        url: "https://github.com",818      })819    ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");820  });821822  it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {823    const vault = await import("./vault");824825    await vault.createVault("settings-pass-12345");826827    const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;828    setMock.mockImplementation(async (next: Record<string, unknown>) => {829      if ("peach_settings" in next) {830        throw new Error("QUOTA_BYTES quota reached");831      }832      Object.assign(storage, next);833    });834835    await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(836      "STORAGE_QUOTA_EXCEEDED"837    );838  });839840  it("card entry round-trips through vault — all fields preserved", async () => {841    const vault = await import("./vault");842843    await vault.createVault("card-test-pass-12345");844    const entry = await vault.addEntry({845      type: "card",846      title: "Personal Visa",847      cardholderName: "Jane Smith",848      cardNumber: "4111111111111111",849      expMonth: "05",850      expYear: "2028",851      cvc: "123",852      cardNickname: "Personal Visa",853      network: "visa",854      last4: "1111",855    });856857    expect(entry.type).toBe("card");858    expect(entry.cardholderName).toBe("Jane Smith");859    expect(entry.cardNumber).toBe("4111111111111111");860    expect(entry.expMonth).toBe("05");861    expect(entry.expYear).toBe("2028");862    expect(entry.cvc).toBe("123");863    expect(entry.network).toBe("visa");864    expect(entry.last4).toBe("1111");865866    vault.lock();867    await vault.unlock("card-test-pass-12345");868    const entries = vault.getEntries();869    expect(entries).toHaveLength(1);870    expect(entries[0].cardNumber).toBe("4111111111111111");871    expect(entries[0].cvc).toBe("123");872  });873874  it("updateEntry — cannot clear card number on card entry", async () => {875    const vault = await import("./vault");876877    await vault.createVault("card-guard-pass-12345");878    const entry = await vault.addEntry({879      type: "card",880      title: "My Card",881      cardholderName: "Jane Smith",882      cardNumber: "4111111111111111",883      expMonth: "05",884      expYear: "2028",885      cvc: "123",886    });887888    await expect(889      vault.updateEntry(entry.id, { cardNumber: "" })890    ).rejects.toThrow("card number is required for card entries");891892    const entries = vault.getEntries();893    expect(entries[0].cardNumber).toBe("4111111111111111");894  });895896  it("declineCardSaveOffer — stores fingerprint in settings", async () => {897    const vault = await import("./vault");898899    await vault.createVault("decline-pass-12345");900    await vault.declineCardSaveOffer("1111", "05", "2028");901902    const settings = vault.getSettings();903    expect(settings.declinedCardFingerprints).toHaveLength(1);904  });905906  it("isCardDeclined — returns true for previously declined card", async () => {907    const vault = await import("./vault");908909    await vault.createVault("declined-check-pass-12345");910    await vault.declineCardSaveOffer("1111", "05", "2028");911912    expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);913  });914915  it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {916    const vault = await import("./vault");917918    await vault.createVault("declined-diff-pass-12345");919    await vault.declineCardSaveOffer("1111", "05", "2028");920921    expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);922  });923924  it("queryDomain matches an entry against its additionalUrls", async () => {925    const vault = await import("./vault");926927    await vault.createVault("multi-domain-pass-12345");928    const entry = await vault.addEntry({929      type: "login",930      title: "Foo",931      username: "june",932      password: "secret",933      url: "https://foo.com",934      additionalUrls: ["https://bar.io", "https://baz.org"],935    });936937    // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only938    // add new buckets when the eTLD+1 is genuinely different from the primary.939    expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);940    expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);941    expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);942    expect(vault.queryDomain("unrelated.com")).toEqual([]);943  });944945  it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {946    const vault = await import("./vault");947948    await vault.createVault("dedupe-additional-pass-12345");949    const entry = await vault.addEntry({950      type: "login",951      title: "Foo",952      username: "june",953      password: "secret",954      url: "https://foo.com",955    });956957    await vault.updateEntry(entry.id, {958      additionalUrls: [959        "  https://login.foo.com  ",960        "https://login.foo.com",961        "",962        "  ",963        "https://accounts.foo.com",964      ],965    });966967    const stored = vault.getEntryById(entry.id);968    expect(stored?.additionalUrls).toEqual([969      "https://login.foo.com",970      "https://accounts.foo.com",971    ]);972973    await vault.updateEntry(entry.id, { additionalUrls: ["", "   "] });974    expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();975  });976977  it("preserves email field through lock/unlock cycle", async () => {978    const vault = await import("./vault");979    await vault.createVault("preserve-email-pass-12345");980    await vault.addEntry({981      type: "login",982      title: "GitHub",983      username: "june",984      email: "[email protected]",985      password: "secret",986      url: "https://github.com",987    });988    vault.lock();989990    const ok = await vault.unlock("preserve-email-pass-12345");991    expect(ok).toBe(true);992    const entry = vault.getEntries().find((e) => e.title === "GitHub");993    expect(entry?.email).toBe("[email protected]");994  });995996  it("preserves additionalUrls through lock/unlock cycle", async () => {997    const vault = await import("./vault");998    await vault.createVault("preserve-urls-pass-12345");999    await vault.addEntry({1000      type: "login",1001      title: "Example",1002      username: "june",1003      password: "secret",1004      url: "https://example.com",1005      additionalUrls: [1006        "https://beta.example.com",1007        "https://staging.example.com",1008      ],1009    });1010    vault.lock();10111012    const ok = await vault.unlock("preserve-urls-pass-12345");1013    expect(ok).toBe(true);1014    const entry = vault.getEntries().find((e) => e.title === "Example");1015    expect(entry?.additionalUrls).toEqual([1016      "https://beta.example.com",1017      "https://staging.example.com",1018    ]);1019  });10201021  it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {1022    const vault = await import("./vault");1023    await vault.createVault("pwchanged-preserve-pass");1024    const entry = await vault.addEntry({1025      title: "Aged",1026      username: "june",1027      password: "secret",1028      url: "https://aged.test",1029    });1030    // addEntry stamps passwordChangedAt at creation; capture it and prove it1031    // survives the encode/decode round-trip unchanged (was dropped pre-fix).1032    const ts = entry.passwordChangedAt;1033    expect(typeof ts).toBe("number");1034    vault.lock();10351036    expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);1037    const restored = vault.getEntries().find((e) => e.id === entry.id);1038    expect(restored?.passwordChangedAt).toBe(ts);1039  });10401041  it("normalizeEntry handles entries without passwordChangedAt", async () => {1042    const vault = await import("./vault");1043    await vault.createVault("pwchanged-absent-pass");1044    // A note carries no password and therefore no passwordChangedAt stamp.1045    const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });1046    vault.lock();10471048    expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);1049    const restored = vault.getEntries().find((e) => e.id === note.id);1050    expect(restored?.passwordChangedAt).toBeUndefined();1051  });10521053  it("applyMergedRemoteEntries does not advance the vault version", async () => {1054    const vault = await import("./vault");1055    await vault.createVault("syncver-pass-aaaaa");1056    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10571058    const before = await vault.getVaultVersion();1059    await new Promise((r) => setTimeout(r, 2));1060    await vault.applyMergedRemoteEntries(1061      [1062        {1063          id: "remote-1",1064          type: "login",1065          title: "Remote",1066          username: "r",1067          password: "x",1068          url: "https://r.test",1069          createdAt: Date.now(),1070          modifiedAt: Date.now(),1071        },1072      ],1073      {},1074    );10751076    expect(await vault.getVaultVersion()).toBe(before);1077  });10781079  it("addEntry advances the vault version", async () => {1080    const vault = await import("./vault");1081    await vault.createVault("syncver-pass-bbbbb");10821083    const before = await vault.getVaultVersion();1084    await new Promise((r) => setTimeout(r, 2));1085    await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10861087    expect(await vault.getVaultVersion()).toBeGreaterThan(before);1088  });10891090  it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1091    const vault = await import("./vault");1092    await vault.createVault("monotonic-pass-aaaaa");1093    const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1094    const before = vault.getEntryById(e1.id)!.modifiedAt;10951096    // Simulate the clock jumping 10s backwards (NTP step / manual change).1097    const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1098    await vault.updateEntry(e1.id, { password: "p2" });1099    const after = vault.getEntryById(e1.id)!.modifiedAt;1100    expect(after).toBeGreaterThan(before);1101    spy.mockRestore();1102  });11031104  it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1105    const vault = await import("./vault");1106    await vault.createVault("monotonic-pass-bbbbb");1107    const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1108    const modAt = vault.getEntryById(e.id)!.modifiedAt;1109    await vault.deleteEntry(e.id);1110    const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1111    expect(tomb.deletedAt!).toBeGreaterThan(modAt);1112  });11131114  it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1115    const vault = await import("./vault");1116    await vault.createVault("rollback-pass-aaaaa");1117    const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });11181119    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1120    // Fail only the slot-A write (the history snapshot write is swallowed by design).1121    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1122      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1123      Object.assign(storage, next);1124    });11251126    await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();11271128    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1129    // In-memory entry must still hold the original password, not the failed edit.1130    expect(vault.getEntryById(e.id)!.password).toBe("original");1131  });11321133  it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1134    const vault = await import("./vault");1135    await vault.createVault("rootkey-pass-aaaaa");1136    const before = vault.getRootKeyHex();1137    expect(before).toMatch(/^[0-9a-f]{64}$/);11381139    expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1140    expect(vault.getRootKeyHex()).toBe(before);11411142    // …and it survives a lock/unlock with the new password (rehydrated from pw_root_*).1143    vault.lock();1144    expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1145    expect(vault.getRootKeyHex()).toBe(before);1146  });11471148  it("getRootKeyHex returns null when the vault is locked", async () => {1149    const vault = await import("./vault");1150    await vault.createVault("rootkey-pass-ccccc");1151    vault.lock();1152    expect(vault.getRootKeyHex()).toBeNull();1153  });11541155  it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1156    const vault = await import("./vault");1157    await vault.createVault("biometric-pass-12345");1158    const rootBefore = vault.getRootKeyHex();1159    const keyHex = vault.getDerivedKeyHex();1160    expect(rootBefore).not.toBeNull();1161    vault.lock();1162    // The biometric path passes the pre-derived AES key; rootKey must rehydrate.1163    const r = await vault.unlockWithKey(keyHex);1164    expect(r.ok).toBe(true);1165    expect(vault.getRootKeyHex()).toBe(rootBefore);1166  });11671168  it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1169    const vault = await import("./vault");1170    await vault.createVault("duress-real-pass-aaaa");1171    await vault.setupDuress("duress-decoy-pass-bbbb");11721173    const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1174    const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11751176    // Equal immediately after setup…1177    expect(lenA()).toBe(lenB());11781179    // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1180    // bucket while the frozen decoy stayed put, leaking that duress exists).1181    await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1182    await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1183    expect(lenA()).toBe(lenB());1184  });11851186  it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1187    const vault = await import("./vault");1188    await vault.createVault("rollback-pass-bbbbb");1189    const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11901191    const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1192    setSpy.mockImplementation(async (next: Record<string, unknown>) => {1193      if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1194      Object.assign(storage, next);1195    });11961197    await expect(vault.deleteEntry(e.id)).rejects.toThrow();11981199    setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1200    // Entry must still be live (not tombstoned) since the delete never persisted.1201    expect(vault.getEntryById(e.id)).toBeDefined();1202    expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1203  });12041205  describe("password history", () => {1206    it("captures the old password on change, newest first", async () => {1207      const vault = await import("./vault");1208      await vault.createVault("history-pass-12345");1209      const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1210      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();12111212      await vault.updateEntry(e.id, { password: "second" });1213      await vault.updateEntry(e.id, { password: "third" });12141215      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1216      expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1217      expect(vault.getEntryById(e.id)?.password).toBe("third");1218    });12191220    it("does not record history on a metadata-only update", async () => {1221      const vault = await import("./vault");1222      await vault.createVault("history-meta-12345");1223      const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });12241225      await vault.updateEntry(e.id, { title: "Renamed" });1226      await vault.updateEntry(e.id, { username: "newuser" });12271228      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1229    });12301231    it("records each history item with the age the password carried", async () => {1232      const vault = await import("./vault");1233      await vault.createVault("history-stamp-12345");1234      const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1235      const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1236      expect(firstChangedAt).toBeGreaterThan(0);12371238      await new Promise((resolve) => setTimeout(resolve, 2));1239      await vault.updateEntry(e.id, { password: "v2" });12401241      const hist = vault.getEntryById(e.id)!.passwordHistory!;1242      // The retired "v1" entry carries the timestamp it held while current.1243      expect(hist[0].changedAt).toBe(firstChangedAt);1244    });12451246    it("skips capture when re-saving the identical password", async () => {1247      const vault = await import("./vault");1248      await vault.createVault("history-noop-12345");1249      const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });12501251      await vault.updateEntry(e.id, { password: "same" });12521253      expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1254    });12551256    it("does not capture an empty prior password (clear then set)", async () => {1257      const vault = await import("./vault");1258      await vault.createVault("history-empty-12345");1259      const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12601261      await vault.updateEntry(e.id, { password: "now-has-one" });12621263      // Nothing worth keeping when the prior value was empty.1264      expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1265    });12661267    it("restoring an old password re-records the swapped-out current one", async () => {1268      const vault = await import("./vault");1269      await vault.createVault("history-restore-12345");1270      const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1271      await vault.updateEntry(e.id, { password: "new" });12721273      // Restore "old" — the editor just saves the old value as the current one.1274      await vault.updateEntry(e.id, { password: "old" });12751276      const hist = vault.getEntryById(e.id)!.passwordHistory!;1277      expect(vault.getEntryById(e.id)?.password).toBe("old");1278      expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1279    });12801281    it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1282      const vault = await import("./vault");1283      await vault.createVault("history-persist-12345");1284      const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1285      await vault.updateEntry(e.id, { password: "p2" });12861287      vault.lock();1288      expect(await vault.unlock("history-persist-12345")).toBe(true);12891290      const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1291      expect(hist.map((h) => h.password)).toEqual(["p1"]);1292    });12931294    it("caps history at 50 entries, dropping the oldest", async () => {1295      const vault = await import("./vault");1296      await vault.createVault("history-cap-12345");1297      const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12981299      for (let i = 1; i <= 55; i++) {1300        await vault.updateEntry(e.id, { password: `pw-${i}` });1301      }13021303      const hist = vault.getEntryById(e.id)!.passwordHistory!;1304      expect(hist).toHaveLength(50);1305      // Newest retired password is pw-54 (pw-55 is now current); oldest kept is pw-5.1306      expect(hist[0].password).toBe("pw-54");1307      expect(hist[hist.length - 1].password).toBe("pw-5");1308    });1309  });13101311  describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1312    it("drops unknown/attacker keys from a restored entry", async () => {1313      const vault = await import("./vault");1314      await vault.createVault("correct horse battery staple");1315      await vault.restoreEntries(1316        [1317          {1318            id: "e1",1319            type: "login",1320            title: "Bank",1321            username: "june",1322            password: "pw",1323            createdAt: 1,1324            modifiedAt: 2,1325            evil: "payload",1326            isAdmin: true,1327            nested: { junk: 1 },1328          } as unknown as import("../shared/types").VaultEntry,1329        ],1330        "replace",1331      );1332      const restored = vault.getEntries().find((e) => e.id === "e1")!;1333      expect(restored.password).toBe("pw");1334      expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1335      expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1336      expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1337    });13381339    it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1340      const vault = await import("./vault");1341      await vault.createVault("correct horse battery staple");1342      await vault.restoreEntries(1343        [1344          {1345            id: "e2",1346            type: "login",1347            title: "Bank",1348            password: "pw",1349            createdAt: 1,1350            modifiedAt: 2,1351            canary: { webhookUrl: "http://attacker.example.com/x" },1352          } as unknown as import("../shared/types").VaultEntry,1353        ],1354        "replace",1355      );1356      const restored = vault.getEntries().find((e) => e.id === "e2")!;1357      expect(restored).toBeDefined();1358      expect(restored.canary).toBeUndefined();1359    });13601361    it("preserves a well-formed HTTPS canary webhook", async () => {1362      const vault = await import("./vault");1363      await vault.createVault("correct horse battery staple");1364      await vault.restoreEntries(1365        [1366          {1367            id: "e3",1368            type: "login",1369            title: "Bank",1370            password: "pw",1371            createdAt: 1,1372            modifiedAt: 2,1373            canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1374          } as unknown as import("../shared/types").VaultEntry,1375        ],1376        "replace",1377      );1378      const restored = vault.getEntries().find((e) => e.id === "e3")!;1379      expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1380      expect(restored.canary?.label).toBe("watch");1381    });13821383    it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1384      const vault = await import("./vault");1385      await vault.createVault("correct horse battery staple");1386      await vault.restoreEntries(1387        [1388          {1389            id: "e4",1390            type: "login",1391            title: "Has bad passkey",1392            createdAt: 1,1393            modifiedAt: 2,1394            passkey: { rpId: "example.com", publicKeyCose: "x" },1395          } as unknown as import("../shared/types").VaultEntry,1396        ],1397        "replace",1398      );1399      expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1400    });14011402  });14031404  describe("MV-8 rotateSyncIdentity", () => {1405    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1406      const vault = await import("./vault");1407      await vault.createVault("correct horse battery staple");1408      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1409      const { upsertPairedDevice, listPairedDevices } = await import(1410        "./paired-devices"1411      );1412      await upsertPairedDevice({1413        deviceId: "d1",1414        deviceName: "Phone",1415        deviceType: "android",1416        longTermPublicKeyHex: "a".repeat(64),1417        signingPublicKeyHex: "b".repeat(64),1418        pairedAt: 1,1419        trustSource: "direct",1420      });14211422      const rootBefore = vault.getRootKeyHex();1423      const res = await vault.rotateSyncIdentity();1424      expect(res.ok).toBe(true);1425      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1426      expect(await listPairedDevices()).toHaveLength(0);1427      // Entries survive — they are sealed under the password key, not the root.1428      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");14291430      vault.lock();1431      expect(await vault.unlock("correct horse battery staple")).toBe(true);1432      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1433    });14341435    it("invalidates the old recovery words and flags recovery-pending", async () => {1436      const vault = await import("./vault");1437      await vault.createVault("correct horse battery staple");1438      const wordsBefore = (await vault.getRecoveryWords()).join(" ");14391440      await vault.rotateSyncIdentity();14411442      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1443      expect(wordsAfter).not.toBe(wordsBefore);1444      const { isRecoveryConfirmPending } = await import("./recovery-pending");1445      expect(await isRecoveryConfirmPending()).toBe(true);1446    });14471448    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1449      const vault = await import("./vault");1450      await vault.createVault("correct horse battery staple");1451      const { createSyncVaultAdapter } = await import(1452        "./sync-vault-adapter"1453      );1454      const adapter = createSyncVaultAdapter();1455      const vidBefore = await adapter.computeVaultId();14561457      await vault.rotateSyncIdentity();14581459      const vidAfter = await adapter.computeVaultId();1460      expect(vidAfter).not.toBe(vidBefore);1461    });1462  });14631464  describe("LV-1 NFC master password normalization", () => {1465    const CAFE_BASE = "café";1466    const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1467    const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14681469    it("a vault created with the NFD form unlocks with the NFC form", async () => {1470      const vault = await import("./vault");1471      expect(PW_NFD).not.toBe(PW_NFC);1472      await vault.createVault(PW_NFD);1473      vault.lock();1474      expect(await vault.unlock(PW_NFC)).toBe(true);1475    });14761477  });14781479  describe("LV-7 updateSettings proto-pollution filter", () => {1480    it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1481      const vault = await import("./vault");1482      await vault.createVault("correct horse battery staple");14831484      await vault.updateSettings({1485        theme: "dark",1486        ["__proto__"]: { polluted: true },1487        constructor: { polluted: true },1488      } as never);14891490      expect(({} as Record<string, unknown>).polluted).toBeUndefined();1491      expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1492      expect(vault.getSettings().theme).toBe("dark");1493    });1494  });14951496  // PB-26-011: URL scheme allow-list at the storage boundary — imported,1497  // added, or patched URLs with a non-http(s) scheme are dropped. Schemeless1498  // values (e.g. "github.com") are kept: they are inert and pre-date this1499  // guard in existing data.1500  describe("PB-26-011 url scheme allow-list", () => {1501    let vault: typeof import("./vault");15021503    beforeEach(async () => {1504      vault = await import("./vault");1505      await vault.createVault("correct horse battery staple");1506    });15071508    it("drops a javascript: url on addEntry", async () => {1509      const entry = await vault.addEntry({1510        title: "Evil",1511        username: "june",1512        password: "secret1",1513        url: "javascript:alert(1)",1514      });1515      const stored = vault.getEntryById(entry.id);1516      expect(stored?.url).toBe("");1517    });15181519    it("drops non-http(s) schemes from additionalUrls", async () => {1520      const entry = await vault.addEntry({1521        title: "Evil",1522        username: "june",1523        password: "secret1",1524        url: "https://ok.test",1525        additionalUrls: [1526          "javascript:alert(1)",1527          "data:text/html,<b>x</b>",1528          "https://also-ok.test",1529        ],1530      });1531      const stored = vault.getEntryById(entry.id);1532      expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]);1533    });15341535    it("drops a data: url on UPDATE_ENTRY patch", async () => {1536      const entry = await vault.addEntry({1537        title: "Ok",1538        username: "june",1539        password: "secret1",1540        url: "https://ok.test",1541      });1542      await vault.updateEntry(entry.id, { url: "data:text/html,<b>x</b>" });1543      const stored = vault.getEntryById(entry.id);1544      expect(stored?.url).toBe("");1545    });15461547    it("keeps https and schemeless urls", async () => {1548      const entry = await vault.addEntry({1549        title: "Ok",1550        username: "june",1551        password: "secret1",1552        url: "https://github.com",1553        additionalUrls: ["github.com"],1554      });1555      const stored = vault.getEntryById(entry.id);1556      expect(stored?.url).toBe("https://github.com");1557      expect(stored?.additionalUrls).toEqual(["github.com"]);1558    });1559  });1560});