device-identity.test.ts

7.9 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { Mock } from "vitest";34// PCH-01-004: deviceId must live in chrome.storage.local (device-local), never5// in chrome.storage.sync (which roams across profiles and collides identities).6//7// Each case re-imports the module after `vi.resetModules()` so the loader's8// hydration cache starts empty; that module-loading boundary is the reason the9// imports below are dynamic rather than static.1011type 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}4950/** Encrypt an arbitrary payload into the identity slot, bypassing the loader. */51async 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}6465/** Decrypt whatever the loader persisted, for schema assertions. */66async 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    // Same keys: the upgrade must never mint a new identity underneath pairings.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});