1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { Mock } from "vitest";3import { generateKeyPairFromSeed } from "@stablelib/x25519";45678910111213141516type StorageShape = Record<string, unknown>;17const IDENTITY_KEY = "peach_local_identity_v4";18const LEGACY_IDENTITY_KEY = "peach_local_identity_v2";19const LEGACY_INFO = "peach-local-identity-v2";20const DEVICE_ID_KEY = "peach_device_id";2122let local: StorageShape;23let sync: StorageShape;24let session: StorageShape;25let localSet: Mock;26let syncSet: Mock;27let sessionSet: Mock;2829function installChromeStorageMock(): void {30 const makeArea = (store: StorageShape, set: Mock) => ({31 get: vi.fn(async (key?: string | string[]) => {32 if (!key) return store;33 if (typeof key === "string") return { [key]: store[key] };34 return key.reduce<Record<string, unknown>>((r, k) => {35 r[k] = store[k];36 return r;37 }, {});38 }),39 set,40 remove: vi.fn(async (key: string | string[]) => {41 for (const k of Array.isArray(key) ? key : [key]) delete store[k];42 }),43 });44 local = {};45 sync = {};46 session = {};47 localSet = vi.fn(async (next: Record<string, unknown>) => {48 Object.assign(local, next);49 });50 syncSet = vi.fn(async (next: Record<string, unknown>) => {51 Object.assign(sync, next);52 });53 sessionSet = vi.fn(async (next: Record<string, unknown>) => {54 Object.assign(session, next);55 });56 (globalThis as { chrome?: typeof chrome }).chrome = {57 storage: {58 local: makeArea(local, localSet),59 sync: makeArea(sync, syncSet),60 session: makeArea(session, sessionSet),61 },62 } as unknown as typeof chrome;63}6465function hexToBytes(hex: string): Uint8Array {66 const out = new Uint8Array(hex.length / 2);67 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);68 return out;69}707172async function writeLegacyIdentity(payload: unknown): Promise<void> {73 const vault = await import("./vault");74 const aes = await import("./aes");75 const rootHex = vault.getRootKeyHex();76 if (!rootHex) throw new Error("unlocked vault required");77 const root = hexToBytes(rootHex);78 try {79 local[LEGACY_IDENTITY_KEY] = {80 v: 3,81 ...(await aes.encrypt(root, JSON.stringify(payload), LEGACY_INFO)),82 };83 } finally {84 root.fill(0);85 }86}8788interface StoredEnvelope {89 v: number;90 blob: { nonce: string; ciphertext: string; tag: string };91 ik: { root?: unknown; local?: unknown; pending?: unknown; pendingSalt?: string };92}9394function storedEnvelope(): StoredEnvelope {95 return local[IDENTITY_KEY] as StoredEnvelope;96}979899async function currentKeys(): Promise<{100 deviceId: string;101 longTermPublicKeyHex: string | undefined;102 signingPublicKeyHex: string | undefined;103 derivedPublicHex: string;104}> {105 const { getLocalPairingIdentity } = await import("./device-identity");106 const { identity, privateKey } = await getLocalPairingIdentity();107 return {108 deviceId: identity.deviceId,109 longTermPublicKeyHex: identity.longTermPublicKeyHex,110 signingPublicKeyHex: identity.signingPublicKeyHex,111 derivedPublicHex: Array.from(generateKeyPairFromSeed(privateKey).publicKey, (b) =>112 b.toString(16).padStart(2, "0"),113 ).join(""),114 };115}116117beforeEach(() => {118 vi.resetModules();119 installChromeStorageMock();120});121122describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => {123 it("fresh_install_writes_id_to_local_only", async () => {124 const vault = await import("./vault");125 await vault.createVault("identity-test-password");126 const { getOrCreateDeviceId } = await import("./device-identity");127128 const id = await getOrCreateDeviceId();129 expect(typeof id).toBe("string");130 expect(id.length).toBeGreaterThan(0);131 expect(local[DEVICE_ID_KEY]).toBeUndefined();132 expect(local[IDENTITY_KEY]).toBeDefined();133 expect(syncSet).not.toHaveBeenCalled();134 });135136 it("returns_a_stable_id_across_calls", async () => {137 const vault = await import("./vault");138 await vault.createVault("identity-test-password");139 const { getOrCreateDeviceId } = await import("./device-identity");140141 const first = await getOrCreateDeviceId();142 const second = await getOrCreateDeviceId();143 expect(second).toBe(first);144 });145146 it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => {147 const vault = await import("./vault");148 await vault.createVault("identity-test-password");149 const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity");150151 const original = await getOrCreateDeviceId();152 const fresh = await rotateDeviceId();153 expect(fresh).not.toBe(original);154 expect(local[IDENTITY_KEY]).toBeDefined();155 expect(await getOrCreateDeviceId()).toBe(fresh);156 expect(syncSet).not.toHaveBeenCalled();157 });158});159160describe("device-identity frozen schema (campaign task 2.1)", () => {161 it("persists every frozen field with public values matching the private material", async () => {162 const vault = await import("./vault");163 await vault.createVault("identity-test-password");164 const { getOrCreateDeviceId } = await import("./device-identity");165166 const deviceId = await getOrCreateDeviceId();167 const keys = await currentKeys();168 expect(keys.deviceId).toBe(deviceId);169 170 171 expect(keys.longTermPublicKeyHex).toBe(keys.derivedPublicHex);172 expect(keys.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);173 expect(keys.signingPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);174 });175176 it("seals the private material instead of storing it in the clear", async () => {177 const vault = await import("./vault");178 await vault.createVault("identity-test-password");179 const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity");180 await getOrCreateDeviceId();181182 const { privateKey } = await getLocalPairingIdentity();183 const privateHex = Array.from(privateKey, (b) => b.toString(16).padStart(2, "0")).join("");184 const envelope = storedEnvelope();185 expect(envelope.v).toBe(4);186 expect(envelope.ik.root).toBeDefined();187 expect(envelope.ik.local).toBeDefined();188 expect(envelope.ik.pending).toBeUndefined();189 expect(JSON.stringify(envelope)).not.toContain(privateHex);190 });191192 it("completes a legacy random record in place instead of regenerating it", async () => {193 const vault = await import("./vault");194 await vault.createVault("identity-test-password");195 await writeLegacyIdentity({196 v: 2,197 deviceId: "legacy-device-id",198 x25519PrivateKeyHex: "11".repeat(32),199 ed25519SeedHex: "22".repeat(32),200 });201202 const { getOrCreateDeviceId } = await import("./device-identity");203 expect(await getOrCreateDeviceId()).toBe("legacy-device-id");204 205 206 expect(storedEnvelope().v).toBe(4);207 expect(local[LEGACY_IDENTITY_KEY]).toBeUndefined();208 const keys = await currentKeys();209 expect(keys.deviceId).toBe("legacy-device-id");210 expect(keys.longTermPublicKeyHex).toBe(211 Array.from(generateKeyPairFromSeed(hexToBytes("11".repeat(32))).publicKey, (b) =>212 b.toString(16).padStart(2, "0"),213 ).join(""),214 );215 });216217 it("rejects a frozen record whose stored public key does not match its private key", async () => {218 const vault = await import("./vault");219 await vault.createVault("identity-test-password");220 await writeLegacyIdentity({221 v: 3,222 deviceId: "tampered",223 x25519PrivateKeyHex: "11".repeat(32),224 x25519PublicKeyHex: "33".repeat(32),225 ed25519SeedHex: "22".repeat(32),226 ed25519PublicKeyHex: "44".repeat(32),227 createdAt: Date.now(),228 });229230 const { getOrCreateDeviceId } = await import("./device-identity");231 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");232 233 expect(local[IDENTITY_KEY]).toBeUndefined();234 });235236 it("rejects a frozen record that is missing fields rather than defaulting them", async () => {237 const vault = await import("./vault");238 await vault.createVault("identity-test-password");239 await writeLegacyIdentity({240 v: 3,241 deviceId: "missing-fields",242 x25519PrivateKeyHex: "11".repeat(32),243 ed25519SeedHex: "22".repeat(32),244 });245246 const { getOrCreateDeviceId } = await import("./device-identity");247 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");248 expect(local[IDENTITY_KEY]).toBeUndefined();249 });250251 it("rejects a partial legacy record instead of minting a replacement", async () => {252 const vault = await import("./vault");253 await vault.createVault("identity-test-password");254 await writeLegacyIdentity({255 v: 2,256 deviceId: "partial",257 x25519PrivateKeyHex: "11".repeat(32),258 });259260 const { getOrCreateDeviceId } = await import("./device-identity");261 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");262 expect(local[IDENTITY_KEY]).toBeUndefined();263 });264});265266describe("device-identity pre-vault wallet (adoption v2 prerequisite)", () => {267 it("mints and reads a durable identity with no vault present", async () => {268 const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");269 expect((await import("./vault")).isUnlocked()).toBe(false);270271 await preparePreVaultIdentity("local-password");272 const first = await readPreVaultIdentity("local-password");273 expect(first.deviceId.length).toBeGreaterThan(0);274 expect(first.longTermPublicKeyHex).toMatch(/^[0-9a-f]{64}$/);275276 277 const envelope = storedEnvelope();278 expect(envelope.ik.pending).toBeDefined();279 expect(envelope.ik.root).toBeUndefined();280 expect(envelope.ik.local).toBeUndefined();281 });282283 it("survives a browser restart that drops the session cache", async () => {284 const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");285 await preparePreVaultIdentity("local-password");286 const before = await readPreVaultIdentity("local-password");287288 289 for (const key of Object.keys(session)) delete session[key];290 vi.resetModules();291 const restarted = await import("./device-identity");292 const after = await restarted.readPreVaultIdentity("local-password");293294 expect(after.deviceId).toBe(before.deviceId);295 expect(after.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex);296 expect(after.signingPublicKeyHex).toBe(before.signingPublicKeyHex);297 });298299 it("refuses the wrong password rather than minting a replacement", async () => {300 const { preparePreVaultIdentity, readPreVaultIdentity } = await import("./device-identity");301 await preparePreVaultIdentity("local-password");302 303 304 for (const key of Object.keys(session)) delete session[key];305306 await expect(readPreVaultIdentity("not-the-password")).rejects.toThrow(307 "identity_migration_required",308 );309 expect(storedEnvelope().ik.pending).toBeDefined();310 });311312 it("promotes the pre-vault identity to the vault wraps once the vault exists", async () => {313 const { preparePreVaultIdentity, readPreVaultIdentity, getOrCreateDeviceId } = await import(314 "./device-identity"315 );316 await preparePreVaultIdentity("local-password");317 const before = await readPreVaultIdentity("local-password");318319 const vault = await import("./vault");320 await vault.createVault("local-password");321 await readPreVaultIdentity("local-password");322323 const envelope = storedEnvelope();324 expect(envelope.ik.pending).toBeUndefined();325 expect(envelope.ik.root).toBeDefined();326 expect(envelope.ik.local).toBeDefined();327328 329 const after = await getOrCreateDeviceId();330 expect(after).toBe(before.deviceId);331 const keys = await currentKeys();332 expect(keys.longTermPublicKeyHex).toBe(before.longTermPublicKeyHex);333 expect(keys.signingPublicKeyHex).toBe(before.signingPublicKeyHex);334 });335336 it("refuses to regenerate the identity when no wrapping key is available", async () => {337 const vault = await import("./vault");338 await vault.createVault("identity-test-password");339 const { getOrCreateDeviceId } = await import("./device-identity");340 const real = await getOrCreateDeviceId();341342 343 344 delete storedEnvelope().ik.root;345 delete storedEnvelope().ik.local;346 for (const key of Object.keys(session)) delete session[key];347 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");348 expect(local[IDENTITY_KEY]).toBeDefined();349 expect(real.length).toBeGreaterThan(0);350 });351});352353describe("device-identity lifecycle (key rotation and duress)", () => {354 it("survives a password change followed by a sync-identity rotation", async () => {355 const vault = await import("./vault");356 await vault.createVault("first-password");357 const { getOrCreateDeviceId, getLocalPairingIdentity } = await import("./device-identity");358 const before = await getOrCreateDeviceId();359 const beforeKeys = await getLocalPairingIdentity();360361 expect(await vault.changePassword("first-password", "second-password")).toBe(true);362 expect((await vault.rotateSyncIdentity()).ok).toBe(true);363364 365 366 for (const key of Object.keys(session)) delete session[key];367 expect(await getOrCreateDeviceId()).toBe(before);368 const afterKeys = await getLocalPairingIdentity();369 expect(afterKeys.identity.longTermPublicKeyHex).toBe(beforeKeys.identity.longTermPublicKeyHex);370 expect(afterKeys.identity.signingPublicKeyHex).toBe(beforeKeys.identity.signingPublicKeyHex);371 });372373 it("does not serve the cached identity inside a duress session", async () => {374 const vault = await import("./vault");375 await vault.createVault("real-password");376 const { getOrCreateDeviceId, buildLocalIdentity } = await import("./device-identity");377 const real = await getOrCreateDeviceId();378 await vault.setupDuress("duress-password");379380 381 382 expect(await vault.unlock("duress-password")).toBe(true);383 expect(vault.isDecoyMode()).toBe(true);384 await expect(buildLocalIdentity()).rejects.toThrow("identity_migration_required");385 expect(real.length).toBeGreaterThan(0);386 });387388 it("refuses to mint a replacement when the record is missing but a vault exists", async () => {389 const vault = await import("./vault");390 await vault.createVault("identity-test-password");391 const { getOrCreateDeviceId } = await import("./device-identity");392 await getOrCreateDeviceId();393394 delete local[IDENTITY_KEY];395 for (const key of Object.keys(session)) delete session[key];396 await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required");397 expect(local[IDENTITY_KEY]).toBeUndefined();398 });399});