21d3f786ea14

fix: device registry — move from chrome.storage.sync to chrome.storage.local with one-time migration; add peach_last_sync_result storage (v3.6.94)

time
2026-05-22 18:21 UTC · 5 months ago
machine
koh2
parent
b92e86329db5
1 file src/background/paired-devices.ts+64 −17
src/background/paired-devices.ts+64 −17
@@ -1,33 +1,50 @@11 // Paired-device registry — v3 chirp-sync.22 //3-// Per the brief: long-term records live in `chrome.storage.sync` so the4-// registry roams across browser profiles signed into Chrome/Firefox sync.5-// `lastKnownIp` is network-specific and lives in `chrome.storage.local`6-// instead — bringing the IP across networks would do nothing but cause7-// the subnet probe to chase a stale address.3+// Long-term records live in `chrome.storage.local`. Keys must not leave4+// the device via a third-party sync service — `longTermPublicKeyHex` and5+// `signingPublicKeyHex` are cryptographic keys; routing them through6+// Google's or Mozilla's sync back-end violates the local-only trust model.87 //8+// `lastKnownIp` is network-specific and also lives in `chrome.storage.local`,9+// keyed by `longTermPublicKeyHex` (not on the device record itself) so10+// network-specific addresses don't roam across profiles.11+//912 // `deviceId` is the canonical primary key (UUID v4, stable for the1013 // lifetime of the installation). `longTermPublicKeyHex` is also unique1114 // per device but rotates if the user reinstalls before re-pairing, so we1215 // key on deviceId and update the key in place during re-pair.16+//17+// Migration note: earlier builds stored the registry in `chrome.storage.sync`.18+// `listPairedDevices` transparently migrates any existing sync-stored19+// registry to local storage on first read. This is a one-time, local-only20+// unpair is the security boundary — see removePairedDeviceById for details.1321 1422 import type { PairedDevice } from "../shared/sync-types";1523 1624 const REGISTRY_KEY = "peach_paired_devices_v3";1725 const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";1826 const LAST_SYNC_AT_KEY = "peach_last_sync_at";27+const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";1928 20-// chrome.storage.sync has hard quotas: 100KB total, 8KB per item. A paired21-// device record is ~200 bytes (one X25519 hex pubkey + UUIDs + a name);22-// 100KB easily holds the realistic ~5-10 devices a user pairs. We store23-// the array under a single key to avoid eating the per-key quota.24-2529 export async function listPairedDevices(): Promise<PairedDevice[]> {2630   try {27-    const result = await chrome.storage.sync.get(REGISTRY_KEY);31+    const result = await chrome.storage.local.get(REGISTRY_KEY);2832     const devices = (result as Record<string, unknown>)[REGISTRY_KEY];29-    if (!Array.isArray(devices)) return [];30-    return devices.filter(isPairedDevice);33+    if (Array.isArray(devices)) return devices.filter(isPairedDevice);34+    // One-time migration from the earlier chrome.storage.sync location.35+    // If local storage is empty but sync has data, move it over and clear36+    // the sync copy so keys stop leaving the device.37+    try {38+      const syncResult = await chrome.storage.sync.get(REGISTRY_KEY);39+      const syncDevices = (syncResult as Record<string, unknown>)[REGISTRY_KEY];40+      if (Array.isArray(syncDevices) && syncDevices.length > 0) {41+        const valid = syncDevices.filter(isPairedDevice);42+        await chrome.storage.local.set({ [REGISTRY_KEY]: valid });43+        await chrome.storage.sync.remove(REGISTRY_KEY).catch(() => undefined);44+        return valid;45+      }46+    } catch { /* sync unavailable in Firefox or context — skip migration */ }47+    return [];3148   } catch {3249     return [];3350   }@@ -61,13 +78,16 @@6178   } else {6279     devices.push(device);6380   }64-  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });81+  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });6582 }6683 6784 export async function removePairedDeviceById(deviceId: string): Promise<void> {6885   const devices = await listPairedDevices();6986   const filtered = devices.filter((d) => d.deviceId !== deviceId);70-  await chrome.storage.sync.set({ [REGISTRY_KEY]: filtered });87+  // Unpairing is local-only — there is no remote unpair RPC. The remote88+  // device retains this device in its own registry. Noise XX rejection on89+  // the next connection attempt is the security boundary.90+  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });7191   // Also clear the per-network IP cache for this device.7292   const device = devices.find((d) => d.deviceId === deviceId);7393   if (device?.longTermPublicKeyHex) {@@ -76,7 +96,7 @@7696 }7797 7898 export async function clearPairedDevices(): Promise<void> {79-  await chrome.storage.sync.remove(REGISTRY_KEY);99+  await chrome.storage.local.remove(REGISTRY_KEY);80100 }81101 82102 export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {@@ -84,7 +104,7 @@84104   const idx = devices.findIndex((d) => d.deviceId === deviceId);85105   if (idx < 0) return;86106   devices[idx] = { ...devices[idx], lastSyncedAt: when };87-  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });107+  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });88108 }89109 90110 export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {@@ -123,6 +143,33 @@123143 124144 export async function setLastSyncAt(when: number): Promise<void> {125145   await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });146+}147+148+export interface SyncResult {149+  entriesReceived: number;150+  entriesSent: number;151+}152+153+export async function getLastSyncResult(): Promise<SyncResult | null> {154+  try {155+    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);156+    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];157+    if (158+      value &&159+      typeof value === "object" &&160+      typeof (value as Record<string, unknown>).entriesReceived === "number" &&161+      typeof (value as Record<string, unknown>).entriesSent === "number"162+    ) {163+      return value as SyncResult;164+    }165+    return null;166+  } catch {167+    return null;168+  }169+}170+171+export async function setLastSyncResult(result: SyncResult): Promise<void> {172+  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });126173 }127174 128175 function isPairedDevice(x: unknown): x is PairedDevice {

view file