paired-devices.ts

7.1 KB · raw

1// Paired-device registry — v3 chirp-sync.2//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.7//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//12// `deviceId` is the canonical primary key (UUID v4, stable for the13// lifetime of the installation). `longTermPublicKeyHex` is also unique14// per device but rotates if the user reinstalls before re-pairing, so we15// 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.2122import type { PairedDevice } from "../shared/sync-types";2324const REGISTRY_KEY = "peach_paired_devices_v3";25const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";26const LAST_SYNC_AT_KEY = "peach_last_sync_at";27const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";2829export async function listPairedDevices(): Promise<PairedDevice[]> {30  try {31    const result = await chrome.storage.local.get(REGISTRY_KEY);32    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];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 [];48  } catch {49    return [];50  }51}5253export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {54  const devices = await listPairedDevices();55  return devices.find((d) => d.deviceId === deviceId) ?? null;56}5758export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {59  const devices = await listPairedDevices();60  return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;61}6263/**64 * Add or update a device by `deviceId`. On re-pair the new pubkey65 * silently replaces the old one; the deviceId is stable across the66 * other device's reinstall.67 */68export async function upsertPairedDevice(device: PairedDevice): Promise<void> {69  if (!device.deviceId) throw new Error("device.deviceId is required");70  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");71  if (!device.deviceName) throw new Error("device.deviceName is required");72  if (!device.deviceType) throw new Error("device.deviceType is required");7374  const devices = await listPairedDevices();75  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);76  if (idx >= 0) {77    devices[idx] = { ...devices[idx], ...device };78  } else {79    devices.push(device);80  }81  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });82}8384export async function removePairedDeviceById(deviceId: string): Promise<void> {85  const devices = await listPairedDevices();86  const filtered = devices.filter((d) => d.deviceId !== deviceId);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 });91  // Also clear the per-network IP cache for this device.92  const device = devices.find((d) => d.deviceId === deviceId);93  if (device?.longTermPublicKeyHex) {94    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);95  }96}9798export async function clearPairedDevices(): Promise<void> {99  await chrome.storage.local.remove(REGISTRY_KEY);100}101102export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {103  const devices = await listPairedDevices();104  const idx = devices.findIndex((d) => d.deviceId === deviceId);105  if (idx < 0) return;106  devices[idx] = { ...devices[idx], lastSyncedAt: when };107  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });108}109110export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {111  try {112    const key = LAST_IP_KEY_PREFIX + pubkeyHex;113    const result = await chrome.storage.local.get(key);114    const value = (result as Record<string, unknown>)[key];115    return typeof value === "string" ? value : null;116  } catch {117    return null;118  }119}120121export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {122  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });123}124125export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {126  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);127}128129export async function getLastSyncAt(): Promise<number | null> {130  try {131    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);132    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];133    if (typeof value === "number") return value;134    if (typeof value === "string" && value !== "") {135      const n = Number.parseInt(value, 10);136      return Number.isFinite(n) ? n : null;137    }138    return null;139  } catch {140    return null;141  }142}143144export async function setLastSyncAt(when: number): Promise<void> {145  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });146}147148export interface SyncResult {149  entriesReceived: number;150  entriesSent: number;151}152153export 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}170171export async function setLastSyncResult(result: SyncResult): Promise<void> {172  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });173}174175function isPairedDevice(x: unknown): x is PairedDevice {176  if (typeof x !== "object" || x === null) return false;177  const r = x as Record<string, unknown>;178  if (typeof r.deviceId !== "string" || !r.deviceId) return false;179  if (typeof r.pairedAt !== "number") return false;180  return true;181}