device-identity.test.ts

16.0 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import type { Mock } from "vitest";3import { generateKeyPairFromSeed } from "@stablelib/x25519";45// PCH-01-004: deviceId must live in chrome.storage.local (device-local), never6// in chrome.storage.sync (which roams across profiles and collides identities).7//8// The identity credentials are sealed under a random identity key which itself9// is wrapped under the vault root, the vault-unlock key, and — on a fresh10// install being adopted — a key derived from the local password.11//12// Each case re-imports the module after `vi.resetModules()` so the loader's13// hydration cache starts empty; that module-loading boundary is the reason the14// imports below are dynamic rather than static.1516type 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}7071/** Seal an arbitrary payload into the pre-envelope identity slot, bypassing the loader. */72async 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}9798/** The private/public pair the loader currently holds for this device. */99async 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    // The stored public half must be the one the private half derives, not an170    // independent value that merely looks well-formed.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    // Same identity material, now in the sealed envelope; the root-readable205    // legacy copy is gone.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    // A rejected record must not be replaced by a freshly minted identity.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    // Only the password-derived wrap exists before the vault does.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    // A browser restart clears chrome.storage.session and the module state.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    // Drop the unlocked-session copy so the password-derived wrap is the only303    // way in; otherwise the cache answers without consulting it.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    // Same identity, now readable from the vault alone.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    // Losing every wrap (no root, no vault-unlock key, no password) must fail343    // closed rather than mint a new device identity.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    // Each swap kills the wrap encrypted under the outgoing key. Force a real365    // envelope open so a missing re-wrap cannot hide behind the session cache.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    // Switching an unlocked real session into duress mode must not leave the381    // real identity reachable through the session cache.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});