paired-devices.ts

17.2 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 { capDeviceName, 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  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile72  // peer cannot grow the registry without limit; the local name already73  // obeys the same bound at the Settings write path.74  const stored = { ...device, deviceName: capDeviceName(device.deviceName) };75  const devices = await listPairedDevices();76  const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);77  if (idx >= 0) {78    devices[idx] = { ...devices[idx], ...stored };79  } else {80    devices.push(stored);81  }82  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });83  // The device-list-changed sync trigger fires from the messages.ts handlers84  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they85  // own the post-mutation contract and have the full payload available. Firing86  // here would race with the test setup pattern (setSyncRunner AFTER upsert).87}8889export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {90  const alias = name.trim();91  if (!alias) throw new Error("device name must not be empty");92  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");93  const devices = await listPairedDevices();94  const index = devices.findIndex((device) => device.deviceId === deviceId);95  if (index < 0) throw new Error("device_not_found");96  devices[index] = { ...devices[index], localAlias: alias };97  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });98}99100/**101 * Move every existing pair to the user-selected mesh relay.102 *103 * Reconnect prefers the relay stored on each pair over the global fallback, so104 * changing only the setting would leave established devices on the old relay.105 */106export async function setRelayUrlForAllPairedDevices(107  relayUrl: string,108): Promise<void> {109  const devices = await listPairedDevices();110  if (devices.length === 0) return;111  await chrome.storage.local.set({112    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),113  });114  // A relay change is mesh state — the next alarm tick or popup open will dial115  // with the new relay. No fire-and-forget trigger here; the in-flight guard116  // would just drop it on the burst of upserts that often follows.117}118119/**120 * Removes the device from the LOCAL paired-devices registry only, leaving no121 * record behind. Prefer `revokeDeviceById` for anything the user would call122 * "remove this device" — a plain removal is silently undone the next time any123 * peer vouches the device back through a manifest.124 *125 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from126 * syncing with OTHER peers in the device graph, and the device RETAINS127 * its copy of the vault root key (syncMasterKey) — it can still decrypt128 * anything it already holds and anything other peers send it. For a129 * lost or stolen device, removal is insufficient: use "Rotate sync130 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so131 * the old device's key material is useless.132 */133export async function removePairedDeviceById(deviceId: string): Promise<void> {134  const devices = await listPairedDevices();135  const filtered = devices.filter((d) => d.deviceId !== deviceId);136  // Unpairing is local-only — there is no remote unpair RPC. The remote137  // device retains this device in its own registry. Noise KK rejection on138  // the next connection attempt is the security boundary.139  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });140  // Also clear the per-network IP cache for this device.141  const device = devices.find((d) => d.deviceId === deviceId);142  if (device?.longTermPublicKeyHex) {143    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);144  }145  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE146  // handler — see upsertPairedDevice for why this lives there, not here.147}148149// ---------------------------------------------------------------------------150// Device revocation (Part 5 §C) — a removal that propagates and stays removed.151// ---------------------------------------------------------------------------152153export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {154  try {155    const result = await chrome.storage.local.get(REVOCATIONS_KEY);156    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];157    if (Array.isArray(value)) return value.filter(isDeviceRevocation);158    return [];159  } catch {160    return [];161  }162}163164/**165 * True when either the id or the long-term key is under a terminal revocation.166 *167 * `deviceId` is a self-asserted string in a manifest, so keying terminality on168 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.169 * The key is the thing a revoked device actually still holds, so it is the170 * thing terminality has to follow.171 */172export async function isDeviceRevoked(173  deviceId: string,174  longTermPublicKeyHex?: string,175): Promise<boolean> {176  // The all-zero sentinel stands for "we never stored a key for this device"177  // (see revokeDeviceById) and must not match a real one.178  const key = longTermPublicKeyHex?.toLowerCase();179  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);180  return (await listDeviceRevocations()).some(181    (r) =>182      r.deviceId === deviceId ||183      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),184  );185}186187/**188 * Revoke a device: record it locally, then drop it from the registry. The189 * record rides out in every manifest this device signs from now on, and blocks190 * the device being vouched back in. A stale entry may survive in the191 * pending-vouch queue; the confirm handler re-checks revocation before192 * promoting anything out of it.193 */194export async function revokeDeviceById(195  deviceId: string,196  revokedByDeviceId: string,197  options: { now?: number } = {},198): Promise<DeviceRevocation | null> {199  const device = await findPairedDeviceById(deviceId);200  const existing = await listDeviceRevocations();201  if (existing.some((r) => r.deviceId === deviceId)) {202    // A direct re-pair in older builds could restore the paired row without203    // clearing its terminal revocation. "Remove" then returned early here and204    // left the row visible forever. Idempotency applies to the revocation205    // record, not to removing any stale live row that overlaps it.206    if (device) await removePairedDeviceById(deviceId);207    return null;208  }209210  const record: DeviceRevocation = {211    deviceId,212    // A device with no stored key was never usable as a sync peer; the213    // all-zero key keeps the record well-formed on the wire without214    // pretending to name a key we never saw.215    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),216    revokedAt: options.now ?? Date.now(),217    revokedByDeviceId,218  };219  await storeRevocations([...existing, record]);220  await removePairedDeviceById(deviceId);221  return record;222}223224/**225 * Merge revocations learned from a peer's manifest. Returns the deviceIds that226 * were newly revoked, so the caller can evict them.227 *228 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is229 * terminal, so the timestamp is display metadata only and cannot be used to230 * win or lose a race (HOLE 2).231 */232export async function mergeDeviceRevocations(233  incoming: DeviceRevocation[],234): Promise<string[]> {235  const existing = await listDeviceRevocations();236  const known = new Set(existing.map((r) => r.deviceId));237  const fresh = incoming238    .filter((r) => !known.has(r.deviceId))239    // Clamped for display sanity only — a year-9999 revokedAt would render as240    // nonsense. The protection against ordering games is that revocation is241    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).242    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));243  if (fresh.length === 0) return [];244  await storeRevocations([...existing, ...fresh].slice(-MAX_REVOCATIONS));245  return fresh.map((r) => r.deviceId);246}247248/**249 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed250 * re-pair: a physically present user on both devices is a stronger consent251 * signal than whatever created the original pairing. Never call this from a252 * path a remote peer can reach.253 */254export async function clearDeviceRevocation(deviceId: string): Promise<void> {255  const existing = await listDeviceRevocations();256  const filtered = existing.filter((r) => r.deviceId !== deviceId);257  if (filtered.length === existing.length) return;258  await storeRevocations(filtered);259}260261/**262 * Repair the invalid paired+revoked overlap produced by older direct-pair263 * handlers.264 *265 * A newer direct, SAS-confirmed pair is the only state allowed to lift the266 * older revocation. Every other overlap resolves in favour of the revocation,267 * including vouched devices and pairs that predate the removal.268 */269export async function repairPairedRevocationOverlaps(): Promise<{270  revocationsCleared: number;271  pairedRowsRemoved: number;272}> {273  const devices = await listPairedDevices();274  const revocations = await listDeviceRevocations();275  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));276  const revocationIdsToClear = new Set<string>();277  const pairedIdsToRemove = new Set<string>();278279  for (const device of devices) {280    const revocation = revocationById.get(device.deviceId);281    if (!revocation) continue;282    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {283      revocationIdsToClear.add(device.deviceId);284    } else {285      pairedIdsToRemove.add(device.deviceId);286    }287  }288289  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {290    return { revocationsCleared: 0, pairedRowsRemoved: 0 };291  }292293  await chrome.storage.local.set({294    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),295    [REVOCATIONS_KEY]: revocations.filter(296      (record) => !revocationIdsToClear.has(record.deviceId),297    ),298  });299  return {300    revocationsCleared: revocationIdsToClear.size,301    pairedRowsRemoved: pairedIdsToRemove.size,302  };303}304305async function storeRevocations(records: DeviceRevocation[]): Promise<void> {306  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });307}308309function isDeviceRevocation(x: unknown): x is DeviceRevocation {310  if (typeof x !== "object" || x === null) return false;311  const r = x as Record<string, unknown>;312  return (313    typeof r.deviceId === "string" &&314    !!r.deviceId &&315    typeof r.longTermPublicKeyHex === "string" &&316    typeof r.revokedAt === "number" &&317    typeof r.revokedByDeviceId === "string"318  );319}320321export async function clearPairedDevices(): Promise<void> {322  await chrome.storage.local.remove(REGISTRY_KEY);323}324325export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {326  const devices = await listPairedDevices();327  const idx = devices.findIndex((d) => d.deviceId === deviceId);328  if (idx < 0) return;329  devices[idx] = { ...devices[idx], lastSyncedAt: when };330  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });331}332333export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {334  try {335    const key = LAST_IP_KEY_PREFIX + pubkeyHex;336    const result = await chrome.storage.local.get(key);337    const value = (result as Record<string, unknown>)[key];338    return typeof value === "string" ? value : null;339  } catch {340    return null;341  }342}343344export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {345  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });346}347348export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {349  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);350}351352export async function getLastSyncAt(): Promise<number | null> {353  try {354    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);355    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];356    if (typeof value === "number") return value;357    if (typeof value === "string" && value !== "") {358      const n = Number.parseInt(value, 10);359      return Number.isFinite(n) ? n : null;360    }361    return null;362  } catch {363    return null;364  }365}366367export async function setLastSyncAt(when: number): Promise<void> {368  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });369}370371/**372 * The last sync ATTEMPT, not just the last success. A 3-second flash in the373 * popup is useless for debugging a real phone, so every terminating path374 * writes this and Settings reads it back after the fact — including across an375 * MV3 service-worker restart.376 */377export interface SyncResult {378  outcome: SyncOutcome;379  detail?: string;380  /** When the attempt finished, success or not. */381  at: number;382  entriesReceived: number;383  entriesSent: number;384}385386export async function getLastSyncResult(): Promise<SyncResult | null> {387  try {388    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);389    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];390    if (391      value &&392      typeof value === "object" &&393      typeof (value as Record<string, unknown>).outcome === "string" &&394      typeof (value as Record<string, unknown>).at === "number" &&395      typeof (value as Record<string, unknown>).entriesReceived === "number" &&396      typeof (value as Record<string, unknown>).entriesSent === "number"397    ) {398      return value as SyncResult;399    }400    return null;401  } catch {402    return null;403  }404}405406export async function setLastSyncResult(result: SyncResult): Promise<void> {407  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });408}409410function isPairedDevice(x: unknown): x is PairedDevice {411  if (typeof x !== "object" || x === null) return false;412  const r = x as Record<string, unknown>;413  if (typeof r.deviceId !== "string" || !r.deviceId) return false;414  if (typeof r.pairedAt !== "number") return false;415  if (416    r.localAlias !== undefined &&417    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)418  ) {419    return false;420  }421  if (422    r.rendezvousSecretHex !== undefined &&423    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))424  ) {425    return false;426  }427  if (r.relayUrl !== undefined) {428    if (typeof r.relayUrl !== "string") return false;429    try {430      if (new URL(r.relayUrl).protocol !== "wss:") return false;431    } catch {432      return false;433    }434  }435  return true;436}