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("soft deletes entries while keeping deletedAt in encrypted storage", async () => {261 const vault = await import("./vault");262263 await vault.createVault("correct horse battery staple");264 const entry = await vault.addEntry({265 title: "GitHub",266 username: "june",267 password: "secret123",268 url: "github.com",269 });270271 await vault.deleteEntry(entry.id);272273 expect(vault.getEntries()).toEqual([]);274275 const stored = storage.peach_vault_a as StoredVault;276 const salt = Uint8Array.from(277 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))278 );279 const key = await deriveKey("correct horse battery staple", salt);280 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");281 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };282283 expect(parsed.entries).toHaveLength(1);284 expect(parsed.entries[0].id).toBe(entry.id);285 expect(typeof parsed.entries[0].deletedAt).toBe("number");286 });287288 it("accepts imported entries without a username or url", async () => {289 const vault = await import("./vault");290291 await vault.createVault("correct horse battery staple");292 const entry = await vault.addEntry({293 title: "Imported Login",294 username: "",295 password: "secret123",296 url: "",297 notes: "migrated from another manager",298 });299300 expect(entry.username).toBe("");301 expect(entry.url).toBe("");302 expect(vault.getEntries()).toHaveLength(1);303 });304305 it("changePassword happy path — new password unlocks, old is rejected", async () => {306 const vault = await import("./vault");307308 await vault.createVault("old-pass-abc123");309 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });310 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });311312 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);313314 vault.lock();315 expect(await vault.unlock("new-pass-xyz789")).toBe(true);316 expect(vault.getEntries()).toHaveLength(2);317318 vault.lock();319 expect(await vault.unlock("old-pass-abc123")).toBe(false);320 });321322 it("stamps peach_last_modified_at on createVault", async () => {323 const vault = await import("./vault");324 const before = Date.now();325 await new Promise((resolve) => setTimeout(resolve, 1));326327 await vault.createVault("stamp-create-pass");328329 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);330 });331332 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {333 const vault = await import("./vault");334 await vault.createVault("stamp-entry-pass");335336 let previous = Number(storage.peach_last_modified_at);337 await new Promise((resolve) => setTimeout(resolve, 1));338 const entry = await vault.addEntry({339 title: "Stamp",340 username: "",341 password: "p",342 url: "",343 });344 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);345346 previous = Number(storage.peach_last_modified_at);347 await new Promise((resolve) => setTimeout(resolve, 1));348 await vault.updateEntry(entry.id, { title: "Stamped" });349 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);350351 previous = Number(storage.peach_last_modified_at);352 await new Promise((resolve) => setTimeout(resolve, 1));353 await vault.deleteEntry(entry.id);354 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);355 });356357 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {358 const vault = await import("./vault");359 await vault.createVault("stamp-real-pass");360361 let previous = Number(storage.peach_last_modified_at);362 await new Promise((resolve) => setTimeout(resolve, 1));363 await vault.setupDuress("stamp-decoy-pass");364 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);365366 previous = Number(storage.peach_last_modified_at);367 await new Promise((resolve) => setTimeout(resolve, 1));368 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);369 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);370 });371372 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {373 const vault = await import("./vault");374375 await vault.createVault("correct-pass-abcde");376 const slotABefore = JSON.stringify(storage.peach_vault_a);377378 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);379 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);380381 vault.lock();382 expect(await vault.unlock("correct-pass-abcde")).toBe(true);383 });384385 it("changePassword with duress configured leaves slot B byte-identical", async () => {386 const vault = await import("./vault");387388 await vault.createVault("real-pass-abcde");389 await vault.setupDuress("duress-pass-xyzzz");390391 const slotBBefore = JSON.stringify(storage.peach_vault_b);392 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);393 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);394395 396 vault.lock();397 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);398 expect(vault.getEntries()).toEqual([]);399 });400401 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {402 const vault = await import("./vault");403404 await vault.createVault("pass-abcdef");405 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });406 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });407 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });408409 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(410 /refuse to replace with empty entry set/i411 );412 expect(vault.getEntries()).toHaveLength(3);413 });414415 it("changePassword swaps the active key before Slot B is regenerated", async () => {416 const vault = await import("./vault");417418 await vault.createVault("correct-pass-12345");419 const entry = await vault.addEntry({420 title: "GitHub",421 username: "june",422 password: "secret123",423 url: "https://github.com",424 });425426 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;427 const originalSet = setMock.getMockImplementation();428 let injectedDuringSlotBWrite = false;429430 setMock.mockImplementation(async (next: Record<string, unknown>) => {431 Object.assign(storage, next);432433 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {434 injectedDuringSlotBWrite = true;435 await vault.updateEntry(entry.id, { notes: "updated during password change" });436 }437 });438439 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);440441 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {442 Object.assign(storage, next);443 }));444445 vault.lock();446 expect(await vault.unlock("new-pass-67890")).toBe(true);447 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");448449 vault.lock();450 expect(await vault.unlock("correct-pass-12345")).toBe(false);451 });452453 it("restoreEntries replace mode replaces existing entries", async () => {454 const vault = await import("./vault");455456 await vault.createVault("replace-pass-12345");457 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });458 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });459460 const incoming = [461 {462 id: "entry-c",463 type: "login" as const,464 title: "C",465 username: "june",466 password: "three",467 url: "https://c.test",468 createdAt: 10,469 modifiedAt: 10,470 },471 {472 id: "entry-d",473 type: "login" as const,474 title: "D",475 username: "june",476 password: "four",477 url: "https://d.test",478 createdAt: 20,479 modifiedAt: 20,480 },481 ];482483 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({484 added: 2,485 updated: 0,486 skipped: 0,487 });488489 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);490 });491492 it("restoreEntries merge mode lets newer incoming entries win", async () => {493 const vault = await import("./vault");494495 await vault.createVault("merge-pass-12345");496 const existing = await vault.addEntry({497 title: "GitHub",498 username: "june",499 password: "old-secret",500 url: "https://github.com",501 });502503 await expect(504 vault.restoreEntries(505 [506 {507 ...existing,508 password: "new-secret",509 modifiedAt: existing.modifiedAt + 100,510 },511 ],512 "merge"513 )514 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });515516 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");517 });518519 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {520 const vault = await import("./vault");521522 await vault.createVault("merge-pass-67890");523 const existing = await vault.addEntry({524 title: "GitHub",525 username: "june",526 password: "current-secret",527 url: "https://github.com",528 });529530 await expect(531 vault.restoreEntries(532 [533 {534 ...existing,535 password: "stale-secret",536 modifiedAt: existing.modifiedAt - 100,537 },538 ],539 "merge"540 )541 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });542543 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");544 });545546 it("clearVaultEntries without explicit confirmation throws", async () => {547 const vault = await import("./vault");548549 await vault.createVault("clear-pass-12345");550 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });551552 const clearVaultEntries = vault.clearVaultEntries as unknown as (553 confirmation?: unknown554 ) => Promise<void>;555556 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);557 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);558 expect(vault.getEntries()).toHaveLength(1);559 });560561 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {562 const vault = await import("./vault");563564 await vault.createVault("clear-pass-67890");565 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });566 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });567 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });568569 await vault.clearVaultEntries("REPLACE_ALL");570571 expect(vault.getEntries()).toEqual([]);572 expect(vault.getDeletedEntries()).toHaveLength(3);573 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);574 });575576 it("deduplicateEntries throws in decoy mode", async () => {577 const vault = await import("./vault");578579 await vault.createVault("real-pass-12345");580 await vault.addEntry({581 title: "GitHub",582 username: "june",583 password: "secret123",584 url: "https://github.com",585 });586 await vault.setupDuress("duress-pass-12345");587 vault.lock();588589 expect(await vault.unlock("duress-pass-12345")).toBe(true);590 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);591 });592593 it("deduplicateEntries removes older duplicates in real mode", async () => {594 const vault = await import("./vault");595596 await vault.createVault("dedupe-pass-12345");597 await vault.addEntry({598 title: "GitHub",599 username: "june",600 password: "old-secret",601 url: "https://github.com",602 });603 await new Promise<void>((resolve) => setTimeout(resolve, 5));604 const newer = await vault.addEntry({605 title: "GitHub",606 username: "june",607 password: "new-secret",608 url: "https://github.com",609 });610611 expect(await vault.deduplicateEntries()).toBe(1);612 expect(vault.getEntries()).toHaveLength(1);613 expect(vault.getEntries()[0].id).toBe(newer.id);614 expect(vault.getDeletedEntries()).toHaveLength(1);615 });616617 it("updateEntry with an empty password on a login entry clears the password", async () => {618 const vault = await import("./vault");619620 await vault.createVault("update-pass-12345");621 const entry = await vault.addEntry({622 title: "GitHub",623 username: "june",624 password: "secret123",625 url: "https://github.com",626 });627628 await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();629 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();630 });631632 it("updateEntry with an empty password on a note entry is allowed", async () => {633 const vault = await import("./vault");634635 await vault.createVault("note-pass-12345");636 const entry = await vault.addEntry({637 type: "note",638 title: "Secure note",639 notes: "keep me",640 });641642 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();643 expect(vault.getEntryById(entry.id)).toMatchObject({644 type: "note",645 notes: "updated",646 });647 });648649 it("lock zeroes the derived key and resets unlock state", async () => {650 const aes = await import("./aes");651 const zeroKeySpy = vi.spyOn(aes, "zeroKey");652 const vault = await import("./vault");653654 await vault.createVault("lock-pass-12345");655 expect(vault.isUnlocked()).toBe(true);656657 vault.lock();658659 expect(zeroKeySpy).toHaveBeenCalled();660 expect(vault.isUnlocked()).toBe(false);661 zeroKeySpy.mockRestore();662 });663664 it("lock clears in-memory entries", async () => {665 const vault = await import("./vault");666667 await vault.createVault("lock-pass-67890");668 const entry = await vault.addEntry({669 title: "GitHub",670 username: "june",671 password: "secret123",672 url: "https://github.com",673 });674675 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");676 vault.lock();677 expect(vault.getEntryById(entry.id)).toBeUndefined();678 });679680 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {681 const vault = await import("./vault");682683 await vault.createVault("real-pass-67890");684 vault.lock();685686 expect(await vault.unlock("totally-wrong-password")).toBe(false);687 expect(vault.isUnlocked()).toBe(false);688 });689690 it("isUnlocked tracks the full lock lifecycle", async () => {691 const vault = await import("./vault");692693 expect(vault.isUnlocked()).toBe(false);694 await vault.createVault("unlock-pass-12345");695 expect(vault.isUnlocked()).toBe(true);696 vault.lock();697 expect(vault.isUnlocked()).toBe(false);698 expect(await vault.unlock("unlock-pass-12345")).toBe(true);699 expect(vault.isUnlocked()).toBe(true);700 vault.lock();701 expect(vault.isUnlocked()).toBe(false);702 });703704 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {705 const vault = await import("./vault");706707 await vault.createVault("quota-pass-12345");708709 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;710 setMock.mockImplementation(async (next: Record<string, unknown>) => {711 if ("peach_vault_a" in next || "peach_vault_b" in next) {712 throw new Error("QUOTA_BYTES quota reached");713 }714 Object.assign(storage, next);715 });716717 await expect(718 vault.addEntry({719 title: "GitHub",720 username: "june",721 password: "secret123",722 url: "https://github.com",723 })724 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");725 });726727 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {728 const vault = await import("./vault");729730 await vault.createVault("settings-pass-12345");731732 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;733 setMock.mockImplementation(async (next: Record<string, unknown>) => {734 if ("peach_settings" in next) {735 throw new Error("QUOTA_BYTES quota reached");736 }737 Object.assign(storage, next);738 });739740 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(741 "STORAGE_QUOTA_EXCEEDED"742 );743 });744745 it("card entry round-trips through vault — all fields preserved", async () => {746 const vault = await import("./vault");747748 await vault.createVault("card-test-pass-12345");749 const entry = await vault.addEntry({750 type: "card",751 title: "Personal Visa",752 cardholderName: "Jane Smith",753 cardNumber: "4111111111111111",754 expMonth: "05",755 expYear: "2028",756 cvc: "123",757 cardNickname: "Personal Visa",758 network: "visa",759 last4: "1111",760 });761762 expect(entry.type).toBe("card");763 expect(entry.cardholderName).toBe("Jane Smith");764 expect(entry.cardNumber).toBe("4111111111111111");765 expect(entry.expMonth).toBe("05");766 expect(entry.expYear).toBe("2028");767 expect(entry.cvc).toBe("123");768 expect(entry.network).toBe("visa");769 expect(entry.last4).toBe("1111");770771 vault.lock();772 await vault.unlock("card-test-pass-12345");773 const entries = vault.getEntries();774 expect(entries).toHaveLength(1);775 expect(entries[0].cardNumber).toBe("4111111111111111");776 expect(entries[0].cvc).toBe("123");777 });778779 it("updateEntry — cannot clear card number on card entry", async () => {780 const vault = await import("./vault");781782 await vault.createVault("card-guard-pass-12345");783 const entry = await vault.addEntry({784 type: "card",785 title: "My Card",786 cardholderName: "Jane Smith",787 cardNumber: "4111111111111111",788 expMonth: "05",789 expYear: "2028",790 cvc: "123",791 });792793 await expect(794 vault.updateEntry(entry.id, { cardNumber: "" })795 ).rejects.toThrow("card number is required for card entries");796797 const entries = vault.getEntries();798 expect(entries[0].cardNumber).toBe("4111111111111111");799 });800801 it("declineCardSaveOffer — stores fingerprint in settings", async () => {802 const vault = await import("./vault");803804 await vault.createVault("decline-pass-12345");805 await vault.declineCardSaveOffer("1111", "05", "2028");806807 const settings = vault.getSettings();808 expect(settings.declinedCardFingerprints).toHaveLength(1);809 });810811 it("isCardDeclined — returns true for previously declined card", async () => {812 const vault = await import("./vault");813814 await vault.createVault("declined-check-pass-12345");815 await vault.declineCardSaveOffer("1111", "05", "2028");816817 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);818 });819820 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {821 const vault = await import("./vault");822823 await vault.createVault("declined-diff-pass-12345");824 await vault.declineCardSaveOffer("1111", "05", "2028");825826 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);827 });828829 it("queryDomain matches an entry against its additionalUrls", async () => {830 const vault = await import("./vault");831832 await vault.createVault("multi-domain-pass-12345");833 const entry = await vault.addEntry({834 type: "login",835 title: "Foo",836 username: "june",837 password: "secret",838 url: "https://foo.com",839 additionalUrls: ["https://bar.io", "https://baz.org"],840 });841842 843 844 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);845 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);846 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);847 expect(vault.queryDomain("unrelated.com")).toEqual([]);848 });849850 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {851 const vault = await import("./vault");852853 await vault.createVault("dedupe-additional-pass-12345");854 const entry = await vault.addEntry({855 type: "login",856 title: "Foo",857 username: "june",858 password: "secret",859 url: "https://foo.com",860 });861862 await vault.updateEntry(entry.id, {863 additionalUrls: [864 " https://login.foo.com ",865 "https://login.foo.com",866 "",867 " ",868 "https://accounts.foo.com",869 ],870 });871872 const stored = vault.getEntryById(entry.id);873 expect(stored?.additionalUrls).toEqual([874 "https://login.foo.com",875 "https://accounts.foo.com",876 ]);877878 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });879 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();880 });881882 it("preserves email field through lock/unlock cycle", async () => {883 const vault = await import("./vault");884 await vault.createVault("preserve-email-pass-12345");885 await vault.addEntry({886 type: "login",887 title: "GitHub",888 username: "june",889 email: "[email protected]",890 password: "secret",891 url: "https://github.com",892 });893 vault.lock();894895 const ok = await vault.unlock("preserve-email-pass-12345");896 expect(ok).toBe(true);897 const entry = vault.getEntries().find((e) => e.title === "GitHub");898 expect(entry?.email).toBe("[email protected]");899 });900901 it("preserves additionalUrls through lock/unlock cycle", async () => {902 const vault = await import("./vault");903 await vault.createVault("preserve-urls-pass-12345");904 await vault.addEntry({905 type: "login",906 title: "Example",907 username: "june",908 password: "secret",909 url: "https://example.com",910 additionalUrls: [911 "https://beta.example.com",912 "https://staging.example.com",913 ],914 });915 vault.lock();916917 const ok = await vault.unlock("preserve-urls-pass-12345");918 expect(ok).toBe(true);919 const entry = vault.getEntries().find((e) => e.title === "Example");920 expect(entry?.additionalUrls).toEqual([921 "https://beta.example.com",922 "https://staging.example.com",923 ]);924 });925926 it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {927 const vault = await import("./vault");928 await vault.createVault("pwchanged-preserve-pass");929 const entry = await vault.addEntry({930 title: "Aged",931 username: "june",932 password: "secret",933 url: "https://aged.test",934 });935 936 937 const ts = entry.passwordChangedAt;938 expect(typeof ts).toBe("number");939 vault.lock();940941 expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);942 const restored = vault.getEntries().find((e) => e.id === entry.id);943 expect(restored?.passwordChangedAt).toBe(ts);944 });945946 it("normalizeEntry handles entries without passwordChangedAt", async () => {947 const vault = await import("./vault");948 await vault.createVault("pwchanged-absent-pass");949 950 const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });951 vault.lock();952953 expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);954 const restored = vault.getEntries().find((e) => e.id === note.id);955 expect(restored?.passwordChangedAt).toBeUndefined();956 });957958 it("applyMergedRemoteEntries does not advance the vault version", async () => {959 const vault = await import("./vault");960 await vault.createVault("syncver-pass-aaaaa");961 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });962963 const before = await vault.getVaultVersion();964 await new Promise((r) => setTimeout(r, 2));965 await vault.applyMergedRemoteEntries(966 [967 {968 id: "remote-1",969 type: "login",970 title: "Remote",971 username: "r",972 password: "x",973 url: "https://r.test",974 createdAt: Date.now(),975 modifiedAt: Date.now(),976 },977 ],978 {},979 );980981 expect(await vault.getVaultVersion()).toBe(before);982 });983984 it("addEntry advances the vault version", async () => {985 const vault = await import("./vault");986 await vault.createVault("syncver-pass-bbbbb");987988 const before = await vault.getVaultVersion();989 await new Promise((r) => setTimeout(r, 2));990 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });991992 expect(await vault.getVaultVersion()).toBeGreaterThan(before);993 });994995 it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {996 const vault = await import("./vault");997 await vault.createVault("monotonic-pass-aaaaa");998 const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });999 const before = vault.getEntryById(e1.id)!.modifiedAt;10001001 1002 const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1003 await vault.updateEntry(e1.id, { password: "p2" });1004 const after = vault.getEntryById(e1.id)!.modifiedAt;1005 expect(after).toBeGreaterThan(before);1006 spy.mockRestore();1007 });10081009 it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1010 const vault = await import("./vault");1011 await vault.createVault("monotonic-pass-bbbbb");1012 const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1013 const modAt = vault.getEntryById(e.id)!.modifiedAt;1014 await vault.deleteEntry(e.id);1015 const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1016 expect(tomb.deletedAt!).toBeGreaterThan(modAt);1017 });10181019 it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1020 const vault = await import("./vault");1021 await vault.createVault("rollback-pass-aaaaa");1022 const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10231024 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1025 1026 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1027 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1028 Object.assign(storage, next);1029 });10301031 await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10321033 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1034 1035 expect(vault.getEntryById(e.id)!.password).toBe("original");1036 });10371038 it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1039 const vault = await import("./vault");1040 await vault.createVault("rootkey-pass-aaaaa");1041 const before = vault.getRootKeyHex();1042 expect(before).toMatch(/^[0-9a-f]{64}$/);10431044 expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1045 expect(vault.getRootKeyHex()).toBe(before);10461047 1048 vault.lock();1049 expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1050 expect(vault.getRootKeyHex()).toBe(before);1051 });10521053 it("getRootKeyHex returns null when the vault is locked", async () => {1054 const vault = await import("./vault");1055 await vault.createVault("rootkey-pass-ccccc");1056 vault.lock();1057 expect(vault.getRootKeyHex()).toBeNull();1058 });10591060 it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1061 const vault = await import("./vault");1062 await vault.createVault("biometric-pass-12345");1063 const rootBefore = vault.getRootKeyHex();1064 const keyHex = vault.getDerivedKeyHex();1065 expect(rootBefore).not.toBeNull();1066 vault.lock();1067 1068 const r = await vault.unlockWithKey(keyHex);1069 expect(r.ok).toBe(true);1070 expect(vault.getRootKeyHex()).toBe(rootBefore);1071 });10721073 it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1074 const vault = await import("./vault");1075 await vault.createVault("duress-real-pass-aaaa");1076 await vault.setupDuress("duress-decoy-pass-bbbb");10771078 const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1079 const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;10801081 1082 expect(lenA()).toBe(lenB());10831084 1085 1086 await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1087 await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1088 expect(lenA()).toBe(lenB());1089 });10901091 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1092 const vault = await import("./vault");1093 await vault.createVault("rollback-pass-bbbbb");1094 const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });10951096 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1097 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1098 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1099 Object.assign(storage, next);1100 });11011102 await expect(vault.deleteEntry(e.id)).rejects.toThrow();11031104 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1105 1106 expect(vault.getEntryById(e.id)).toBeDefined();1107 expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1108 });11091110 describe("password history", () => {1111 it("captures the old password on change, newest first", async () => {1112 const vault = await import("./vault");1113 await vault.createVault("history-pass-12345");1114 const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1115 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11161117 await vault.updateEntry(e.id, { password: "second" });1118 await vault.updateEntry(e.id, { password: "third" });11191120 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1121 expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1122 expect(vault.getEntryById(e.id)?.password).toBe("third");1123 });11241125 it("does not record history on a metadata-only update", async () => {1126 const vault = await import("./vault");1127 await vault.createVault("history-meta-12345");1128 const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11291130 await vault.updateEntry(e.id, { title: "Renamed" });1131 await vault.updateEntry(e.id, { username: "newuser" });11321133 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1134 });11351136 it("records each history item with the age the password carried", async () => {1137 const vault = await import("./vault");1138 await vault.createVault("history-stamp-12345");1139 const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1140 const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1141 expect(firstChangedAt).toBeGreaterThan(0);11421143 await new Promise((resolve) => setTimeout(resolve, 2));1144 await vault.updateEntry(e.id, { password: "v2" });11451146 const hist = vault.getEntryById(e.id)!.passwordHistory!;1147 1148 expect(hist[0].changedAt).toBe(firstChangedAt);1149 });11501151 it("skips capture when re-saving the identical password", async () => {1152 const vault = await import("./vault");1153 await vault.createVault("history-noop-12345");1154 const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11551156 await vault.updateEntry(e.id, { password: "same" });11571158 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1159 });11601161 it("does not capture an empty prior password (clear then set)", async () => {1162 const vault = await import("./vault");1163 await vault.createVault("history-empty-12345");1164 const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });11651166 await vault.updateEntry(e.id, { password: "now-has-one" });11671168 1169 expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1170 });11711172 it("restoring an old password re-records the swapped-out current one", async () => {1173 const vault = await import("./vault");1174 await vault.createVault("history-restore-12345");1175 const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1176 await vault.updateEntry(e.id, { password: "new" });11771178 1179 await vault.updateEntry(e.id, { password: "old" });11801181 const hist = vault.getEntryById(e.id)!.passwordHistory!;1182 expect(vault.getEntryById(e.id)?.password).toBe("old");1183 expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1184 });11851186 it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1187 const vault = await import("./vault");1188 await vault.createVault("history-persist-12345");1189 const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1190 await vault.updateEntry(e.id, { password: "p2" });11911192 vault.lock();1193 expect(await vault.unlock("history-persist-12345")).toBe(true);11941195 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1196 expect(hist.map((h) => h.password)).toEqual(["p1"]);1197 });11981199 it("caps history at 50 entries, dropping the oldest", async () => {1200 const vault = await import("./vault");1201 await vault.createVault("history-cap-12345");1202 const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12031204 for (let i = 1; i <= 55; i++) {1205 await vault.updateEntry(e.id, { password: `pw-${i}` });1206 }12071208 const hist = vault.getEntryById(e.id)!.passwordHistory!;1209 expect(hist).toHaveLength(50);1210 1211 expect(hist[0].password).toBe("pw-54");1212 expect(hist[hist.length - 1].password).toBe("pw-5");1213 });1214 });12151216 describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1217 it("drops unknown/attacker keys from a restored entry", async () => {1218 const vault = await import("./vault");1219 await vault.createVault("correct horse battery staple");1220 await vault.restoreEntries(1221 [1222 {1223 id: "e1",1224 type: "login",1225 title: "Bank",1226 username: "june",1227 password: "pw",1228 createdAt: 1,1229 modifiedAt: 2,1230 evil: "payload",1231 isAdmin: true,1232 nested: { junk: 1 },1233 } as unknown as import("../shared/types").VaultEntry,1234 ],1235 "replace",1236 );1237 const restored = vault.getEntries().find((e) => e.id === "e1")!;1238 expect(restored.password).toBe("pw");1239 expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1240 expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1241 expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1242 });12431244 it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1245 const vault = await import("./vault");1246 await vault.createVault("correct horse battery staple");1247 await vault.restoreEntries(1248 [1249 {1250 id: "e2",1251 type: "login",1252 title: "Bank",1253 password: "pw",1254 createdAt: 1,1255 modifiedAt: 2,1256 canary: { webhookUrl: "http://attacker.example.com/x" },1257 } as unknown as import("../shared/types").VaultEntry,1258 ],1259 "replace",1260 );1261 const restored = vault.getEntries().find((e) => e.id === "e2")!;1262 expect(restored).toBeDefined();1263 expect(restored.canary).toBeUndefined();1264 });12651266 it("preserves a well-formed HTTPS canary webhook", async () => {1267 const vault = await import("./vault");1268 await vault.createVault("correct horse battery staple");1269 await vault.restoreEntries(1270 [1271 {1272 id: "e3",1273 type: "login",1274 title: "Bank",1275 password: "pw",1276 createdAt: 1,1277 modifiedAt: 2,1278 canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1279 } as unknown as import("../shared/types").VaultEntry,1280 ],1281 "replace",1282 );1283 const restored = vault.getEntries().find((e) => e.id === "e3")!;1284 expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1285 expect(restored.canary?.label).toBe("watch");1286 });12871288 it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1289 const vault = await import("./vault");1290 await vault.createVault("correct horse battery staple");1291 await vault.restoreEntries(1292 [1293 {1294 id: "e4",1295 type: "login",1296 title: "Has bad passkey",1297 createdAt: 1,1298 modifiedAt: 2,1299 passkey: { rpId: "example.com", publicKeyCose: "x" },1300 } as unknown as import("../shared/types").VaultEntry,1301 ],1302 "replace",1303 );1304 expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1305 });13061307 });13081309 describe("MV-8 rotateSyncIdentity", () => {1310 it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1311 const vault = await import("./vault");1312 await vault.createVault("correct horse battery staple");1313 const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1314 const { upsertPairedDevice, listPairedDevices } = await import(1315 "./paired-devices"1316 );1317 await upsertPairedDevice({1318 deviceId: "d1",1319 deviceName: "Phone",1320 deviceType: "android",1321 longTermPublicKeyHex: "a".repeat(64),1322 signingPublicKeyHex: "b".repeat(64),1323 pairedAt: 1,1324 trustSource: "direct",1325 });13261327 const rootBefore = vault.getRootKeyHex();1328 const res = await vault.rotateSyncIdentity();1329 expect(res.ok).toBe(true);1330 expect(vault.getRootKeyHex()).not.toBe(rootBefore);1331 expect(await listPairedDevices()).toHaveLength(0);1332 1333 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13341335 vault.lock();1336 expect(await vault.unlock("correct horse battery staple")).toBe(true);1337 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1338 });13391340 it("invalidates the old recovery words and flags recovery-pending", async () => {1341 const vault = await import("./vault");1342 await vault.createVault("correct horse battery staple");1343 const wordsBefore = (await vault.getRecoveryWords()).join(" ");13441345 await vault.rotateSyncIdentity();13461347 const wordsAfter = (await vault.getRecoveryWords()).join(" ");1348 expect(wordsAfter).not.toBe(wordsBefore);1349 const { isRecoveryConfirmPending } = await import("./recovery-pending");1350 expect(await isRecoveryConfirmPending()).toBe(true);1351 });13521353 it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1354 const vault = await import("./vault");1355 await vault.createVault("correct horse battery staple");1356 const { createSyncVaultAdapter } = await import(1357 "./sync-vault-adapter"1358 );1359 const adapter = createSyncVaultAdapter();1360 const vidBefore = await adapter.computeVaultId();13611362 await vault.rotateSyncIdentity();13631364 const vidAfter = await adapter.computeVaultId();1365 expect(vidAfter).not.toBe(vidBefore);1366 });1367 });13681369 describe("LV-1 NFC master password normalization", () => {1370 const CAFE_BASE = "café";1371 const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1372 const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;13731374 it("a vault created with the NFD form unlocks with the NFC form", async () => {1375 const vault = await import("./vault");1376 expect(PW_NFD).not.toBe(PW_NFC);1377 await vault.createVault(PW_NFD);1378 vault.lock();1379 expect(await vault.unlock(PW_NFC)).toBe(true);1380 });13811382 });13831384 describe("LV-7 updateSettings proto-pollution filter", () => {1385 it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1386 const vault = await import("./vault");1387 await vault.createVault("correct horse battery staple");13881389 await vault.updateSettings({1390 theme: "dark",1391 ["__proto__"]: { polluted: true },1392 constructor: { polluted: true },1393 } as never);13941395 expect(({} as Record<string, unknown>).polluted).toBeUndefined();1396 expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1397 expect(vault.getSettings().theme).toBe("dark");1398 });1399 });1400});