paired-devices.ts

13.1 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";25import { clampSyncTimestamp } from "../shared/sync-limits";2627const REGISTRY_KEY = "peach_paired_devices_v3";28const REVOCATIONS_KEY = "peach_device_revocations_v1";29/** Bound the list so a hostile peer cannot grow storage without limit. */30const MAX_REVOCATIONS = 500;31const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";32const LAST_SYNC_AT_KEY = "peach_last_sync_at";33const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3435export async function listPairedDevices(): Promise<PairedDevice[]> {36  try {37    const result = await chrome.storage.local.get(REGISTRY_KEY);38    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];39    if (Array.isArray(devices)) return devices.filter(isPairedDevice);40    return [];41  } catch {42    return [];43  }44}4546export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {47  const devices = await listPairedDevices();48  return devices.find((d) => d.deviceId === deviceId) ?? null;49}5051export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {52  const devices = await listPairedDevices();53  return devices.find((d) =>54    d.longTermPublicKeyHex !== undefined &&55    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)56  ) ?? null;57}5859/**60 * Add or update a device by `deviceId`. On re-pair the new pubkey61 * silently replaces the old one; the deviceId is stable across the62 * other device's reinstall.63 */64export async function upsertPairedDevice(device: PairedDevice): Promise<void> {65  if (!device.deviceId) throw new Error("device.deviceId is required");66  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");67  if (!device.deviceName) throw new Error("device.deviceName is required");68  if (!device.deviceType) throw new Error("device.deviceType is required");6970  const devices = await listPairedDevices();71  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);72  if (idx >= 0) {73    devices[idx] = { ...devices[idx], ...device };74  } else {75    devices.push(device);76  }77  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });78}7980/**81 * Move every existing pair to the user-selected mesh relay.82 *83 * Reconnect prefers the relay stored on each pair over the global fallback, so84 * changing only the setting would leave established devices on the old relay.85 */86export async function setRelayUrlForAllPairedDevices(87  relayUrl: string,88): Promise<void> {89  const devices = await listPairedDevices();90  if (devices.length === 0) return;91  await chrome.storage.local.set({92    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),93  });94}9596/**97 * Removes the device from the LOCAL paired-devices registry only, leaving no98 * record behind. Prefer `revokeDeviceById` for anything the user would call99 * "remove this device" — a plain removal is silently undone the next time any100 * peer vouches the device back through a manifest.101 *102 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from103 * syncing with OTHER peers in the device graph, and the device RETAINS104 * its copy of the vault root key (syncMasterKey) — it can still decrypt105 * anything it already holds and anything other peers send it. For a106 * lost or stolen device, removal is insufficient: use "Rotate sync107 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so108 * the old device's key material is useless.109 */110export async function removePairedDeviceById(deviceId: string): Promise<void> {111  const devices = await listPairedDevices();112  const filtered = devices.filter((d) => d.deviceId !== deviceId);113  // Unpairing is local-only — there is no remote unpair RPC. The remote114  // device retains this device in its own registry. Noise KK rejection on115  // the next connection attempt is the security boundary.116  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });117  // Also clear the per-network IP cache for this device.118  const device = devices.find((d) => d.deviceId === deviceId);119  if (device?.longTermPublicKeyHex) {120    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);121  }122}123124// ---------------------------------------------------------------------------125// Device revocation (Part 5 §C) — a removal that propagates and stays removed.126// ---------------------------------------------------------------------------127128export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {129  try {130    const result = await chrome.storage.local.get(REVOCATIONS_KEY);131    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];132    if (Array.isArray(value)) return value.filter(isDeviceRevocation);133    return [];134  } catch {135    return [];136  }137}138139/**140 * True when either the id or the long-term key is under a terminal revocation.141 *142 * `deviceId` is a self-asserted string in a manifest, so keying terminality on143 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.144 * The key is the thing a revoked device actually still holds, so it is the145 * thing terminality has to follow.146 */147export async function isDeviceRevoked(148  deviceId: string,149  longTermPublicKeyHex?: string,150): Promise<boolean> {151  // The all-zero sentinel stands for "we never stored a key for this device"152  // (see revokeDeviceById) and must not match a real one.153  const key = longTermPublicKeyHex?.toLowerCase();154  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);155  return (await listDeviceRevocations()).some(156    (r) =>157      r.deviceId === deviceId ||158      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),159  );160}161162/**163 * Revoke a device: record it locally, then drop it from the registry. The164 * record rides out in every manifest this device signs from now on, and blocks165 * the device being vouched back in. A stale entry may survive in the166 * pending-vouch queue; the confirm handler re-checks revocation before167 * promoting anything out of it.168 */169export async function revokeDeviceById(170  deviceId: string,171  revokedByDeviceId: string,172  options: { now?: number } = {},173): Promise<DeviceRevocation | null> {174  const device = await findPairedDeviceById(deviceId);175  const existing = await listDeviceRevocations();176  if (existing.some((r) => r.deviceId === deviceId)) return null;177178  const record: DeviceRevocation = {179    deviceId,180    // A device with no stored key was never usable as a sync peer; the181    // all-zero key keeps the record well-formed on the wire without182    // pretending to name a key we never saw.183    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),184    revokedAt: options.now ?? Date.now(),185    revokedByDeviceId,186  };187  await storeRevocations([...existing, record]);188  await removePairedDeviceById(deviceId);189  return record;190}191192/**193 * Merge revocations learned from a peer's manifest. Returns the deviceIds that194 * were newly revoked, so the caller can evict them.195 *196 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is197 * terminal, so the timestamp is display metadata only and cannot be used to198 * win or lose a race (HOLE 2).199 */200export async function mergeDeviceRevocations(201  incoming: DeviceRevocation[],202): Promise<string[]> {203  const existing = await listDeviceRevocations();204  const known = new Set(existing.map((r) => r.deviceId));205  const fresh = incoming206    .filter((r) => !known.has(r.deviceId))207    // Clamped for display sanity only — a year-9999 revokedAt would render as208    // nonsense. The protection against ordering games is that revocation is209    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).210    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));211  if (fresh.length === 0) return [];212  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));213  return fresh.map((r) => r.deviceId);214}215216/**217 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed218 * re-pair: a physically present user on both devices is a stronger consent219 * signal than whatever created the original pairing. Never call this from a220 * path a remote peer can reach.221 */222export async function clearDeviceRevocation(deviceId: string): Promise<void> {223  const existing = await listDeviceRevocations();224  const filtered = existing.filter((r) => r.deviceId !== deviceId);225  if (filtered.length === existing.length) return;226  await storeRevocations(filtered);227}228229async function storeRevocations(records: DeviceRevocation[]): Promise<void> {230  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });231}232233function isDeviceRevocation(x: unknown): x is DeviceRevocation {234  if (typeof x !== "object" || x === null) return false;235  const r = x as Record<string, unknown>;236  return (237    typeof r.deviceId === "string" &&238    !!r.deviceId &&239    typeof r.longTermPublicKeyHex === "string" &&240    typeof r.revokedAt === "number" &&241    typeof r.revokedByDeviceId === "string"242  );243}244245export async function clearPairedDevices(): Promise<void> {246  await chrome.storage.local.remove(REGISTRY_KEY);247}248249export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {250  const devices = await listPairedDevices();251  const idx = devices.findIndex((d) => d.deviceId === deviceId);252  if (idx < 0) return;253  devices[idx] = { ...devices[idx], lastSyncedAt: when };254  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });255}256257export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {258  try {259    const key = LAST_IP_KEY_PREFIX + pubkeyHex;260    const result = await chrome.storage.local.get(key);261    const value = (result as Record<string, unknown>)[key];262    return typeof value === "string" ? value : null;263  } catch {264    return null;265  }266}267268export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {269  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });270}271272export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {273  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);274}275276export async function getLastSyncAt(): Promise<number | null> {277  try {278    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);279    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];280    if (typeof value === "number") return value;281    if (typeof value === "string" && value !== "") {282      const n = Number.parseInt(value, 10);283      return Number.isFinite(n) ? n : null;284    }285    return null;286  } catch {287    return null;288  }289}290291export async function setLastSyncAt(when: number): Promise<void> {292  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });293}294295export interface SyncResult {296  entriesReceived: number;297  entriesSent: number;298}299300export async function getLastSyncResult(): Promise<SyncResult | null> {301  try {302    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);303    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];304    if (305      value &&306      typeof value === "object" &&307      typeof (value as Record<string, unknown>).entriesReceived === "number" &&308      typeof (value as Record<string, unknown>).entriesSent === "number"309    ) {310      return value as SyncResult;311    }312    return null;313  } catch {314    return null;315  }316}317318export async function setLastSyncResult(result: SyncResult): Promise<void> {319  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });320}321322function isPairedDevice(x: unknown): x is PairedDevice {323  if (typeof x !== "object" || x === null) return false;324  const r = x as Record<string, unknown>;325  if (typeof r.deviceId !== "string" || !r.deviceId) return false;326  if (typeof r.pairedAt !== "number") return false;327  if (328    r.rendezvousSecretHex !== undefined &&329    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))330  ) {331    return false;332  }333  if (r.relayUrl !== undefined) {334    if (typeof r.relayUrl !== "string") return false;335    try {336      if (new URL(r.relayUrl).protocol !== "wss:") return false;337    } catch {338      return false;339    }340  }341  return true;342}