7e9dafc46041

add v3 paired device registry in chrome.storage.sync with per-network IP cache in storage.local

time
2026-05-18 02:17 UTC · 5 months ago
machine
koh2
parent
98614d0cb031
2 files src/background/paired-devices.test.ts+153 −0src/background/paired-devices.ts+134 −0
src/background/paired-devices.test.ts+153 −0
@@ -0,0 +1,153 @@1+import { describe, expect, it, beforeEach } from "vitest";2+import type { PairedDevice } from "../shared/sync-types";3+import {4+  clearLastKnownIp,5+  clearPairedDevices,6+  findPairedDeviceById,7+  findPairedDeviceByPubkey,8+  getLastKnownIp,9+  getLastSyncAt,10+  listPairedDevices,11+  markLastSyncedAt,12+  removePairedDeviceById,13+  setLastKnownIp,14+  setLastSyncAt,15+  upsertPairedDevice,16+} from "./paired-devices";17+18+function makeStorageStub() {19+  const sync = new Map<string, unknown>();20+  const local = new Map<string, unknown>();21+  function area(store: Map<string, unknown>) {22+    return {23+      async get(key?: string | string[] | null) {24+        if (key === null || key === undefined) {25+          const all: Record<string, unknown> = {};26+          for (const [k, v] of store) all[k] = v;27+          return all;28+        }29+        const keys = Array.isArray(key) ? key : [key];30+        const out: Record<string, unknown> = {};31+        for (const k of keys) if (store.has(k)) out[k] = store.get(k);32+        return out;33+      },34+      async set(values: Record<string, unknown>) {35+        for (const [k, v] of Object.entries(values)) store.set(k, v);36+      },37+      async remove(key: string | string[]) {38+        const keys = Array.isArray(key) ? key : [key];39+        for (const k of keys) store.delete(k);40+      },41+    };42+  }43+  (globalThis as unknown as { chrome: unknown }).chrome = {44+    storage: { sync: area(sync), local: area(local) },45+  };46+  return { sync, local };47+}48+49+const SAMPLE: PairedDevice = {50+  deviceId: "AAA-111",51+  deviceName: "June's Pixel 9",52+  deviceType: "android",53+  longTermPublicKeyHex: "0".repeat(64),54+  pairedAt: 1700000000000,55+};56+57+describe("paired-devices registry", () => {58+  beforeEach(() => makeStorageStub());59+60+  it("upsert + list", async () => {61+    await upsertPairedDevice(SAMPLE);62+    expect(await listPairedDevices()).toEqual([SAMPLE]);63+  });64+65+  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {66+    await upsertPairedDevice(SAMPLE);67+    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });68+    const all = await listPairedDevices();69+    expect(all).toHaveLength(1);70+    expect(all[0].deviceName).toBe("Renamed Pixel");71+  });72+73+  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {74+    await upsertPairedDevice(SAMPLE);75+    const newPubkey = "f".repeat(64);76+    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });77+    const found = await findPairedDeviceById(SAMPLE.deviceId);78+    expect(found?.longTermPublicKeyHex).toBe(newPubkey);79+  });80+81+  it("rejects upsert with missing fields", async () => {82+    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);83+    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);84+    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);85+    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);86+  });87+88+  it("findPairedDeviceByPubkey returns the correct device", async () => {89+    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };90+    await upsertPairedDevice(SAMPLE);91+    await upsertPairedDevice(other);92+    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);93+    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();94+  });95+96+  it("removePairedDeviceById drops the device and its IP cache", async () => {97+    await upsertPairedDevice(SAMPLE);98+    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");99+    await removePairedDeviceById(SAMPLE.deviceId);100+    expect(await listPairedDevices()).toEqual([]);101+    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();102+  });103+104+  it("clearPairedDevices wipes the whole registry", async () => {105+    await upsertPairedDevice(SAMPLE);106+    await clearPairedDevices();107+    expect(await listPairedDevices()).toEqual([]);108+  });109+110+  it("markLastSyncedAt mutates only the target device", async () => {111+    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };112+    await upsertPairedDevice(SAMPLE);113+    await upsertPairedDevice(other);114+    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);115+    const found = await findPairedDeviceById(SAMPLE.deviceId);116+    expect(found?.lastSyncedAt).toBe(1700001000000);117+    const untouched = await findPairedDeviceById(other.deviceId);118+    expect(untouched?.lastSyncedAt).toBeUndefined();119+  });120+121+  it("getLastKnownIp / setLastKnownIp round-trip", async () => {122+    await setLastKnownIp("ab".repeat(32), "10.0.0.5");123+    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");124+    await clearLastKnownIp("ab".repeat(32));125+    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();126+  });127+128+  it("last sync timestamp accepts a legacy string value", async () => {129+    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({130+      peach_last_sync_at: "1700002000000",131+    });132+    expect(await getLastSyncAt()).toBe(1700002000000);133+  });134+135+  it("setLastSyncAt persists a numeric value", async () => {136+    await setLastSyncAt(1700003000000);137+    expect(await getLastSyncAt()).toBe(1700003000000);138+  });139+140+  it("listPairedDevices ignores garbage entries silently", async () => {141+    (globalThis as unknown as { chrome: { storage: { sync: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.sync.set({142+      peach_paired_devices_v3: [143+        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },144+        { not: "a device" },145+        null,146+        42,147+      ],148+    });149+    const devices = await listPairedDevices();150+    expect(devices).toHaveLength(1);151+    expect(devices[0].deviceId).toBe("real");152+  });153+});

view file

src/background/paired-devices.ts+134 −0
@@ -0,0 +1,134 @@1+// Paired-device registry — v3 chirp-sync.2+//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.8+//9+// `deviceId` is the canonical primary key (UUID v4, stable for the10+// lifetime of the installation). `longTermPublicKeyHex` is also unique11+// per device but rotates if the user reinstalls before re-pairing, so we12+// key on deviceId and update the key in place during re-pair.13+14+import type { PairedDevice } from "../shared/sync-types";15+16+const REGISTRY_KEY = "peach_paired_devices_v3";17+const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";18+const LAST_SYNC_AT_KEY = "peach_last_sync_at";19+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+25+export async function listPairedDevices(): Promise<PairedDevice[]> {26+  try {27+    const result = await chrome.storage.sync.get(REGISTRY_KEY);28+    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];29+    if (!Array.isArray(devices)) return [];30+    return devices.filter(isPairedDevice);31+  } catch {32+    return [];33+  }34+}35+36+export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {37+  const devices = await listPairedDevices();38+  return devices.find((d) => d.deviceId === deviceId) ?? null;39+}40+41+export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {42+  const devices = await listPairedDevices();43+  return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;44+}45+46+/**47+ * Add or update a device by `deviceId`. On re-pair the new pubkey48+ * silently replaces the old one; the deviceId is stable across the49+ * other device's reinstall.50+ */51+export async function upsertPairedDevice(device: PairedDevice): Promise<void> {52+  if (!device.deviceId) throw new Error("device.deviceId is required");53+  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");54+  if (!device.deviceName) throw new Error("device.deviceName is required");55+  if (!device.deviceType) throw new Error("device.deviceType is required");56+57+  const devices = await listPairedDevices();58+  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);59+  if (idx >= 0) {60+    devices[idx] = { ...devices[idx], ...device };61+  } else {62+    devices.push(device);63+  }64+  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });65+}66+67+export async function removePairedDeviceById(deviceId: string): Promise<void> {68+  const devices = await listPairedDevices();69+  const filtered = devices.filter((d) => d.deviceId !== deviceId);70+  await chrome.storage.sync.set({ [REGISTRY_KEY]: filtered });71+  // Also clear the per-network IP cache for this device.72+  const device = devices.find((d) => d.deviceId === deviceId);73+  if (device?.longTermPublicKeyHex) {74+    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);75+  }76+}77+78+export async function clearPairedDevices(): Promise<void> {79+  await chrome.storage.sync.remove(REGISTRY_KEY);80+}81+82+export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {83+  const devices = await listPairedDevices();84+  const idx = devices.findIndex((d) => d.deviceId === deviceId);85+  if (idx < 0) return;86+  devices[idx] = { ...devices[idx], lastSyncedAt: when };87+  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });88+}89+90+export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {91+  try {92+    const key = LAST_IP_KEY_PREFIX + pubkeyHex;93+    const result = await chrome.storage.local.get(key);94+    const value = (result as Record<string, unknown>)[key];95+    return typeof value === "string" ? value : null;96+  } catch {97+    return null;98+  }99+}100+101+export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {102+  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });103+}104+105+export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {106+  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);107+}108+109+export async function getLastSyncAt(): Promise<number | null> {110+  try {111+    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);112+    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];113+    if (typeof value === "number") return value;114+    if (typeof value === "string" && value !== "") {115+      const n = Number.parseInt(value, 10);116+      return Number.isFinite(n) ? n : null;117+    }118+    return null;119+  } catch {120+    return null;121+  }122+}123+124+export async function setLastSyncAt(when: number): Promise<void> {125+  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });126+}127+128+function isPairedDevice(x: unknown): x is PairedDevice {129+  if (typeof x !== "object" || x === null) return false;130+  const r = x as Record<string, unknown>;131+  if (typeof r.deviceId !== "string" || !r.deviceId) return false;132+  if (typeof r.pairedAt !== "number") return false;133+  return true;134+}

view file