e37a085ed620

fix: PCH-01-004 move peach_chirp_device_id to chrome.storage.local with first-read sync migration — prevents cross-profile sync-identity collision (v3.8.1)

time
2026-06-10 19:31 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
7dfe6433a0ed
2 files src/background/chirp-identity.test.ts+85 −0src/background/chirp-identity.ts+24 −5
src/background/chirp-identity.test.ts+85 −0
@@ -0,0 +1,85 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+// PCH-01-004: deviceId must live in chrome.storage.local (device-local), never4+// in chrome.storage.sync (which roams across profiles and collides identities).5+6+type StorageShape = Record<string, unknown>;7+const DEVICE_ID_KEY = "peach_chirp_device_id";8+9+describe("chirp-identity getOrCreateDeviceId (PCH-01-004)", () => {10+  let local: StorageShape;11+  let sync: StorageShape;12+  let localSet: ReturnType<typeof vi.fn>;13+  let syncSet: ReturnType<typeof vi.fn>;14+  let syncRemove: ReturnType<typeof vi.fn>;15+16+  function makeArea(store: StorageShape, set: ReturnType<typeof vi.fn>, remove?: ReturnType<typeof vi.fn>) {17+    return {18+      get: vi.fn(async (key?: string | string[]) => {19+        if (!key) return store;20+        if (typeof key === "string") return { [key]: store[key] };21+        return key.reduce<Record<string, unknown>>((r, k) => {22+          r[k] = store[k];23+          return r;24+        }, {});25+      }),26+      set,27+      remove: remove ?? vi.fn(async () => undefined),28+    };29+  }30+31+  beforeEach(() => {32+    vi.resetModules();33+    local = {};34+    sync = {};35+    localSet = vi.fn(async (next: Record<string, unknown>) => {36+      Object.assign(local, next);37+    });38+    syncSet = vi.fn(async (next: Record<string, unknown>) => {39+      Object.assign(sync, next);40+    });41+    syncRemove = vi.fn(async (key: string) => {42+      delete sync[key];43+    });44+45+    const chromeMock = {46+      storage: {47+        local: makeArea(local, localSet),48+        sync: makeArea(sync, syncSet, syncRemove),49+      },50+    } as unknown as typeof chrome;51+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;52+  });53+54+  it("migrates_legacy_sync_id_to_local_and_removes_from_sync", async () => {55+    sync[DEVICE_ID_KEY] = "legacy-roamed-id";56+    const { getOrCreateDeviceId } = await import("./chirp-identity");57+58+    const id = await getOrCreateDeviceId();59+60+    expect(id).toBe("legacy-roamed-id");61+    expect(local[DEVICE_ID_KEY]).toBe("legacy-roamed-id");62+    expect(syncRemove).toHaveBeenCalledWith(DEVICE_ID_KEY);63+    expect(sync[DEVICE_ID_KEY]).toBeUndefined();64+  });65+66+  it("fresh_install_writes_id_to_local_only", async () => {67+    const { getOrCreateDeviceId } = await import("./chirp-identity");68+69+    const id = await getOrCreateDeviceId();70+71+    expect(typeof id).toBe("string");72+    expect(id.length).toBeGreaterThan(0);73+    expect(local[DEVICE_ID_KEY]).toBe(id);74+    expect(syncSet).not.toHaveBeenCalled();75+  });76+77+  it("returns_a_stable_id_across_calls", async () => {78+    const { getOrCreateDeviceId } = await import("./chirp-identity");79+80+    const first = await getOrCreateDeviceId();81+    const second = await getOrCreateDeviceId();82+83+    expect(second).toBe(first);84+  });85+});

view file

src/background/chirp-identity.ts+24 −5
@@ -1,7 +1,10 @@11 // Local-device identity helpers for the chirp-sync architecture.22 //33 // `deviceId` is a stable UUID v4 generated on first call and persisted in4-// `chrome.storage.sync` so it roams across browser-profile reinstalls.4+// `chrome.storage.local`. It is deliberately device-local and must NOT roam:5+// it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6+// so a shared id across profiles would collide derived sync keys. A first-read7+// migration moves any legacy value out of `chrome.storage.sync`.58 //69 // `deviceName` defaults to a short, friendly browser+OS description and710 // can be overridden by the user from Settings → Sync.@@ -30,11 +33,27 @@3033 }3134 3235 export async function getOrCreateDeviceId(): Promise<string> {33-  const result = await chrome.storage.sync.get(DEVICE_ID_KEY);34-  const current = (result as Record<string, unknown>)[DEVICE_ID_KEY];35-  if (typeof current === "string" && current.length > 0) return current;36+  // PCH-01-004: deviceId is the ONLY symmetry-breaker in the sync-identity HKDF37+  // (sync-identity.ts:54). It must NOT roam across profiles via chrome.storage.sync,38+  // or two profiles on the same browser account sharing a vault derive identical39+  // static + signing keys and collide in the device graph. Keep it device-local.40+  const local = await chrome.storage.local.get(DEVICE_ID_KEY);41+  const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];42+  if (typeof localId === "string" && localId.length > 0) return localId;43+44+  // First-read migration: adopt a value previously stored in sync, then evict it45+  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the46+  // id lives in local, this branch never runs again.47+  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);48+  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];49+  if (typeof syncedId === "string" && syncedId.length > 0) {50+    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });51+    await chrome.storage.sync.remove(DEVICE_ID_KEY);52+    return syncedId;53+  }54+3655   const fresh = generateUuidV4();37-  await chrome.storage.sync.set({ [DEVICE_ID_KEY]: fresh });56+  await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });3857   return fresh;3958 }4059 

view file