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
↓ peach-browser-e37a085e.tar.gzatlas at this save
files changed2 files
src/background/chirp-identity.test.ts+85 −0src/background/chirp-identity.ts+24 −5src/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+});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