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 60 vi.stubGlobal("fetch", vi.fn(async () => {61 throw new Error("network disabled in tests");62 }));63 });6465 it("creates a vault and stores encrypted payload", async () => {66 const vault = await import("./vault");6768 await vault.createVault("correct horse battery staple");6970 expect(storage.peach_vault_a).toBeDefined();71 expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();72 expect(vault.getEntries()).toEqual([]);73 expect(alarms.created).toContainEqual({74 name: "autolock",75 delayInMinutes: 5,76 });77 });7879 it("writes both slots A and B on vault creation", async () => {80 const vault = await import("./vault");81 await vault.createVault("correct horse battery staple");8283 expect(storage.peach_vault_a).toBeDefined();84 expect(storage.peach_vault_b).toBeDefined();85 expect(storage.peach_duress).toBeUndefined();86 expect(storage.peach_vault).toBeUndefined();87 expect(storage.peach_decoy_vault).toBeUndefined();88 });8990 it("wraps the derived vault key for PIN unlock and invalidates it on password change", async () => {91 const vault = await import("./vault");92 const password = "correct horse battery staple";9394 await vault.createVault(password);95 expect(await vault.setupPinUnlock(password, "123")).toBe(false);96 expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);97 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);98 expect((storage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();99100 vault.lock();101 expect(await vault.unlockWithPin("000000")).toBe(false);102 expect(await vault.unlockWithPin("1234")).toBe(true);103104 expect(await vault.changePassword(password, "new correct horse battery"))105 .toBe(true);106 vault.lock();107 expect(await vault.unlockWithPin("1234")).toBe(false);108 expect(await vault.unlock("new correct horse battery")).toBe(true);109 });110111 it("slot A and slot B have matching ciphertext size at fresh install", async () => {112 const vault = await import("./vault");113 await vault.createVault("correct horse battery staple");114115 const a = storage.peach_vault_a as StoredVault;116 const b = storage.peach_vault_b as StoredVault;117 expect(a.ciphertext.length).toBe(b.ciphertext.length);118 });119120 it("does not expose a plaintext duress flag after setupDuress", async () => {121 const vault = await import("./vault");122 await vault.createVault("real-password-12345");123 await vault.setupDuress("decoy-password-67890");124125 expect(storage.peach_duress).toBeUndefined();126 expect(storage.peach_decoy_vault).toBeUndefined();127 const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));128 129 130 for (const key of keys) {131 expect(132 [133 "peach_vault_a",134 "peach_vault_b",135 "peach_settings",136 "peach_vault_history",137 "peach_last_modified_at",138 ]139 ).toContain(key);140 }141 expect(keys).toContain("peach_vault_a");142 expect(keys).toContain("peach_vault_b");143 });144145 it("unlocks with real password and loads real entries", async () => {146 const vault = await import("./vault");147 await vault.createVault("real-password-12345");148 await vault.addEntry({149 title: "GitHub",150 username: "june",151 password: "realsecret",152 url: "github.com",153 });154 vault.lock();155156 const ok = await vault.unlock("real-password-12345");157 expect(ok).toBe(true);158 expect(vault.getEntries()).toHaveLength(1);159 expect(vault.getEntries()[0].title).toBe("GitHub");160 });161162 it("unlocks with duress password and loads empty entries", async () => {163 const vault = await import("./vault");164 await vault.createVault("real-password-12345");165 await vault.addEntry({166 title: "GitHub",167 username: "june",168 password: "realsecret",169 url: "github.com",170 });171 await vault.setupDuress("decoy-password-67890");172 vault.lock();173174 const ok = await vault.unlock("decoy-password-67890");175 expect(ok).toBe(true);176 expect(vault.getEntries()).toEqual([]);177 });178179 it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {180 const vault = await import("./vault");181 await vault.createVault("real-password-12345");182 await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });183 await vault.setupDuress("decoy-password-67890");184185 186 expect(vault.getRootKeyHex()).toBeTruthy();187 vault.lock();188189 expect(await vault.unlock("decoy-password-67890")).toBe(true);190 191 192 193 expect(vault.getRootKeyHex()).toBeNull();194195 196 197 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");198 const adapter = createSyncVaultAdapter();199 await expect(adapter.computeVaultId()).rejects.toThrow();200 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();201 vault.lock();202 });203204 it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {205 206 207 208 209 const argon2 = await import("../crypto/argon2");210 const deriveSpy = vi.spyOn(argon2, "deriveKey");211212 const vault = await import("./vault");213 await vault.createVault("real-password-12345");214 await vault.setupDuress("decoy-password-67890");215 vault.lock();216217 deriveSpy.mockClear();218 await vault.unlock("real-password-12345");219 expect(deriveSpy).toHaveBeenCalledTimes(2);220 vault.lock();221222 deriveSpy.mockClear();223 await vault.unlock("decoy-password-67890");224 expect(deriveSpy).toHaveBeenCalledTimes(2);225 vault.lock();226227 deriveSpy.mockClear();228 await vault.unlock("wrong-password-xxxxx");229 expect(deriveSpy).toHaveBeenCalledTimes(2);230231 deriveSpy.mockRestore();232 });233234 it("rejects wrong passwords", async () => {235 const vault = await import("./vault");236 await vault.createVault("real-password-12345");237 vault.lock();238239 const ok = await vault.unlock("nope-nope-nope");240 expect(ok).toBe(false);241 });242243 it("persists duressConfigured across lock/unlock in encrypted payload", async () => {244 const vault = await import("./vault");245 await vault.createVault("real-password-12345");246 await vault.setupDuress("decoy-password-67890");247 vault.lock();248249 await vault.unlock("real-password-12345");250 expect(await vault.isDuressEnabled()).toBe(true);251252 await vault.disableDuress();253 expect(await vault.isDuressEnabled()).toBe(false);254 vault.lock();255256 await vault.unlock("real-password-12345");257 expect(await vault.isDuressEnabled()).toBe(false);258 });259260 it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {261 262 const firstVault = await import("./vault");263 await firstVault.createVault("legacy-password-abcde");264 const legacySlotA = storage.peach_vault_a as StoredVault;265 storage.peach_vault = legacySlotA;266 delete storage.peach_vault_a;267 delete storage.peach_vault_b;268 storage.peach_duress = { enabled: true };269 storage.peach_decoy_vault = legacySlotA; 270 vi.resetModules();271272 273 const vault = await import("./vault");274 await vault.hasVault();275276 277 expect(storage.peach_vault).toBeUndefined();278 expect(storage.peach_decoy_vault).toBeUndefined();279 expect(storage.peach_duress).toBeUndefined();280 expect(storage.peach_vault_a).toBeDefined();281 expect(storage.peach_vault_b).toBeDefined();282 });283284 it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {285 const vault = await import("./vault");286287 await vault.createVault("correct horse battery staple");288 const entry = await vault.addEntry({289 title: "GitHub",290 username: "june",291 password: "secret123",292 url: "github.com",293 });294295 await vault.deleteEntry(entry.id);296297 expect(vault.getEntries()).toEqual([]);298299 const stored = storage.peach_vault_a as StoredVault;300 const salt = Uint8Array.from(301 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))302 );303 const key = await deriveKey("correct horse battery staple", salt);304 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");305 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };306307 expect(parsed.entries).toHaveLength(1);308 expect(parsed.entries[0].id).toBe(entry.id);309 expect(typeof parsed.entries[0].deletedAt).toBe("number");310 });311312 it("accepts imported entries without a username or url", async () => {313 const vault = await import("./vault");314315 await vault.createVault("correct horse battery staple");316 const entry = await vault.addEntry({317 title: "Imported Login",318 username: "",319 password: "secret123",320 url: "",321 notes: "migrated from another manager",322 });323324 expect(entry.username).toBe("");325 expect(entry.url).toBe("");326 expect(vault.getEntries()).toHaveLength(1);327 });328329 it("changePassword happy path — new password unlocks, old is rejected", async () => {330 const vault = await import("./vault");331332 await vault.createVault("old-pass-abc123");333 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });334 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });335336 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);337338 vault.lock();339 expect(await vault.unlock("new-pass-xyz789")).toBe(true);340 expect(vault.getEntries()).toHaveLength(2);341342 vault.lock();343 expect(await vault.unlock("old-pass-abc123")).toBe(false);344 });345346 it("stamps peach_last_modified_at on createVault", async () => {347 const vault = await import("./vault");348 const before = Date.now();349 await new Promise((resolve) => setTimeout(resolve, 1));350351 await vault.createVault("stamp-create-pass");352353 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);354 });355356 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {357 const vault = await import("./vault");358 await vault.createVault("stamp-entry-pass");359360 let previous = Number(storage.peach_last_modified_at);361 await new Promise((resolve) => setTimeout(resolve, 1));362 const entry = await vault.addEntry({363 title: "Stamp",364 username: "",365 password: "p",366 url: "",367 });368 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);369370 previous = Number(storage.peach_last_modified_at);371 await new Promise((resolve) => setTimeout(resolve, 1));372 await vault.updateEntry(entry.id, { title: "Stamped" });373 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);374375 previous = Number(storage.peach_last_modified_at);376 await new Promise((resolve) => setTimeout(resolve, 1));377 await vault.deleteEntry(entry.id);378 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);379 });380381 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {382 const vault = await import("./vault");383 await vault.createVault("stamp-real-pass");384385 let previous = Number(storage.peach_last_modified_at);386 await new Promise((resolve) => setTimeout(resolve, 1));387 await vault.setupDuress("stamp-decoy-pass");388 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);389390 previous = Number(storage.peach_last_modified_at);391 await new Promise((resolve) => setTimeout(resolve, 1));392 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);393 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);394 });395396 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {397 const vault = await import("./vault");398399 await vault.createVault("correct-pass-abcde");400 const slotABefore = JSON.stringify(storage.peach_vault_a);401402 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);403 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);404405 vault.lock();406 expect(await vault.unlock("correct-pass-abcde")).toBe(true);407 });408409 it("changePassword with duress configured leaves slot B byte-identical", async () => {410 const vault = await import("./vault");411412 await vault.createVault("real-pass-abcde");413 await vault.setupDuress("duress-pass-xyzzz");414415 const slotBBefore = JSON.stringify(storage.peach_vault_b);416 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);417 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);418419 420 vault.lock();421 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);422 expect(vault.getEntries()).toEqual([]);423 });424425 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {426 const vault = await import("./vault");427428 await vault.createVault("pass-abcdef");429 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });430 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });431 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });432433 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(434 /refuse to replace with empty entry set/i435 );436 expect(vault.getEntries()).toHaveLength(3);437 });438439 it("changePassword swaps the active key before Slot B is regenerated", async () => {440 const vault = await import("./vault");441442 await vault.createVault("correct-pass-12345");443 const entry = await vault.addEntry({444 title: "GitHub",445 username: "june",446 password: "secret123",447 url: "https://github.com",448 });449450 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;451 const originalSet = setMock.getMockImplementation();452 let injectedDuringSlotBWrite = false;453454 setMock.mockImplementation(async (next: Record<string, unknown>) => {455 Object.assign(storage, next);456457 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {458 injectedDuringSlotBWrite = true;459 await vault.updateEntry(entry.id, { notes: "updated during password change" });460 }461 });462463 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);464465 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {466 Object.assign(storage, next);467 }));468469 vault.lock();470 expect(await vault.unlock("new-pass-67890")).toBe(true);471 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");472473 vault.lock();474 expect(await vault.unlock("correct-pass-12345")).toBe(false);475 });476477 it("restoreEntries replace mode replaces existing entries", async () => {478 const vault = await import("./vault");479480 await vault.createVault("replace-pass-12345");481 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });482 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });483484 const incoming = [485 {486 id: "entry-c",487 type: "login" as const,488 title: "C",489 username: "june",490 password: "three",491 url: "https://c.test",492 createdAt: 10,493 modifiedAt: 10,494 },495 {496 id: "entry-d",497 type: "login" as const,498 title: "D",499 username: "june",500 password: "four",501 url: "https://d.test",502 createdAt: 20,503 modifiedAt: 20,504 },505 ];506507 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({508 added: 2,509 updated: 0,510 skipped: 0,511 });512513 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);514 });515516 it("restoreEntries merge mode lets newer incoming entries win", async () => {517 const vault = await import("./vault");518519 await vault.createVault("merge-pass-12345");520 const existing = await vault.addEntry({521 title: "GitHub",522 username: "june",523 password: "old-secret",524 url: "https://github.com",525 });526527 await expect(528 vault.restoreEntries(529 [530 {531 ...existing,532 password: "new-secret",533 modifiedAt: existing.modifiedAt + 100,534 },535 ],536 "merge"537 )538 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });539540 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");541 });542543 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {544 const vault = await import("./vault");545546 await vault.createVault("merge-pass-67890");547 const existing = await vault.addEntry({548 title: "GitHub",549 username: "june",550 password: "current-secret",551 url: "https://github.com",552 });553554 await expect(555 vault.restoreEntries(556 [557 {558 ...existing,559 password: "stale-secret",560 modifiedAt: existing.modifiedAt - 100,561 },562 ],563 "merge"564 )565 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });566567 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");568 });569570 it("clearVaultEntries without explicit confirmation throws", async () => {571 const vault = await import("./vault");572573 await vault.createVault("clear-pass-12345");574 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });575576 const clearVaultEntries = vault.clearVaultEntries as unknown as (577 confirmation?: unknown578 ) => Promise<void>;579580 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);581 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);582 expect(vault.getEntries()).toHaveLength(1);583 });584585 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {586 const vault = await import("./vault");587588 await vault.createVault("clear-pass-67890");589 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });590 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });591 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });592593 await vault.clearVaultEntries("REPLACE_ALL");594595 expect(vault.getEntries()).toEqual([]);596 expect(vault.getDeletedEntries()).toHaveLength(3);597 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);598 });599600 it("deduplicateEntries throws in decoy mode", async () => {601 const vault = await import("./vault");602603 await vault.createVault("real-pass-12345");604 await vault.addEntry({605 title: "GitHub",606 username: "june",607 password: "secret123",608 url: "https://github.com",609 });610 await vault.setupDuress("duress-pass-12345");611 vault.lock();612613 expect(await vault.unlock("duress-pass-12345")).toBe(true);614 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);615 });616617 it("deduplicateEntries removes older duplicates in real mode", async () => {618 const vault = await import("./vault");619620 await vault.createVault("dedupe-pass-12345");621 await vault.addEntry({622 title: "GitHub",623 username: "june",624 password: "old-secret",625 url: "https://github.com",626 });627 await new Promise<void>((resolve) => setTimeout(resolve, 5));628 const newer = await vault.addEntry({629 title: "GitHub",630 username: "june",631 password: "new-secret",632 url: "https://github.com",633 });634635 expect(await vault.deduplicateEntries()).toBe(1);636 expect(vault.getEntries()).toHaveLength(1);637 expect(vault.getEntries()[0].id).toBe(newer.id);638 expect(vault.getDeletedEntries()).toHaveLength(1);639 });640641 it("updateEntry with an empty password on a login entry clears the password", async () => {642 const vault = await import("./vault");643644 await vault.createVault("update-pass-12345");645 const entry = await vault.addEntry({646 title: "GitHub",647 username: "june",648 password: "secret123",649 url: "https://github.com",650 });651652 await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();653 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();654 });655656 it("updateEntry with an empty password on a note entry is allowed", async () => {657 const vault = await import("./vault");658659 await vault.createVault("note-pass-12345");660 const entry = await vault.addEntry({661 type: "note",662 title: "Secure note",663 notes: "keep me",664 });665666 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();667 expect(vault.getEntryById(entry.id)).toMatchObject({668 type: "note",669 notes: "updated",670 });671 });672673 it("lock zeroes the derived key and resets unlock state", async () => {674 const aes = await import("./aes");675 const zeroKeySpy = vi.spyOn(aes, "zeroKey");676 const vault = await import("./vault");677678 await vault.createVault("lock-pass-12345");679 expect(vault.isUnlocked()).toBe(true);680681 vault.lock();682683 expect(zeroKeySpy).toHaveBeenCalled();684 expect(vault.isUnlocked()).toBe(false);685 zeroKeySpy.mockRestore();686 });687688 it("lock clears in-memory entries", async () => {689 const vault = await import("./vault");690691 await vault.createVault("lock-pass-67890");692 const entry = await vault.addEntry({693 title: "GitHub",694 username: "june",695 password: "secret123",696 url: "https://github.com",697 });698699 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");700 vault.lock();701 expect(vault.getEntryById(entry.id)).toBeUndefined();702 });703704 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {705 const vault = await import("./vault");706707 await vault.createVault("real-pass-67890");708 vault.lock();709710 expect(await vault.unlock("totally-wrong-password")).toBe(false);711 expect(vault.isUnlocked()).toBe(false);712 });713714 it("isUnlocked tracks the full lock lifecycle", async () => {715 const vault = await import("./vault");716717 expect(vault.isUnlocked()).toBe(false);718 await vault.createVault("unlock-pass-12345");719 expect(vault.isUnlocked()).toBe(true);720 vault.lock();721 expect(vault.isUnlocked()).toBe(false);722 expect(await vault.unlock("unlock-pass-12345")).toBe(true);723 expect(vault.isUnlocked()).toBe(true);724 vault.lock();725 expect(vault.isUnlocked()).toBe(false);726 });727728 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {729 const vault = await import("./vault");730731 await vault.createVault("quota-pass-12345");732733 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;734 setMock.mockImplementation(async (next: Record<string, unknown>) => {735 if ("peach_vault_a" in next || "peach_vault_b" in next) {736 throw new Error("QUOTA_BYTES quota reached");737 }738 Object.assign(storage, next);739 });740741 await expect(742 vault.addEntry({743 title: "GitHub",744 username: "june",745 password: "secret123",746 url: "https://github.com",747 })748 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");749 });750751 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {752 const vault = await import("./vault");753754 await vault.createVault("settings-pass-12345");755756 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;757 setMock.mockImplementation(async (next: Record<string, unknown>) => {758 if ("peach_settings" in next) {759 throw new Error("QUOTA_BYTES quota reached");760 }761 Object.assign(storage, next);762 });763764 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(765 "STORAGE_QUOTA_EXCEEDED"766 );767 });768769 it("card entry round-trips through vault — all fields preserved", async () => {770 const vault = await import("./vault");771772 await vault.createVault("card-test-pass-12345");773 const entry = await vault.addEntry({774 type: "card",775 title: "Personal Visa",776 cardholderName: "Jane Smith",777 cardNumber: "4111111111111111",778 expMonth: "05",779 expYear: "2028",780 cvc: "123",781 cardNickname: "Personal Visa",782 network: "visa",783 last4: "1111",784 });785786 expect(entry.type).toBe("card");787 expect(entry.cardholderName).toBe("Jane Smith");788 expect(entry.cardNumber).toBe("4111111111111111");789 expect(entry.expMonth).toBe("05");790 expect(entry.expYear).toBe("2028");791 expect(entry.cvc).toBe("123");792 expect(entry.network).toBe("visa");793 expect(entry.last4).toBe("1111");794795 vault.lock();796 await vault.unlock("card-test-pass-12345");797 const entries = vault.getEntries();798 expect(entries).toHaveLength(1);799 expect(entries[0].cardNumber).toBe("4111111111111111");800 expect(entries[0].cvc).toBe("123");801 });802803 it("updateEntry — cannot clear card number on card entry", async () => {804 const vault = await import("./vault");805806 await vault.createVault("card-guard-pass-12345");807 const entry = await vault.addEntry({808 type: "card",809 title: "My Card",810 cardholderName: "Jane Smith",811 cardNumber: "4111111111111111",812 expMonth: "05",813 expYear: "2028",814 cvc: "123",815 });816817 await expect(818 vault.updateEntry(entry.id, { cardNumber: "" })819 ).rejects.toThrow("card number is required for card entries");820821 const entries = vault.getEntries();822 expect(entries[0].cardNumber).toBe("4111111111111111");823 });824825 it("declineCardSaveOffer — stores fingerprint in settings", async () => {826 const vault = await import("./vault");827828 await vault.createVault("decline-pass-12345");829 await vault.declineCardSaveOffer("1111", "05", "2028");830831 const settings = vault.getSettings();832 expect(settings.declinedCardFingerprints).toHaveLength(1);833 });834835 it("isCardDeclined — returns true for previously declined card", async () => {836 const vault = await import("./vault");837838 await vault.createVault("declined-check-pass-12345");839 await vault.declineCardSaveOffer("1111", "05", "2028");840841 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);842 });843844 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {845 const vault = await import("./vault");846847 await vault.createVault("declined-diff-pass-12345");848 await vault.declineCardSaveOffer("1111", "05", "2028");849850 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);851 });852853 it("queryDomain matches an entry against its additionalUrls", async () => {854 const vault = await import("./vault");855856 await vault.createVault("multi-domain-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 additionalUrls: ["https://bar.io", "https://baz.org"],864 });865866 867 868 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);869 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);870 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);871 expect(vault.queryDomain("unrelated.com")).toEqual([]);872 });873874 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {875 const vault = await import("./vault");876877 await vault.createVault("dedupe-additional-pass-12345");878 const entry = await vault.addEntry({879 type: "login",880 title: "Foo",881 username: "june",882 password: "secret",883 url: "https://foo.com",884 });885886 await vault.updateEntry(entry.id, {887 additionalUrls: [888 " https://login.foo.com ",889 "https://login.foo.com",890 "",891 " ",892 "https://accounts.foo.com",893 ],894 });895896 const stored = vault.getEntryById(entry.id);897 expect(stored?.additionalUrls).toEqual([898 "https://login.foo.com",899 "https://accounts.foo.com",900 ]);901902 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });903 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();904 });905906 it("preserves email field through lock/unlock cycle", async () => {907 const vault = await import("./vault");908 await vault.createVault("preserve-email-pass-12345");909 await vault.addEntry({910 type: "login",911 title: "GitHub",912 username: "june",913 email: "[email protected]",914 password: "secret",915 url: "https://github.com",916 });917 vault.lock();918919 const ok = await vault.unlock("preserve-email-pass-12345");920 expect(ok).toBe(true);921 const entry = vault.getEntries().find((e) => e.title === "GitHub");922 expect(entry?.email).toBe("[email protected]");923 });924925 it("preserves additionalUrls through lock/unlock cycle", async () => {926 const vault = await import("./vault");927 await vault.createVault("preserve-urls-pass-12345");928 await vault.addEntry({929 type: "login",930 title: "Example",931 username: "june",932 password: "secret",933 url: "https://example.com",934 additionalUrls: [935 "https://beta.example.com",936 "https://staging.example.com",937 ],938 });939 vault.lock();940941 const ok = await vault.unlock("preserve-urls-pass-12345");942 expect(ok).toBe(true);943 const entry = vault.getEntries().find((e) => e.title === "Example");944 expect(entry?.additionalUrls).toEqual([945 "https://beta.example.com",946 "https://staging.example.com",947 ]);948 });949950 it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {951 const vault = await import("./vault");952 await vault.createVault("pwchanged-preserve-pass");953 const entry = await vault.addEntry({954 title: "Aged",955 username: "june",956 password: "secret",957 url: "https://aged.test",958 });959 960 961 const ts = entry.passwordChangedAt;962 expect(typeof ts).toBe("number");963 vault.lock();964965 expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);966 const restored = vault.getEntries().find((e) => e.id === entry.id);967 expect(restored?.passwordChangedAt).toBe(ts);968 });969970 it("normalizeEntry handles entries without passwordChangedAt", async () => {971 const vault = await import("./vault");972 await vault.createVault("pwchanged-absent-pass");973 974 const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });975 vault.lock();976977 expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);978 const restored = vault.getEntries().find((e) => e.id === note.id);979 expect(restored?.passwordChangedAt).toBeUndefined();980 });981982 it("applyMergedRemoteEntries does not advance the vault version", async () => {983 const vault = await import("./vault");984 await vault.createVault("syncver-pass-aaaaa");985 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });986987 const before = await vault.getVaultVersion();988 await new Promise((r) => setTimeout(r, 2));989 await vault.applyMergedRemoteEntries(990 [991 {992 id: "remote-1",993 type: "login",994 title: "Remote",995 username: "r",996 password: "x",997 url: "https://r.test",998 createdAt: Date.now(),999 modifiedAt: Date.now(),1000 },1001 ],1002 {},1003 );10041005 expect(await vault.getVaultVersion()).toBe(before);1006 });10071008 it("addEntry advances the vault version", async () => {1009 const vault = await import("./vault");1010 await vault.createVault("syncver-pass-bbbbb");10111012 const before = await vault.getVaultVersion();1013 await new Promise((r) => setTimeout(r, 2));1014 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10151016 expect(await vault.getVaultVersion()).toBeGreaterThan(before);1017 });10181019 it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1020 const vault = await import("./vault");1021 await vault.createVault("monotonic-pass-aaaaa");1022 const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1023 const before = vault.getEntryById(e1.id)!.modifiedAt;10241025 1026 const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1027 await vault.updateEntry(e1.id, { password: "p2" });1028 const after = vault.getEntryById(e1.id)!.modifiedAt;1029 expect(after).toBeGreaterThan(before);1030 spy.mockRestore();1031 });10321033 it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1034 const vault = await import("./vault");1035 await vault.createVault("monotonic-pass-bbbbb");1036 const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1037 const modAt = vault.getEntryById(e.id)!.modifiedAt;1038 await vault.deleteEntry(e.id);1039 const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1040 expect(tomb.deletedAt!).toBeGreaterThan(modAt);1041 });10421043 it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1044 const vault = await import("./vault");1045 await vault.createVault("rollback-pass-aaaaa");1046 const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10471048 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1049 1050 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1051 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1052 Object.assign(storage, next);1053 });10541055 await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10561057 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1058 1059 expect(vault.getEntryById(e.id)!.password).toBe("original");1060 });10611062 it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1063 const vault = await import("./vault");1064 await vault.createVault("rootkey-pass-aaaaa");1065 const before = vault.getRootKeyHex();1066 expect(before).toMatch(/^[0-9a-f]{64}$/);10671068 expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1069 expect(vault.getRootKeyHex()).toBe(before);10701071 1072 vault.lock();1073 expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1074 expect(vault.getRootKeyHex()).toBe(before);1075 });10761077 it("getRootKeyHex returns null when the vault is locked", async () => {1078 const vault = await import("./vault");1079 await vault.createVault("rootkey-pass-ccccc");1080 vault.lock();1081 expect(vault.getRootKeyHex()).toBeNull();1082 });10831084 it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1085 const vault = await import("./vault");1086 await vault.createVault("biometric-pass-12345");1087 const rootBefore = vault.getRootKeyHex();1088 const keyHex = vault.getDerivedKeyHex();1089 expect(rootBefore).not.toBeNull();1090 vault.lock();1091 1092 const r = await vault.unlockWithKey(keyHex);1093 expect(r.ok).toBe(true);1094 expect(vault.getRootKeyHex()).toBe(rootBefore);1095 });10961097 it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1098 const vault = await import("./vault");1099 await vault.createVault("legacy-pass-12345");1100 const keyHex = vault.getDerivedKeyHex();1101 vault.lock();1102 1103 1104 const slot = storage.peach_vault_a as Record<string, unknown>;1105 delete slot.recovery_salt;1106 delete slot.recovery_wrapped_root_key;1107 delete slot.pw_root_ciphertext;1108 delete slot.pw_root_nonce;1109 delete slot.pw_root_tag;1110 const r = await vault.unlockWithKey(keyHex);1111 expect(r.ok).toBe(true);1112 expect(vault.getRootKeyHex()).toBeNull();1113 });11141115 it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1116 const vault = await import("./vault");1117 await vault.createVault("duress-real-pass-aaaa");1118 await vault.setupDuress("duress-decoy-pass-bbbb");11191120 const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1121 const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11221123 1124 expect(lenA()).toBe(lenB());11251126 1127 1128 await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1129 await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1130 expect(lenA()).toBe(lenB());1131 });11321133 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1134 const vault = await import("./vault");1135 await vault.createVault("rollback-pass-bbbbb");1136 const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11371138 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1139 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1140 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1141 Object.assign(storage, next);1142 });11431144 await expect(vault.deleteEntry(e.id)).rejects.toThrow();11451146 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1147 1148 expect(vault.getEntryById(e.id)).toBeDefined();1149 expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1150 });11511152 describe("password history", () => {1153 it("captures the old password on change, newest first", async () => {1154 const vault = await import("./vault");1155 await vault.createVault("history-pass-12345");1156 const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1157 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11581159 await vault.updateEntry(e.id, { password: "second" });1160 await vault.updateEntry(e.id, { password: "third" });11611162 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1163 expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1164 expect(vault.getEntryById(e.id)?.password).toBe("third");1165 });11661167 it("does not record history on a metadata-only update", async () => {1168 const vault = await import("./vault");1169 await vault.createVault("history-meta-12345");1170 const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11711172 await vault.updateEntry(e.id, { title: "Renamed" });1173 await vault.updateEntry(e.id, { username: "newuser" });11741175 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1176 });11771178 it("records each history item with the age the password carried", async () => {1179 const vault = await import("./vault");1180 await vault.createVault("history-stamp-12345");1181 const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1182 const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1183 expect(firstChangedAt).toBeGreaterThan(0);11841185 await new Promise((resolve) => setTimeout(resolve, 2));1186 await vault.updateEntry(e.id, { password: "v2" });11871188 const hist = vault.getEntryById(e.id)!.passwordHistory!;1189 1190 expect(hist[0].changedAt).toBe(firstChangedAt);1191 });11921193 it("skips capture when re-saving the identical password", async () => {1194 const vault = await import("./vault");1195 await vault.createVault("history-noop-12345");1196 const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11971198 await vault.updateEntry(e.id, { password: "same" });11991200 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1201 });12021203 it("does not capture an empty prior password (clear then set)", async () => {1204 const vault = await import("./vault");1205 await vault.createVault("history-empty-12345");1206 const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12071208 await vault.updateEntry(e.id, { password: "now-has-one" });12091210 1211 expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1212 });12131214 it("restoring an old password re-records the swapped-out current one", async () => {1215 const vault = await import("./vault");1216 await vault.createVault("history-restore-12345");1217 const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1218 await vault.updateEntry(e.id, { password: "new" });12191220 1221 await vault.updateEntry(e.id, { password: "old" });12221223 const hist = vault.getEntryById(e.id)!.passwordHistory!;1224 expect(vault.getEntryById(e.id)?.password).toBe("old");1225 expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1226 });12271228 it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1229 const vault = await import("./vault");1230 await vault.createVault("history-persist-12345");1231 const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1232 await vault.updateEntry(e.id, { password: "p2" });12331234 vault.lock();1235 expect(await vault.unlock("history-persist-12345")).toBe(true);12361237 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1238 expect(hist.map((h) => h.password)).toEqual(["p1"]);1239 });12401241 it("caps history at 50 entries, dropping the oldest", async () => {1242 const vault = await import("./vault");1243 await vault.createVault("history-cap-12345");1244 const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12451246 for (let i = 1; i <= 55; i++) {1247 await vault.updateEntry(e.id, { password: `pw-${i}` });1248 }12491250 const hist = vault.getEntryById(e.id)!.passwordHistory!;1251 expect(hist).toHaveLength(50);1252 1253 expect(hist[0].password).toBe("pw-54");1254 expect(hist[hist.length - 1].password).toBe("pw-5");1255 });1256 });12571258 describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1259 it("drops unknown/attacker keys from a restored entry", async () => {1260 const vault = await import("./vault");1261 await vault.createVault("correct horse battery staple");1262 await vault.restoreEntries(1263 [1264 {1265 id: "e1",1266 type: "login",1267 title: "Bank",1268 username: "june",1269 password: "pw",1270 createdAt: 1,1271 modifiedAt: 2,1272 evil: "payload",1273 isAdmin: true,1274 nested: { junk: 1 },1275 } as unknown as import("../shared/types").VaultEntry,1276 ],1277 "replace",1278 );1279 const restored = vault.getEntries().find((e) => e.id === "e1")!;1280 expect(restored.password).toBe("pw");1281 expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1282 expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1283 expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1284 });12851286 it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1287 const vault = await import("./vault");1288 await vault.createVault("correct horse battery staple");1289 await vault.restoreEntries(1290 [1291 {1292 id: "e2",1293 type: "login",1294 title: "Bank",1295 password: "pw",1296 createdAt: 1,1297 modifiedAt: 2,1298 canary: { webhookUrl: "http://attacker.example.com/x" },1299 } as unknown as import("../shared/types").VaultEntry,1300 ],1301 "replace",1302 );1303 const restored = vault.getEntries().find((e) => e.id === "e2")!;1304 expect(restored).toBeDefined();1305 expect(restored.canary).toBeUndefined();1306 });13071308 it("preserves a well-formed HTTPS canary webhook", async () => {1309 const vault = await import("./vault");1310 await vault.createVault("correct horse battery staple");1311 await vault.restoreEntries(1312 [1313 {1314 id: "e3",1315 type: "login",1316 title: "Bank",1317 password: "pw",1318 createdAt: 1,1319 modifiedAt: 2,1320 canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1321 } as unknown as import("../shared/types").VaultEntry,1322 ],1323 "replace",1324 );1325 const restored = vault.getEntries().find((e) => e.id === "e3")!;1326 expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1327 expect(restored.canary?.label).toBe("watch");1328 });13291330 it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1331 const vault = await import("./vault");1332 await vault.createVault("correct horse battery staple");1333 await vault.restoreEntries(1334 [1335 {1336 id: "e4",1337 type: "login",1338 title: "Has bad passkey",1339 createdAt: 1,1340 modifiedAt: 2,1341 passkey: { rpId: "example.com", publicKeyCose: "x" },1342 } as unknown as import("../shared/types").VaultEntry,1343 ],1344 "replace",1345 );1346 expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1347 });13481349 it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1350 const vault = await import("./vault");1351 await vault.createVault("correct horse battery staple");1352 await vault.restoreEntries(1353 [1354 {1355 id: "e5",1356 type: "passkey",1357 title: "Legacy Passkey",1358 createdAt: 1,1359 modifiedAt: 2,1360 rpId: "example.com",1361 rpName: "Example",1362 credentialId: "cred-123",1363 publicKeyCose: "cose-blob",1364 userHandle: "uh",1365 userName: "june",1366 userDisplayName: "June",1367 algorithm: -7,1368 } as unknown as import("../shared/types").VaultEntry,1369 ],1370 "replace",1371 );1372 const restored = vault.getEntries().find((e) => e.id === "e5")!;1373 expect(restored).toBeDefined();1374 expect(restored.passkey?.credentialId).toBe("cred-123");1375 1376 expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1377 });1378 });13791380 describe("MV-8 rotateSyncIdentity", () => {1381 it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1382 const vault = await import("./vault");1383 await vault.createVault("correct horse battery staple");1384 const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1385 const { upsertPairedDevice, listPairedDevices } = await import(1386 "./paired-devices"1387 );1388 await upsertPairedDevice({1389 deviceId: "d1",1390 deviceName: "Phone",1391 deviceType: "android",1392 longTermPublicKeyHex: "a".repeat(64),1393 signingPublicKeyHex: "b".repeat(64),1394 pairedAt: 1,1395 trustSource: "direct",1396 });13971398 const rootBefore = vault.getRootKeyHex();1399 const res = await vault.rotateSyncIdentity();1400 expect(res.ok).toBe(true);1401 expect(vault.getRootKeyHex()).not.toBe(rootBefore);1402 expect(await listPairedDevices()).toHaveLength(0);1403 1404 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");14051406 vault.lock();1407 expect(await vault.unlock("correct horse battery staple")).toBe(true);1408 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1409 });14101411 it("invalidates the old recovery words and flags recovery-pending", async () => {1412 const vault = await import("./vault");1413 await vault.createVault("correct horse battery staple");1414 const wordsBefore = (await vault.getRecoveryWords()).join(" ");14151416 await vault.rotateSyncIdentity();14171418 const wordsAfter = (await vault.getRecoveryWords()).join(" ");1419 expect(wordsAfter).not.toBe(wordsBefore);1420 const { isRecoveryConfirmPending } = await import("./recovery-pending");1421 expect(await isRecoveryConfirmPending()).toBe(true);1422 });14231424 it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1425 const vault = await import("./vault");1426 await vault.createVault("correct horse battery staple");1427 const { createSyncVaultAdapter } = await import(1428 "./sync-vault-adapter"1429 );1430 const adapter = createSyncVaultAdapter();1431 const vidBefore = await adapter.computeVaultId();14321433 await vault.rotateSyncIdentity();14341435 const vidAfter = await adapter.computeVaultId();1436 expect(vidAfter).not.toBe(vidBefore);1437 });1438 });14391440 describe("LV-1 NFC master password normalization", () => {1441 const CAFE_BASE = "café";1442 const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1443 const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14441445 it("a vault created with the NFD form unlocks with the NFC form", async () => {1446 const vault = await import("./vault");1447 expect(PW_NFD).not.toBe(PW_NFC);1448 await vault.createVault(PW_NFD);1449 vault.lock();1450 expect(await vault.unlock(PW_NFC)).toBe(true);1451 });14521453 it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1454 const vault = await import("./vault");1455 const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1456 await vault.createVault("base-password-12345");14571458 1459 1460 const rawSalt = generateSalt();1461 const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1462 await vault.changePasswordWithNewKey(rawSalt, rawKey);1463 vault.lock();14641465 1466 expect(await vault.unlock(PW_NFD)).toBe(true);14671468 1469 1470 vault.lock();1471 expect(await vault.unlock(PW_NFC)).toBe(true);1472 });1473 });14741475 describe("LV-7 updateSettings proto-pollution filter", () => {1476 it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1477 const vault = await import("./vault");1478 await vault.createVault("correct horse battery staple");14791480 await vault.updateSettings({1481 theme: "dark",1482 ["__proto__"]: { polluted: true },1483 constructor: { polluted: true },1484 } as never);14851486 expect(({} as Record<string, unknown>).polluted).toBeUndefined();1487 expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1488 expect(vault.getSettings().theme).toBe("dark");1489 });1490 });1491});