paired-devices.ts

11.9 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 { DeviceRevocation, PairedDevice } from "../shared/sync-types";24import { constantTimeEqualHex } from "../shared/constant-time";2526const REGISTRY_KEY = "peach_paired_devices_v3";27const REVOCATIONS_KEY = "peach_device_revocations_v1";28/** Bound the list so a hostile peer cannot grow storage without limit. */29const MAX_REVOCATIONS = 500;30const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";31const LAST_SYNC_AT_KEY = "peach_last_sync_at";32const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3334export async function listPairedDevices(): Promise<PairedDevice[]> {35  try {36    const result = await chrome.storage.local.get(REGISTRY_KEY);37    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];38    if (Array.isArray(devices)) return devices.filter(isPairedDevice);39    return [];40  } catch {41    return [];42  }43}4445export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {46  const devices = await listPairedDevices();47  return devices.find((d) => d.deviceId === deviceId) ?? null;48}4950export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {51  const devices = await listPairedDevices();52  return devices.find((d) =>53    d.longTermPublicKeyHex !== undefined &&54    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)55  ) ?? null;56}5758/**59 * Add or update a device by `deviceId`. On re-pair the new pubkey60 * silently replaces the old one; the deviceId is stable across the61 * other device's reinstall.62 */63export async function upsertPairedDevice(device: PairedDevice): Promise<void> {64  if (!device.deviceId) throw new Error("device.deviceId is required");65  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");66  if (!device.deviceName) throw new Error("device.deviceName is required");67  if (!device.deviceType) throw new Error("device.deviceType is required");6869  const devices = await listPairedDevices();70  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);71  if (idx >= 0) {72    devices[idx] = { ...devices[idx], ...device };73  } else {74    devices.push(device);75  }76  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });77}7879/**80 * Move every existing pair to the user-selected mesh relay.81 *82 * Reconnect prefers the relay stored on each pair over the global fallback, so83 * changing only the setting would leave established devices on the old relay.84 */85export async function setRelayUrlForAllPairedDevices(86  relayUrl: string,87): Promise<void> {88  const devices = await listPairedDevices();89  if (devices.length === 0) return;90  await chrome.storage.local.set({91    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),92  });93}9495/**96 * Removes the device from the LOCAL paired-devices registry only, leaving no97 * record behind. Prefer `revokeDeviceById` for anything the user would call98 * "remove this device" — a plain removal is silently undone the next time any99 * peer vouches the device back through a manifest.100 *101 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from102 * syncing with OTHER peers in the device graph, and the device RETAINS103 * its copy of the vault root key (syncMasterKey) — it can still decrypt104 * anything it already holds and anything other peers send it. For a105 * lost or stolen device, removal is insufficient: use "Rotate sync106 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so107 * the old device's key material is useless.108 */109export async function removePairedDeviceById(deviceId: string): Promise<void> {110  const devices = await listPairedDevices();111  const filtered = devices.filter((d) => d.deviceId !== deviceId);112  // Unpairing is local-only — there is no remote unpair RPC. The remote113  // device retains this device in its own registry. Noise KK rejection on114  // the next connection attempt is the security boundary.115  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });116  // Also clear the per-network IP cache for this device.117  const device = devices.find((d) => d.deviceId === deviceId);118  if (device?.longTermPublicKeyHex) {119    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);120  }121}122123// ---------------------------------------------------------------------------124// Device revocation (Part 5 §C) — a removal that propagates and stays removed.125// ---------------------------------------------------------------------------126127export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {128  try {129    const result = await chrome.storage.local.get(REVOCATIONS_KEY);130    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];131    if (Array.isArray(value)) return value.filter(isDeviceRevocation);132    return [];133  } catch {134    return [];135  }136}137138export async function isDeviceRevoked(deviceId: string): Promise<boolean> {139  return (await listDeviceRevocations()).some((r) => r.deviceId === deviceId);140}141142/**143 * Revoke a device: record it locally, then drop it from the registry and the144 * pending-vouch queue. The record rides out in every manifest this device145 * signs from now on, and blocks the device being vouched back in.146 */147export async function revokeDeviceById(148  deviceId: string,149  revokedByDeviceId: string,150  options: { now?: number } = {},151): Promise<DeviceRevocation | null> {152  const device = await findPairedDeviceById(deviceId);153  const existing = await listDeviceRevocations();154  if (existing.some((r) => r.deviceId === deviceId)) return null;155156  const record: DeviceRevocation = {157    deviceId,158    // A device with no stored key was never usable as a sync peer; the159    // all-zero key keeps the record well-formed on the wire without160    // pretending to name a key we never saw.161    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),162    revokedAt: options.now ?? Date.now(),163    revokedByDeviceId,164  };165  await storeRevocations([...existing, record]);166  await removePairedDeviceById(deviceId);167  return record;168}169170/**171 * Merge revocations learned from a peer's manifest. Returns the deviceIds that172 * were newly revoked, so the caller can evict them.173 *174 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is175 * terminal, so the timestamp is display metadata only and cannot be used to176 * win or lose a race (HOLE 2).177 */178export async function mergeDeviceRevocations(179  incoming: DeviceRevocation[],180): Promise<string[]> {181  const existing = await listDeviceRevocations();182  const known = new Set(existing.map((r) => r.deviceId));183  const fresh = incoming.filter((r) => !known.has(r.deviceId));184  if (fresh.length === 0) return [];185  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));186  return fresh.map((r) => r.deviceId);187}188189/**190 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed191 * re-pair: a physically present user on both devices is a stronger consent192 * signal than whatever created the original pairing. Never call this from a193 * path a remote peer can reach.194 */195export async function clearDeviceRevocation(deviceId: string): Promise<void> {196  const existing = await listDeviceRevocations();197  const filtered = existing.filter((r) => r.deviceId !== deviceId);198  if (filtered.length === existing.length) return;199  await storeRevocations(filtered);200}201202async function storeRevocations(records: DeviceRevocation[]): Promise<void> {203  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });204}205206function isDeviceRevocation(x: unknown): x is DeviceRevocation {207  if (typeof x !== "object" || x === null) return false;208  const r = x as Record<string, unknown>;209  return (210    typeof r.deviceId === "string" &&211    !!r.deviceId &&212    typeof r.longTermPublicKeyHex === "string" &&213    typeof r.revokedAt === "number" &&214    typeof r.revokedByDeviceId === "string"215  );216}217218export async function clearPairedDevices(): Promise<void> {219  await chrome.storage.local.remove(REGISTRY_KEY);220}221222export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {223  const devices = await listPairedDevices();224  const idx = devices.findIndex((d) => d.deviceId === deviceId);225  if (idx < 0) return;226  devices[idx] = { ...devices[idx], lastSyncedAt: when };227  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });228}229230export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {231  try {232    const key = LAST_IP_KEY_PREFIX + pubkeyHex;233    const result = await chrome.storage.local.get(key);234    const value = (result as Record<string, unknown>)[key];235    return typeof value === "string" ? value : null;236  } catch {237    return null;238  }239}240241export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {242  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });243}244245export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {246  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);247}248249export async function getLastSyncAt(): Promise<number | null> {250  try {251    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);252    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];253    if (typeof value === "number") return value;254    if (typeof value === "string" && value !== "") {255      const n = Number.parseInt(value, 10);256      return Number.isFinite(n) ? n : null;257    }258    return null;259  } catch {260    return null;261  }262}263264export async function setLastSyncAt(when: number): Promise<void> {265  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });266}267268export interface SyncResult {269  entriesReceived: number;270  entriesSent: number;271}272273export async function getLastSyncResult(): Promise<SyncResult | null> {274  try {275    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);276    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];277    if (278      value &&279      typeof value === "object" &&280      typeof (value as Record<string, unknown>).entriesReceived === "number" &&281      typeof (value as Record<string, unknown>).entriesSent === "number"282    ) {283      return value as SyncResult;284    }285    return null;286  } catch {287    return null;288  }289}290291export async function setLastSyncResult(result: SyncResult): Promise<void> {292  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });293}294295function isPairedDevice(x: unknown): x is PairedDevice {296  if (typeof x !== "object" || x === null) return false;297  const r = x as Record<string, unknown>;298  if (typeof r.deviceId !== "string" || !r.deviceId) return false;299  if (typeof r.pairedAt !== "number") return false;300  if (301    r.rendezvousSecretHex !== undefined &&302    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))303  ) {304    return false;305  }306  if (r.relayUrl !== undefined) {307    if (typeof r.relayUrl !== "string") return false;308    try {309      if (new URL(r.relayUrl).protocol !== "wss:") return false;310    } catch {311      return false;312    }313  }314  return true;315}