/** * sync-identity-encryption.test.ts — M12/SEC-003 * * getSyncIdentity must encrypt the X25519 private key under the vault's * derived key before persisting, must require unlock to read, and must * migrate v0 (unencrypted) records to v1 (encrypted) on first read. */ import { beforeEach, describe, expect, it, vi } from "vitest"; describe("sync identity encryption (M12/SEC-003)", () => { let storage: Record; beforeEach(() => { vi.resetModules(); vi.useRealTimers(); storage = {}; const chromeMock = { runtime: { id: "peach-extension", sendMessage: vi.fn() }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete storage[k]; }), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; }); it("throws vault_locked when called before unlock", async () => { const vault = await import("./vault"); await expect(vault.getSyncIdentity()).rejects.toThrow(/vault_locked/); }); it("persisted record contains no plaintext private key field after generation", async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); const { privateKeyBytes } = await vault.getSyncIdentity(); const stored = JSON.parse(storage.peach_sync_identity as string) as Record; expect(stored.priv).toBeUndefined(); expect(typeof stored.enc_priv).toBe("string"); expect(typeof stored.enc_nonce).toBe("string"); expect(typeof stored.enc_tag).toBe("string"); expect(typeof stored.pub).toBe("string"); expect(privateKeyBytes.length).toBe(32); }); it("subsequent reads return the same private key bytes", async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); const first = await vault.getSyncIdentity(); const second = await vault.getSyncIdentity(); expect(Array.from(second.privateKeyBytes)).toEqual(Array.from(first.privateKeyBytes)); expect(second.publicKeyHex).toBe(first.publicKeyHex); }); it("migrates v0 (unencrypted priv/pub) record to v1 encrypted form", async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); // Overwrite with a v0-shaped record. const fakePriv = new Uint8Array(32).fill(7); const fakePub = new Uint8Array(32).fill(8); const b64 = (b: Uint8Array) => { let s = ""; for (const v of b) s += String.fromCharCode(v); return btoa(s); }; storage.peach_sync_identity = JSON.stringify({ priv: b64(fakePriv), pub: b64(fakePub), }); const result = await vault.getSyncIdentity(); expect(Array.from(result.privateKeyBytes)).toEqual(Array.from(fakePriv)); // Storage was upgraded. const stored = JSON.parse(storage.peach_sync_identity as string) as Record; expect(stored.priv).toBeUndefined(); expect(typeof stored.enc_priv).toBe("string"); }); });