paired-devices.ts

7.8 KB · raw

1// Paired-device registry — v3 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// The registry has only ever lived in `chrome.storage.local` for released18// builds, so no cross-profile evacuation path is needed — the public keys19// never leave the device via a third-party sync back-end. Unpairing is20// local-only; Noise KK rejection on the next connection is the security21// boundary (see removePairedDeviceById).2223import type { PairedDevice } from "../shared/sync-types";2425const REGISTRY_KEY = "peach_paired_devices_v3";26const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";27const LAST_SYNC_AT_KEY = "peach_last_sync_at";28const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";2930export async function listPairedDevices(): Promise<PairedDevice[]> {31  try {32    const result = await chrome.storage.local.get(REGISTRY_KEY);33    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];34    if (Array.isArray(devices)) return devices.filter(isPairedDevice);35    return [];36  } catch {37    return [];38  }39}4041export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {42  const devices = await listPairedDevices();43  return devices.find((d) => d.deviceId === deviceId) ?? null;44}4546export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {47  const devices = await listPairedDevices();48  return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;49}5051/**52 * Add or update a device by `deviceId`. On re-pair the new pubkey53 * silently replaces the old one; the deviceId is stable across the54 * other device's reinstall.55 */56export async function upsertPairedDevice(device: PairedDevice): Promise<void> {57  if (!device.deviceId) throw new Error("device.deviceId is required");58  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");59  if (!device.deviceName) throw new Error("device.deviceName is required");60  if (!device.deviceType) throw new Error("device.deviceType is required");6162  const devices = await listPairedDevices();63  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);64  if (idx >= 0) {65    devices[idx] = { ...devices[idx], ...device };66  } else {67    devices.push(device);68  }69  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });70}7172/**73 * Move every existing pair to the user-selected mesh relay.74 *75 * Reconnect prefers the relay stored on each pair over the global fallback, so76 * changing only the setting would leave established devices on the old relay.77 */78export async function setRelayUrlForAllPairedDevices(79  relayUrl: string,80): Promise<void> {81  const devices = await listPairedDevices();82  if (devices.length === 0) return;83  await chrome.storage.local.set({84    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),85  });86}8788/**89 * Removes the device from the LOCAL paired-devices registry only.90 *91 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from92 * syncing with OTHER peers in the device graph, and the device RETAINS93 * its copy of the vault root key (syncMasterKey) — it can still decrypt94 * anything it already holds and anything other peers send it. For a95 * lost or stolen device, removal is insufficient: use "Rotate sync96 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so97 * the old device's key material is useless.98 */99export async function removePairedDeviceById(deviceId: string): Promise<void> {100  const devices = await listPairedDevices();101  const filtered = devices.filter((d) => d.deviceId !== deviceId);102  // Unpairing is local-only — there is no remote unpair RPC. The remote103  // device retains this device in its own registry. Noise KK rejection on104  // the next connection attempt is the security boundary.105  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });106  // Also clear the per-network IP cache for this device.107  const device = devices.find((d) => d.deviceId === deviceId);108  if (device?.longTermPublicKeyHex) {109    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);110  }111}112113export async function clearPairedDevices(): Promise<void> {114  await chrome.storage.local.remove(REGISTRY_KEY);115}116117export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {118  const devices = await listPairedDevices();119  const idx = devices.findIndex((d) => d.deviceId === deviceId);120  if (idx < 0) return;121  devices[idx] = { ...devices[idx], lastSyncedAt: when };122  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });123}124125export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {126  try {127    const key = LAST_IP_KEY_PREFIX + pubkeyHex;128    const result = await chrome.storage.local.get(key);129    const value = (result as Record<string, unknown>)[key];130    return typeof value === "string" ? value : null;131  } catch {132    return null;133  }134}135136export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {137  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });138}139140export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {141  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);142}143144export async function getLastSyncAt(): Promise<number | null> {145  try {146    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);147    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];148    if (typeof value === "number") return value;149    if (typeof value === "string" && value !== "") {150      const n = Number.parseInt(value, 10);151      return Number.isFinite(n) ? n : null;152    }153    return null;154  } catch {155    return null;156  }157}158159export async function setLastSyncAt(when: number): Promise<void> {160  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });161}162163export interface SyncResult {164  entriesReceived: number;165  entriesSent: number;166}167168export async function getLastSyncResult(): Promise<SyncResult | null> {169  try {170    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);171    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];172    if (173      value &&174      typeof value === "object" &&175      typeof (value as Record<string, unknown>).entriesReceived === "number" &&176      typeof (value as Record<string, unknown>).entriesSent === "number"177    ) {178      return value as SyncResult;179    }180    return null;181  } catch {182    return null;183  }184}185186export async function setLastSyncResult(result: SyncResult): Promise<void> {187  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });188}189190function isPairedDevice(x: unknown): x is PairedDevice {191  if (typeof x !== "object" || x === null) return false;192  const r = x as Record<string, unknown>;193  if (typeof r.deviceId !== "string" || !r.deviceId) return false;194  if (typeof r.pairedAt !== "number") return false;195  if (196    r.rendezvousSecretHex !== undefined &&197    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))198  ) {199    return false;200  }201  if (r.relayUrl !== undefined) {202    if (typeof r.relayUrl !== "string") return false;203    try {204      if (new URL(r.relayUrl).protocol !== "wss:") return false;205    } catch {206      return false;207    }208  }209  return true;210}