paired-devices.ts

13.6 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";26import type { SyncOutcome } from "../shared/sync-outcome";2728const REGISTRY_KEY = "peach_paired_devices_v3";29const REVOCATIONS_KEY = "peach_device_revocations_v1";30/** Bound the list so a hostile peer cannot grow storage without limit. */31const MAX_REVOCATIONS = 500;32const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";33const LAST_SYNC_AT_KEY = "peach_last_sync_at";34const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";3536export async function listPairedDevices(): Promise<PairedDevice[]> {37  try {38    const result = await chrome.storage.local.get(REGISTRY_KEY);39    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];40    if (Array.isArray(devices)) return devices.filter(isPairedDevice);41    return [];42  } catch {43    return [];44  }45}4647export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {48  const devices = await listPairedDevices();49  return devices.find((d) => d.deviceId === deviceId) ?? null;50}5152export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {53  const devices = await listPairedDevices();54  return devices.find((d) =>55    d.longTermPublicKeyHex !== undefined &&56    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)57  ) ?? null;58}5960/**61 * Add or update a device by `deviceId`. On re-pair the new pubkey62 * silently replaces the old one; the deviceId is stable across the63 * other device's reinstall.64 */65export async function upsertPairedDevice(device: PairedDevice): Promise<void> {66  if (!device.deviceId) throw new Error("device.deviceId is required");67  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");68  if (!device.deviceName) throw new Error("device.deviceName is required");69  if (!device.deviceType) throw new Error("device.deviceType is required");7071  const devices = await listPairedDevices();72  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);73  if (idx >= 0) {74    devices[idx] = { ...devices[idx], ...device };75  } else {76    devices.push(device);77  }78  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });79}8081/**82 * Move every existing pair to the user-selected mesh relay.83 *84 * Reconnect prefers the relay stored on each pair over the global fallback, so85 * changing only the setting would leave established devices on the old relay.86 */87export async function setRelayUrlForAllPairedDevices(88  relayUrl: string,89): Promise<void> {90  const devices = await listPairedDevices();91  if (devices.length === 0) return;92  await chrome.storage.local.set({93    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),94  });95}9697/**98 * Removes the device from the LOCAL paired-devices registry only, leaving no99 * record behind. Prefer `revokeDeviceById` for anything the user would call100 * "remove this device" — a plain removal is silently undone the next time any101 * peer vouches the device back through a manifest.102 *103 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from104 * syncing with OTHER peers in the device graph, and the device RETAINS105 * its copy of the vault root key (syncMasterKey) — it can still decrypt106 * anything it already holds and anything other peers send it. For a107 * lost or stolen device, removal is insufficient: use "Rotate sync108 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so109 * the old device's key material is useless.110 */111export async function removePairedDeviceById(deviceId: string): Promise<void> {112  const devices = await listPairedDevices();113  const filtered = devices.filter((d) => d.deviceId !== deviceId);114  // Unpairing is local-only — there is no remote unpair RPC. The remote115  // device retains this device in its own registry. Noise KK rejection on116  // the next connection attempt is the security boundary.117  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });118  // Also clear the per-network IP cache for this device.119  const device = devices.find((d) => d.deviceId === deviceId);120  if (device?.longTermPublicKeyHex) {121    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);122  }123}124125// ---------------------------------------------------------------------------126// Device revocation (Part 5 §C) — a removal that propagates and stays removed.127// ---------------------------------------------------------------------------128129export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {130  try {131    const result = await chrome.storage.local.get(REVOCATIONS_KEY);132    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];133    if (Array.isArray(value)) return value.filter(isDeviceRevocation);134    return [];135  } catch {136    return [];137  }138}139140/**141 * True when either the id or the long-term key is under a terminal revocation.142 *143 * `deviceId` is a self-asserted string in a manifest, so keying terminality on144 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.145 * The key is the thing a revoked device actually still holds, so it is the146 * thing terminality has to follow.147 */148export async function isDeviceRevoked(149  deviceId: string,150  longTermPublicKeyHex?: string,151): Promise<boolean> {152  // The all-zero sentinel stands for "we never stored a key for this device"153  // (see revokeDeviceById) and must not match a real one.154  const key = longTermPublicKeyHex?.toLowerCase();155  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);156  return (await listDeviceRevocations()).some(157    (r) =>158      r.deviceId === deviceId ||159      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),160  );161}162163/**164 * Revoke a device: record it locally, then drop it from the registry. The165 * record rides out in every manifest this device signs from now on, and blocks166 * the device being vouched back in. A stale entry may survive in the167 * pending-vouch queue; the confirm handler re-checks revocation before168 * promoting anything out of it.169 */170export async function revokeDeviceById(171  deviceId: string,172  revokedByDeviceId: string,173  options: { now?: number } = {},174): Promise<DeviceRevocation | null> {175  const device = await findPairedDeviceById(deviceId);176  const existing = await listDeviceRevocations();177  if (existing.some((r) => r.deviceId === deviceId)) return null;178179  const record: DeviceRevocation = {180    deviceId,181    // A device with no stored key was never usable as a sync peer; the182    // all-zero key keeps the record well-formed on the wire without183    // pretending to name a key we never saw.184    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),185    revokedAt: options.now ?? Date.now(),186    revokedByDeviceId,187  };188  await storeRevocations([...existing, record]);189  await removePairedDeviceById(deviceId);190  return record;191}192193/**194 * Merge revocations learned from a peer's manifest. Returns the deviceIds that195 * were newly revoked, so the caller can evict them.196 *197 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is198 * terminal, so the timestamp is display metadata only and cannot be used to199 * win or lose a race (HOLE 2).200 */201export async function mergeDeviceRevocations(202  incoming: DeviceRevocation[],203): Promise<string[]> {204  const existing = await listDeviceRevocations();205  const known = new Set(existing.map((r) => r.deviceId));206  const fresh = incoming207    .filter((r) => !known.has(r.deviceId))208    // Clamped for display sanity only — a year-9999 revokedAt would render as209    // nonsense. The protection against ordering games is that revocation is210    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).211    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));212  if (fresh.length === 0) return [];213  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));214  return fresh.map((r) => r.deviceId);215}216217/**218 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed219 * re-pair: a physically present user on both devices is a stronger consent220 * signal than whatever created the original pairing. Never call this from a221 * path a remote peer can reach.222 */223export async function clearDeviceRevocation(deviceId: string): Promise<void> {224  const existing = await listDeviceRevocations();225  const filtered = existing.filter((r) => r.deviceId !== deviceId);226  if (filtered.length === existing.length) return;227  await storeRevocations(filtered);228}229230async function storeRevocations(records: DeviceRevocation[]): Promise<void> {231  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });232}233234function isDeviceRevocation(x: unknown): x is DeviceRevocation {235  if (typeof x !== "object" || x === null) return false;236  const r = x as Record<string, unknown>;237  return (238    typeof r.deviceId === "string" &&239    !!r.deviceId &&240    typeof r.longTermPublicKeyHex === "string" &&241    typeof r.revokedAt === "number" &&242    typeof r.revokedByDeviceId === "string"243  );244}245246export async function clearPairedDevices(): Promise<void> {247  await chrome.storage.local.remove(REGISTRY_KEY);248}249250export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {251  const devices = await listPairedDevices();252  const idx = devices.findIndex((d) => d.deviceId === deviceId);253  if (idx < 0) return;254  devices[idx] = { ...devices[idx], lastSyncedAt: when };255  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });256}257258export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {259  try {260    const key = LAST_IP_KEY_PREFIX + pubkeyHex;261    const result = await chrome.storage.local.get(key);262    const value = (result as Record<string, unknown>)[key];263    return typeof value === "string" ? value : null;264  } catch {265    return null;266  }267}268269export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {270  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });271}272273export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {274  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);275}276277export async function getLastSyncAt(): Promise<number | null> {278  try {279    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);280    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];281    if (typeof value === "number") return value;282    if (typeof value === "string" && value !== "") {283      const n = Number.parseInt(value, 10);284      return Number.isFinite(n) ? n : null;285    }286    return null;287  } catch {288    return null;289  }290}291292export async function setLastSyncAt(when: number): Promise<void> {293  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });294}295296/**297 * The last sync ATTEMPT, not just the last success. A 3-second flash in the298 * popup is useless for debugging a real phone, so every terminating path299 * writes this and Settings reads it back after the fact — including across an300 * MV3 service-worker restart.301 */302export interface SyncResult {303  outcome: SyncOutcome;304  detail?: string;305  /** When the attempt finished, success or not. */306  at: number;307  entriesReceived: number;308  entriesSent: number;309}310311export async function getLastSyncResult(): Promise<SyncResult | null> {312  try {313    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);314    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];315    if (316      value &&317      typeof value === "object" &&318      typeof (value as Record<string, unknown>).outcome === "string" &&319      typeof (value as Record<string, unknown>).at === "number" &&320      typeof (value as Record<string, unknown>).entriesReceived === "number" &&321      typeof (value as Record<string, unknown>).entriesSent === "number"322    ) {323      return value as SyncResult;324    }325    return null;326  } catch {327    return null;328  }329}330331export async function setLastSyncResult(result: SyncResult): Promise<void> {332  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });333}334335function isPairedDevice(x: unknown): x is PairedDevice {336  if (typeof x !== "object" || x === null) return false;337  const r = x as Record<string, unknown>;338  if (typeof r.deviceId !== "string" || !r.deviceId) return false;339  if (typeof r.pairedAt !== "number") return false;340  if (341    r.rendezvousSecretHex !== undefined &&342    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))343  ) {344    return false;345  }346  if (r.relayUrl !== undefined) {347    if (typeof r.relayUrl !== "string") return false;348    try {349      if (new URL(r.relayUrl).protocol !== "wss:") return false;350    } catch {351      return false;352    }353  }354  return true;355}