import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import { generateKeyPairFromSeed } from "@stablelib/x25519"; // PCH-01-004: deviceId must live in chrome.storage.local (device-local), never // in chrome.storage.sync (which roams across profiles and collides identities). // // The identity credentials are sealed under a random identity key which itself // is wrapped under the vault root, the vault-unlock key, and — on a fresh // install being adopted — a key derived from the local password. // // Each case re-imports the module after `vi.resetModules()` so the loader's // hydration cache starts empty; that module-loading boundary is the reason the // imports below are dynamic rather than static. type StorageShape = Record; const IDENTITY_KEY = "peach_local_identity_v4"; const LEGACY_IDENTITY_KEY = "peach_local_identity_v2"; const LEGACY_INFO = "peach-local-identity-v2"; const DEVICE_ID_KEY = "peach_device_id"; let local: StorageShape; let sync: StorageShape; let session: StorageShape; let localSet: Mock; let syncSet: Mock; let sessionSet: Mock; function installChromeStorageMock(): void { const makeArea = (store: StorageShape, set: Mock) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((r, k) => { r[k] = store[k]; return r; }, {}); }), set, remove: vi.fn(async (key: string | string[]) => { for (const k of Array.isArray(key) ? key : [key]) delete store[k]; }), }); local = {}; sync = {}; session = {}; localSet = vi.fn(async (next: Record) => { Object.assign(local, next); }); syncSet = vi.fn(async (next: Record) => { Object.assign(sync, next); }); sessionSet = vi.fn(async (next: Record) => { Object.assign(session, next); }); (globalThis as { chrome?: typeof chrome }).chrome = { storage: { local: makeArea(local, localSet), sync: makeArea(sync, syncSet), session: makeArea(session, sessionSet), }, } as unknown as typeof chrome; } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } /** Seal an arbitrary payload into the pre-envelope identity slot, bypassing the loader. */ async function writeLegacyIdentity(payload: unknown): Promise { const vault = await import("./vault"); const aes = await import("./aes"); const rootHex = vault.getRootKeyHex(); if (!rootHex) throw new Error("unlocked vault required"); const root = hexToBytes(rootHex); try { local[LEGACY_IDENTITY_KEY] = { v: 3, ...(await aes.encrypt(root, JSON.stringify(payload), LEGACY_INFO)), }; } finally { root.fill(0); } } interface StoredEnvelope { v: number; blob: { nonce: string; ciphertext: string; tag: string }; ik: { root?: unknown; local?: unknown; pending?: unknown; pendingSalt?: string }; } function storedEnvelope(): StoredEnvelope { return local[IDENTITY_KEY] as StoredEnvelope; } /** The private/public pair the loader currently holds for this device. */ async function currentKeys(): Promise<{ deviceId: string; longTermPublicKeyHex: string | undefined; signingPublicKeyHex: string | undefined; derivedPublicHex: string; }> { const { getLocalPairingIdentity } = await import("./device-identity"); const { identity, privateKey } = await getLocalPairingIdentity(); return { deviceId: identity.deviceId, longTermPublicKeyHex: identity.longTermPublicKeyHex, signingPublicKeyHex: identity.signingPublicKeyHex, derivedPublicHex: Array.from(generateKeyPairFromSeed(privateKey).publicKey, (b) => b.toString(16).padStart(2, "0"), ).join(""), }; } beforeEach(() => { vi.resetModules(); installChromeStorageMock(); }); describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => { it("fresh_install_writes_id_to_local_only", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const id = await getOrCreateDeviceId(); expect(typeof id).toBe("string"); expect(id.length).toBeGreaterThan(0); expect(local[DEVICE_ID_KEY]).toBeUndefined(); expect(local[IDENTITY_KEY]).toBeDefined(); expect(syncSet).not.toHaveBeenCalled(); }); it("returns_a_stable_id_across_calls", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const first = await getOrCreateDeviceId(); const second = await getOrCreateDeviceId(); expect(second).toBe(first); }); it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity"); const original = await getOrCreateDeviceId(); const fresh = await rotateDeviceId(); expect(fresh).not.toBe(original); expect(local[IDENTITY_KEY]).toBeDefined(); expect(await getOrCreateDeviceId()).toBe(fresh); expect(syncSet).not.toHaveBeenCalled(); }); }); describe("device-identity frozen schema (campaign task 2.1)", () => { it("persists every frozen field with public values matching the private material", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const deviceId = await getOrCreateDeviceId(); const keys = await currentKeys(); expect(keys.deviceId).toBe(deviceId); // The stored public half must be the one the private half derives, not an // independent value that merely looks well-formed. expect(keys.longTermPublicKeyHex).toBe(keys.derivedPublicHex); expect(keys.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/); expect(keys.signingPublicKeyHex).toMatch(/^[0-9a-f]{64}$/); }); it("seals the private material instead of storing it in the clear", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity"); await getOrCreateDeviceId(); const { privateKey } = await getLocalPairingIdentity(); const privateHex = Array.from(privateKey, (b) => b.toString(16).padStart(2, "0")).join(""); const envelope = storedEnvelope(); expect(envelope.v).toBe(4); expect(envelope.ik.root).toBeDefined(); expect(envelope.ik.local).toBeDefined(); expect(envelope.ik.pending).toBeUndefined(); expect(JSON.stringify(envelope)).not.toContain(privateHex); }); it("completes a legacy random record in place instead of regenerating it", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeLegacyIdentity({ v: 2, deviceId: "legacy-device-id", x25519PrivateKeyHex: "11".repeat(32), ed25519SeedHex: "22".repeat(32), }); const { getOrCreateDeviceId } = await import("./device-identity"); expect(await getOrCreateDeviceId()).toBe("legacy-device-id"); // Same identity material, now in the sealed envelope; the root-readable // legacy copy is gone. expect(storedEnvelope().v).toBe(4); expect(local[LEGACY_IDENTITY_KEY]).toBeUndefined(); const keys = await currentKeys(); expect(keys.deviceId).toBe("legacy-device-id"); expect(keys.longTermPublicKeyHex).toBe( Array.from(generateKeyPairFromSeed(hexToBytes("11".repeat(32))).publicKey, (b) => b.toString(16).padStart(2, "0"), ).join(""), ); }); it("rejects a frozen record whose stored public key does not match its private key", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeLegacyIdentity({ v: 3, deviceId: "tampered", x25519PrivateKeyHex: "11".repeat(32), x25519PublicKeyHex: "33".repeat(32), ed25519SeedHex: "22".repeat(32), ed25519PublicKeyHex: "44".repeat(32), createdAt: Date.now(), }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); // A rejected record must not be replaced by a freshly minted identity. expect(local[IDENTITY_KEY]).toBeUndefined(); }); it("rejects a frozen record that is missing fields rather than defaulting them", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeLegacyIdentity({ v: 3, deviceId: "missing-fields", x25519PrivateKeyHex: "11".repeat(32), ed25519SeedHex: "22".repeat(32), }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); expect(local[IDENTITY_KEY]).toBeUndefined(); }); it("rejects a partial legacy record instead of minting a replacement", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeLegacyIdentity({ v: 2, deviceId: "partial", x25519PrivateKeyHex: "11".repeat(32), }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); expect(local[IDENTITY_KEY]).toBeUndefined(); }); }); describe("device-identity pre-vault wallet (adoption v2 prerequisite)", () => { it("mints and reads a durable identity with no vault present", async () => { const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity"); expect((await import("./vault")).isUnlocked()).toBe(false); await preparePreVaultIdentity("local-password"); const first = await readPreVaultIdentity("local-password"); expect(first.deviceId.length).toBeGreaterThan(0); expect(first.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/); // Only the password-derived wrap exists before the vault does. const envelope = storedEnvelope(); expect(envelope.ik.pending).toBeDefined(); expect(envelope.ik.root).toBeUndefined(); expect(envelope.ik.local).toBeUndefined(); }); it("survives a browser restart that drops the session cache", async () => { const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity"); await preparePreVaultIdentity("local-password"); const before = await readPreVaultIdentity("local-password"); // A browser restart clears chrome.storage.session and the module state. for (const key of Object.keys(session)) delete session[key]; vi.resetModules(); const restarted = await import("./device-identity"); const after = await restarted.readPreVaultIdentity("local-password"); expect(after.deviceId).toBe(before.deviceId); expect(after.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex); expect(after.signingPublicKeyHex).toBe(before.signingPublicKeyHex); }); it("refuses the wrong password rather than minting a replacement", async () => { const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity"); await preparePreVaultIdentity("local-password"); // Drop the unlocked-session copy so the password-derived wrap is the only // way in; otherwise the cache answers without consulting it. for (const key of Object.keys(session)) delete session[key]; await expect(readPreVaultIdentity("not-the-password")).rejects.toThrow( "identity_migration_required", ); expect(storedEnvelope().ik.pending).toBeDefined(); }); it("promotes the pre-vault identity to the vault wraps once the vault exists", async () => { const { preparePreVaultIdentity, readPreVaultIdentity, getOrCreateDeviceId } = await import( "./device-identity" ); await preparePreVaultIdentity("local-password"); const before = await readPreVaultIdentity("local-password"); const vault = await import("./vault"); await vault.createVault("local-password"); await readPreVaultIdentity("local-password"); const envelope = storedEnvelope(); expect(envelope.ik.pending).toBeUndefined(); expect(envelope.ik.root).toBeDefined(); expect(envelope.ik.local).toBeDefined(); // Same identity, now readable from the vault alone. const after = await getOrCreateDeviceId(); expect(after).toBe(before.deviceId); const keys = await currentKeys(); expect(keys.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex); expect(keys.signingPublicKeyHex).toBe(before.signingPublicKeyHex); }); it("refuses to regenerate the identity when no wrapping key is available", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const real = await getOrCreateDeviceId(); // Losing every wrap (no root, no vault-unlock key, no password) must fail // closed rather than mint a new device identity. delete storedEnvelope().ik.root; delete storedEnvelope().ik.local; for (const key of Object.keys(session)) delete session[key]; await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); expect(local[IDENTITY_KEY]).toBeDefined(); expect(real.length).toBeGreaterThan(0); }); }); describe("device-identity lifecycle (key rotation and duress)", () => { it("survives a password change followed by a sync-identity rotation", async () => { const vault = await import("./vault"); await vault.createVault("first-password"); const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity"); const before = await getOrCreateDeviceId(); const beforeKeys = await getLocalPairingIdentity(); expect(await vault.changePassword("first-password", "second-password")).toBe(true); expect((await vault.rotateSyncIdentity()).ok).toBe(true); // Each swap kills the wrap encrypted under the outgoing key. Force a real // envelope open so a missing re-wrap cannot hide behind the session cache. for (const key of Object.keys(session)) delete session[key]; expect(await getOrCreateDeviceId()).toBe(before); const afterKeys = await getLocalPairingIdentity(); expect(afterKeys.identity.longTermPublicKeyHex).toBe(beforeKeys.identity.longTermPublicKeyHex); expect(afterKeys.identity.signingPublicKeyHex).toBe(beforeKeys.identity.signingPublicKeyHex); }); it("does not serve the cached identity inside a duress session", async () => { const vault = await import("./vault"); await vault.createVault("real-password"); const { getOrCreateDeviceId, buildLocalIdentity } = await import("./device-identity"); const real = await getOrCreateDeviceId(); await vault.setupDuress("duress-password"); // Switching an unlocked real session into duress mode must not leave the // real identity reachable through the session cache. expect(await vault.unlock("duress-password")).toBe(true); expect(vault.isDecoyMode()).toBe(true); await expect(buildLocalIdentity()).rejects.toThrow("identity_migration_required"); expect(real.length).toBeGreaterThan(0); }); it("refuses to mint a replacement when the record is missing but a vault exists", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); await getOrCreateDeviceId(); delete local[IDENTITY_KEY]; for (const key of Object.keys(session)) delete session[key]; await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); expect(local[IDENTITY_KEY]).toBeUndefined(); }); });