paired-devices.ts

8.0 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";24import { constantTimeEqualHex } from "../shared/constant-time";2526const REGISTRY_KEY = "peach_paired_devices_v3";27const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";28const LAST_SYNC_AT_KEY = "peach_last_sync_at";29const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3031export async function listPairedDevices(): Promise<PairedDevice[]> {32  try {33    const result = await chrome.storage.local.get(REGISTRY_KEY);34    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];35    if (Array.isArray(devices)) return devices.filter(isPairedDevice);36    return [];37  } catch {38    return [];39  }40}4142export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {43  const devices = await listPairedDevices();44  return devices.find((d) => d.deviceId === deviceId) ?? null;45}4647export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {48  const devices = await listPairedDevices();49  return devices.find((d) =>50    d.longTermPublicKeyHex !== undefined &&51    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)52  ) ?? null;53}5455/**56 * Add or update a device by `deviceId`. On re-pair the new pubkey57 * silently replaces the old one; the deviceId is stable across the58 * other device's reinstall.59 */60export async function upsertPairedDevice(device: PairedDevice): Promise<void> {61  if (!device.deviceId) throw new Error("device.deviceId is required");62  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");63  if (!device.deviceName) throw new Error("device.deviceName is required");64  if (!device.deviceType) throw new Error("device.deviceType is required");6566  const devices = await listPairedDevices();67  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);68  if (idx >= 0) {69    devices[idx] = { ...devices[idx], ...device };70  } else {71    devices.push(device);72  }73  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });74}7576/**77 * Move every existing pair to the user-selected mesh relay.78 *79 * Reconnect prefers the relay stored on each pair over the global fallback, so80 * changing only the setting would leave established devices on the old relay.81 */82export async function setRelayUrlForAllPairedDevices(83  relayUrl: string,84): Promise<void> {85  const devices = await listPairedDevices();86  if (devices.length === 0) return;87  await chrome.storage.local.set({88    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),89  });90}9192/**93 * Removes the device from the LOCAL paired-devices registry only.94 *95 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from96 * syncing with OTHER peers in the device graph, and the device RETAINS97 * its copy of the vault root key (syncMasterKey) — it can still decrypt98 * anything it already holds and anything other peers send it. For a99 * lost or stolen device, removal is insufficient: use "Rotate sync100 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so101 * the old device's key material is useless.102 */103export async function removePairedDeviceById(deviceId: string): Promise<void> {104  const devices = await listPairedDevices();105  const filtered = devices.filter((d) => d.deviceId !== deviceId);106  // Unpairing is local-only — there is no remote unpair RPC. The remote107  // device retains this device in its own registry. Noise KK rejection on108  // the next connection attempt is the security boundary.109  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });110  // Also clear the per-network IP cache for this device.111  const device = devices.find((d) => d.deviceId === deviceId);112  if (device?.longTermPublicKeyHex) {113    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);114  }115}116117export async function clearPairedDevices(): Promise<void> {118  await chrome.storage.local.remove(REGISTRY_KEY);119}120121export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {122  const devices = await listPairedDevices();123  const idx = devices.findIndex((d) => d.deviceId === deviceId);124  if (idx < 0) return;125  devices[idx] = { ...devices[idx], lastSyncedAt: when };126  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });127}128129export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {130  try {131    const key = LAST_IP_KEY_PREFIX + pubkeyHex;132    const result = await chrome.storage.local.get(key);133    const value = (result as Record<string, unknown>)[key];134    return typeof value === "string" ? value : null;135  } catch {136    return null;137  }138}139140export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {141  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });142}143144export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {145  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);146}147148export async function getLastSyncAt(): Promise<number | null> {149  try {150    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);151    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];152    if (typeof value === "number") return value;153    if (typeof value === "string" && value !== "") {154      const n = Number.parseInt(value, 10);155      return Number.isFinite(n) ? n : null;156    }157    return null;158  } catch {159    return null;160  }161}162163export async function setLastSyncAt(when: number): Promise<void> {164  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });165}166167export interface SyncResult {168  entriesReceived: number;169  entriesSent: number;170}171172export async function getLastSyncResult(): Promise<SyncResult | null> {173  try {174    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);175    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];176    if (177      value &&178      typeof value === "object" &&179      typeof (value as Record<string, unknown>).entriesReceived === "number" &&180      typeof (value as Record<string, unknown>).entriesSent === "number"181    ) {182      return value as SyncResult;183    }184    return null;185  } catch {186    return null;187  }188}189190export async function setLastSyncResult(result: SyncResult): Promise<void> {191  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });192}193194function isPairedDevice(x: unknown): x is PairedDevice {195  if (typeof x !== "object" || x === null) return false;196  const r = x as Record<string, unknown>;197  if (typeof r.deviceId !== "string" || !r.deviceId) return false;198  if (typeof r.pairedAt !== "number") return false;199  if (200    r.rendezvousSecretHex !== undefined &&201    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))202  ) {203    return false;204  }205  if (r.relayUrl !== undefined) {206    if (typeof r.relayUrl !== "string") return false;207    try {208      if (new URL(r.relayUrl).protocol !== "wss:") return false;209    } catch {210      return false;211    }212  }213  return true;214}