1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { Mock } from "vitest";34567891011type StorageShape = Record<string, unknown>;12const DEVICE_ID_KEY = "peach_device_id";13const IDENTITY_KEY = "peach_local_identity_v2";14const IDENTITY_INFO = "peach-local-identity-v2";1516let local: StorageShape;17let sync: StorageShape;18let localSet: Mock;19let syncSet: Mock;2021function installChromeStorageMock(): void {22 const makeArea = (store: StorageShape, set: Mock) => ({23 get: vi.fn(async (key?: string | string[]) => {24 if (!key) return store;25 if (typeof key === "string") return { [key]: store[key] };26 return key.reduce<Record<string, unknown>>((r, k) => {27 r[k] = store[k];28 return r;29 }, {});30 }),31 set,32 remove: vi.fn(async () => undefined),33 });34 local = {};35 sync = {};36 localSet = vi.fn(async (next: Record<string, unknown>) => {37 Object.assign(local, next);38 });39 syncSet = vi.fn(async (next: Record<string, unknown>) => {40 Object.assign(sync, next);41 });42 (globalThis as { chrome?: typeof chrome }).chrome = {43 storage: {44 local: makeArea(local, localSet),45 sync: makeArea(sync, syncSet),46 },47 } as unknown as typeof chrome;48}495051async function writeRawIdentity(payload: unknown, envelopeVersion = 3): Promise<void> {52 const vault = await import("./vault");53 const rootHex = vault.getRootKeyHex();54 if (!rootHex) throw new Error("unlocked vault required");55 const root = hexToBytes(rootHex);56 try {57 const aes = await import("./aes");58 const sealed = await aes.encrypt(root, JSON.stringify(payload), IDENTITY_INFO);59 local[IDENTITY_KEY] = { v: envelopeVersion, ...sealed };60 } finally {61 root.fill(0);62 }63}646566async function readStoredIdentity(): Promise<Record<string, unknown>> {67 const vault = await import("./vault");68 const rootHex = vault.getRootKeyHex();69 if (!rootHex) throw new Error("unlocked vault required");70 const row = local[IDENTITY_KEY] as Record<string, string>;71 const root = hexToBytes(rootHex);72 try {73 const aes = await import("./aes");74 return JSON.parse(await aes.decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO));75 } finally {76 root.fill(0);77 }78}7980function hexToBytes(hex: string): Uint8Array {81 const out = new Uint8Array(hex.length / 2);82 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);83 return out;84}8586beforeEach(() => {87 vi.resetModules();88 installChromeStorageMock();89});9091describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => {92 it("fresh_install_writes_id_to_local_only", async () => {93 const vault = await import("./vault");94 await vault.createVault("identity-test-password");95 const { getOrCreateDeviceId } = await import("./device-identity");9697 const id = await getOrCreateDeviceId();9899 expect(typeof id).toBe("string");100 expect(id.length).toBeGreaterThan(0);101 expect(local[DEVICE_ID_KEY]).toBeUndefined();102 expect(local[IDENTITY_KEY]).toBeDefined();103 expect(syncSet).not.toHaveBeenCalled();104 });105106 it("returns_a_stable_id_across_calls", async () => {107 const vault = await import("./vault");108 await vault.createVault("identity-test-password");109 const { getOrCreateDeviceId } = await import("./device-identity");110111 const first = await getOrCreateDeviceId();112 const second = await getOrCreateDeviceId();113114 expect(second).toBe(first);115 });116117 it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => {118 const vault = await import("./vault");119 await vault.createVault("identity-test-password");120 const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity");121122 const original = await getOrCreateDeviceId();123 const fresh = await rotateDeviceId();124125 expect(fresh).not.toBe(original);126 expect(local[IDENTITY_KEY]).toBeDefined();127 expect(await getOrCreateDeviceId()).toBe(fresh);128 expect(syncSet).not.toHaveBeenCalled();129 });130});131132describe("device-identity frozen schema (campaign task 2.1)", () => {133 it("persists every frozen field with public values matching the private material", async () => {134 const vault = await import("./vault");135 await vault.createVault("identity-test-password");136 const { getOrCreateDeviceId } = await import("./device-identity");137138 const deviceId = await getOrCreateDeviceId();139 const stored = await readStoredIdentity();140141 expect(stored.v).toBe(3);142 expect(stored.deviceId).toBe(deviceId);143 expect(stored.x25519PrivateKeyHex).toMatch(/^[0-9a-f]{64}$/);144 expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);145 expect(stored.ed25519SeedHex).toMatch(/^[0-9a-f]{64}$/);146 expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);147 expect(stored.createdAt as number).toBeGreaterThan(0);148 });149150 it("completes a legacy random record in place instead of regenerating it", async () => {151 const vault = await import("./vault");152 await vault.createVault("identity-test-password");153 const legacy = {154 v: 2,155 deviceId: "legacy-device-id",156 x25519PrivateKeyHex: "11".repeat(32),157 ed25519SeedHex: "22".repeat(32),158 };159 await writeRawIdentity(legacy, 2);160 const { getOrCreateDeviceId } = await import("./device-identity");161162 expect(await getOrCreateDeviceId()).toBe("legacy-device-id");163164 const stored = await readStoredIdentity();165 expect(stored.v).toBe(3);166 expect(stored.deviceId).toBe("legacy-device-id");167 168 expect(stored.x25519PrivateKeyHex).toBe("11".repeat(32));169 expect(stored.ed25519SeedHex).toBe("22".repeat(32));170 expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);171 expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/);172 });173174 it("rejects a frozen record whose stored public key does not match its private key", async () => {175 const vault = await import("./vault");176 await vault.createVault("identity-test-password");177 await writeRawIdentity({178 v: 3,179 deviceId: "tampered",180 x25519PrivateKeyHex: "11".repeat(32),181 x25519PublicKeyHex: "33".repeat(32),182 ed25519SeedHex: "22".repeat(32),183 ed25519PublicKeyHex: "44".repeat(32),184 createdAt: 1_700_000_000_000,185 });186 const { getOrCreateDeviceId } = await import("./device-identity");187188 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");189 });190191 it("rejects a frozen record that is missing fields rather than defaulting them", async () => {192 const vault = await import("./vault");193 await vault.createVault("identity-test-password");194 await writeRawIdentity({195 v: 3,196 deviceId: "partial",197 x25519PrivateKeyHex: "11".repeat(32),198 x25519PublicKeyHex: "33".repeat(32),199 ed25519SeedHex: "22".repeat(32),200 createdAt: 1_700_000_000_000,201 });202 const { getOrCreateDeviceId } = await import("./device-identity");203204 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");205 });206207 it("rejects a partial legacy record instead of minting a replacement", async () => {208 const vault = await import("./vault");209 await vault.createVault("identity-test-password");210 await writeRawIdentity({211 v: 2,212 deviceId: "legacy-device-id",213 ed25519SeedHex: "22".repeat(32),214 });215 const { getOrCreateDeviceId } = await import("./device-identity");216217 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");218 });219});