paired-devices.ts

20.4 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// `deviceId` is the canonical primary key (UUID v4, stable for the9// lifetime of the installation). `longTermPublicKeyHex` is also unique10// per device but rotates if the user reinstalls before re-pairing, so we11// key on deviceId and update the key in place during re-pair.12//13// The registry has only ever lived in `chrome.storage.local` for released14// builds, so no cross-profile evacuation path is needed — the public keys15// never leave the device via a third-party sync back-end. Unpairing is16// local-only; Noise KK rejection on the next connection is the security17// boundary (see removePairedDeviceById).1819import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";20import { constantTimeEqualHex } from "../shared/constant-time";21import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";22import type { SyncOutcome } from "../shared/sync-outcome";2324const REGISTRY_KEY = "peach_paired_devices_v3";25const REVOCATIONS_KEY = "peach_device_revocations_v1";26/** Bound the list so a hostile peer cannot grow storage without limit. */27const MAX_REVOCATIONS = 500;28const LAST_SYNC_AT_KEY = "peach_last_sync_at";29const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";30let deviceMutationTail: Promise<void> = Promise.resolve();3132function enqueueDeviceMutation<T>(mutation: () => Promise<T>): Promise<T> {33  const result = deviceMutationTail.then(mutation, mutation);34  deviceMutationTail = result.then(() => undefined, () => undefined);35  return result;36}3738async function listPairedDevicesRaw(): Promise<PairedDevice[]> {39  try {40    const result = await chrome.storage.local.get(REGISTRY_KEY);41    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];42    if (Array.isArray(devices)) return devices.filter(isPairedDevice);43    return [];44  } catch {45    return [];46  }47}4849export async function listPairedDevices(): Promise<PairedDevice[]> {50  await deviceMutationTail;51  return listPairedDevicesRaw();52}5354export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {55  const devices = await listPairedDevices();56  return devices.find((d) => d.deviceId === deviceId) ?? null;57}5859export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {60  const devices = await listPairedDevices();61  return devices.find((d) =>62    d.longTermPublicKeyHex !== undefined &&63    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)64  ) ?? null;65}6667/**68 * Durable registry write. PB-26-213: the reusable rendezvous secret must69 * never reach this registry — it lives in the encrypted pair-secret record70 * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a71 * missed writer fails closed to a secretless row instead of plaintext disk.72 */73export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {74  await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));75}7677async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {78  await chrome.storage.local.set({79    [REGISTRY_KEY]: devices.map((device) => {80      if (!isValidSecret(device.rendezvousSecretHex)) return device;81      const scrubbed = { ...device };82      delete scrubbed.rendezvousSecretHex;83      return scrubbed;84    }),85  });86}8788function isValidSecret(value: unknown): value is string {89  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);90}9192/**93 * Add or update a device by `deviceId`. On re-pair the new pubkey94 * silently replaces the old one; the deviceId is stable across the95 * other device's reinstall.96 */97export async function upsertPairedDevice(device: PairedDevice): Promise<void> {98  if (!device.deviceId) throw new Error("device.deviceId is required");99  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");100  if (!device.deviceName) throw new Error("device.deviceName is required");101  if (!device.deviceType) throw new Error("device.deviceType is required");102103  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile104  // peer cannot grow the registry without limit; the local name already105  // obeys the same bound at the Settings write path.106  await enqueueDeviceMutation(async () => {107    const stored = { ...device, deviceName: capDeviceName(device.deviceName!) };108    const devices = await listPairedDevicesRaw();109    const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);110    if (idx >= 0) devices[idx] = { ...devices[idx], ...stored };111    else devices.push(stored);112    await writePairedDevicesRaw(devices);113  });114  // The device-list-changed sync trigger fires from the messages.ts handlers115  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they116  // own the post-mutation contract and have the full payload available. Firing117  // here would race with the test setup pattern (setSyncRunner AFTER upsert).118}119120export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {121  const alias = name.trim();122  if (!alias) throw new Error("device name must not be empty");123  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");124  await enqueueDeviceMutation(async () => {125    const devices = await listPairedDevicesRaw();126    const index = devices.findIndex((device) => device.deviceId === deviceId);127    if (index < 0) throw new Error("device_not_found");128    devices[index] = { ...devices[index], localAlias: alias };129    await chrome.storage.local.set({ [REGISTRY_KEY]: devices });130  });131}132133/**134 * Move every existing pair to the user-selected mesh relay.135 *136 * Reconnect prefers the relay stored on each pair over the global fallback, so137 * changing only the setting would leave established devices on the old relay.138 */139export async function setRelayUrlForAllPairedDevices(140  relayUrl: string,141): Promise<void> {142  await enqueueDeviceMutation(async () => {143    const devices = await listPairedDevicesRaw();144    if (devices.length === 0) return;145    await chrome.storage.local.set({146      [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),147    });148  });149  // A relay change is mesh state — the next alarm tick or popup open will dial150  // with the new relay. No fire-and-forget trigger here; the in-flight guard151  // would just drop it on the burst of upserts that often follows.152}153154/**155 * Removes the device from the LOCAL paired-devices registry only, leaving no156 * record behind. Prefer `revokeDeviceById` for anything the user would call157 * "remove this device" — a plain removal is silently undone the next time any158 * peer vouches the device back through a manifest.159 *160 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from161 * syncing with OTHER peers in the device graph, and the device RETAINS162 * its copy of the vault root key (syncMasterKey) — it can still decrypt163 * anything it already holds and anything other peers send it. For a164 * lost or stolen device, removal is insufficient: use "Rotate sync165 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so166 * the old device's key material is useless.167 */168export async function removePairedDeviceById(deviceId: string): Promise<void> {169  await enqueueDeviceMutation(async () => {170    const devices = await listPairedDevicesRaw();171    await chrome.storage.local.set({172      [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),173    });174  });175  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE176  // handler — see upsertPairedDevice for why this lives there, not here.177}178179// ---------------------------------------------------------------------------180// Device revocation (Part 5 §C) — a removal that propagates and stays removed.181// ---------------------------------------------------------------------------182183async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {184  try {185    const result = await chrome.storage.local.get(REVOCATIONS_KEY);186    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];187    if (Array.isArray(value)) return value.filter(isDeviceRevocation);188    return [];189  } catch {190    return [];191  }192}193194export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {195  await deviceMutationTail;196  return listDeviceRevocationsRaw();197}198199/**200 * True when either the id or the long-term key is under a terminal revocation.201 *202 * `deviceId` is a self-asserted string in a manifest, so keying terminality on203 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.204 * The key is the thing a revoked device actually still holds, so it is the205 * thing terminality has to follow.206 */207export async function isDeviceRevoked(208  deviceId: string,209  longTermPublicKeyHex?: string,210): Promise<boolean> {211  // The all-zero sentinel stands for "we never stored a key for this device"212  // (see revokeDeviceById) and must not match a real one.213  const key = longTermPublicKeyHex?.toLowerCase();214  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);215  await deviceMutationTail;216  return (await listDeviceRevocationsRaw()).some(217    (r) =>218      r.deviceId === deviceId ||219      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),220  );221}222223/**224 * Revoke a device: record it locally, then drop it from the registry. The225 * record rides out in every manifest this device signs from now on, and blocks226 * the device being vouched back in. A stale entry may survive in the227 * pending-vouch queue; the confirm handler re-checks revocation before228 * promoting anything out of it.229 */230export async function revokeDeviceById(231  deviceId: string,232  revokedByDeviceId: string,233  options: { now?: number } = {},234): Promise<DeviceRevocation | null> {235  return enqueueDeviceMutation(async () => {236  const devices = await listPairedDevicesRaw();237  const device = devices.find((candidate) => candidate.deviceId === deviceId);238  const existing = await listDeviceRevocationsRaw();239  if (existing.some((r) => r.deviceId === deviceId)) {240    // A direct re-pair in older builds could restore the paired row without241    // clearing its terminal revocation. "Remove" then returned early here and242    // left the row visible forever. Idempotency applies to the revocation243    // record, not to removing any stale live row that overlaps it.244    if (device) await chrome.storage.local.set({245      [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),246    });247    return null;248  }249250  const record: DeviceRevocation = {251    deviceId,252    // A device with no stored key was never usable as a sync peer; the253    // all-zero key keeps the record well-formed on the wire without254    // pretending to name a key we never saw.255    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),256    revokedAt: options.now ?? Date.now(),257    revokedByDeviceId,258  };259  await chrome.storage.local.set({260    [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),261    [REVOCATIONS_KEY]: capRevocations([...existing, record]),262  });263  return record;264  });265}266267/**268 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed269 * re-pair — a physically present user on both devices. Never call this from270 * a path a remote peer can reach.271 */272export async function clearDeviceRevocation(deviceId: string): Promise<void> {273  await enqueueDeviceMutation(async () => {274  const existing = await listDeviceRevocationsRaw();275  const filtered = existing.filter((r) => r.deviceId !== deviceId);276  if (filtered.length !== existing.length) {277    await storeRevocationsRaw(filtered);278  }279  });280}281282/**283 * True when the incoming identity matches an existing paired device's deviceId284 * OR a terminal revocation record (by deviceId or long-term public key).285 * A fresh pairing ceremony must not reuse terminally revoked identity material.286 */287export async function hasAdoptionIdentityConflict(288  deviceId: string,289  longTermPublicKeyHex: string,290): Promise<boolean> {291  await deviceMutationTail;292  const devices = await listPairedDevicesRaw();293  if (devices.some((d) => d.deviceId === deviceId)) return true;294  const key = longTermPublicKeyHex.toLowerCase();295  return (await listDeviceRevocationsRaw()).some((record) =>296    record.deviceId === deviceId ||297    (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)298  );299}300301/**302 * Merge revocations learned from a peer's manifest. Returns the deviceIds that303 * were newly revoked, so the caller can evict them.304 *305 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is306 * terminal, so the timestamp is display metadata only and cannot be used to307 * win or lose a race (HOLE 2).308 */309export async function mergeDeviceRevocations(310  incoming: DeviceRevocation[],311): Promise<string[]> {312  return enqueueDeviceMutation(async () => {313  const existing = await listDeviceRevocationsRaw();314  const known = new Set(existing.map((r) => r.deviceId));315  const fresh = incoming316    .filter((r) => !known.has(r.deviceId))317    // Clamped for display sanity only — a year-9999 revokedAt would render as318    // nonsense. The protection against ordering games is that revocation is319    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).320    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));321  if (fresh.length === 0) return [];322  await storeRevocationsRaw([...existing, ...fresh]);323  return fresh.map((r) => r.deviceId);324  });325}326327/**328 * Repair the invalid paired+revoked overlap produced by older direct-pair329 * handlers.330 *331 * A newer direct, SAS-confirmed pair is the only state allowed to lift the332 * older revocation. Every other overlap resolves in favour of the revocation,333 * including vouched devices and pairs that predate the removal.334 */335export async function repairPairedRevocationOverlaps(): Promise<{336  revocationsCleared: number;337  pairedRowsRemoved: number;338}> {339  return enqueueDeviceMutation(async () => {340  const devices = await listPairedDevicesRaw();341  const revocations = await listDeviceRevocationsRaw();342  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));343  const revocationIdsToClear = new Set<string>();344  const pairedIdsToRemove = new Set<string>();345346  for (const device of devices) {347    const revocation = revocationById.get(device.deviceId);348    if (!revocation) continue;349    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {350      revocationIdsToClear.add(device.deviceId);351    } else {352      pairedIdsToRemove.add(device.deviceId);353    }354  }355356  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {357    return { revocationsCleared: 0, pairedRowsRemoved: 0 };358  }359360  await chrome.storage.local.set({361    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),362    [REVOCATIONS_KEY]: revocations.filter(363      (record) => !revocationIdsToClear.has(record.deviceId),364    ),365  });366  return {367    revocationsCleared: revocationIdsToClear.size,368    pairedRowsRemoved: pairedIdsToRemove.size,369  };370  });371}372373async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {374  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });375}376377/**378 * PB-26-110: bound the ledger by evicting from whichever issuer currently holds379 * the most records, oldest first.380 *381 * Evicting globally-oldest-first let one early flood consume the whole cap and382 * permanently starve every other issuer, including genuine revocations, because383 * declining to evict at all is not a bounded option. Largest-holder-first keeps384 * the bound while guaranteeing a flood can only ever displace its own records:385 * once an issuer holds more than anyone else, everything it adds costs it one of386 * its own oldest.387 *388 * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a389 * 500-record cap; switch to a live-count map if the cap ever grows by orders of390 * magnitude.391 */392function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {393  if (records.length <= MAX_REVOCATIONS) return records;394  const byIssuer = new Map<string, DeviceRevocation[]>();395  for (const record of records) {396    const key = record.revokedByDeviceId ?? "";397    const list = byIssuer.get(key);398    if (list) list.push(record);399    else byIssuer.set(key, [record]);400  }401  const dropped = new Set<DeviceRevocation>();402  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {403    let victim: DeviceRevocation[] | undefined;404    for (const list of byIssuer.values()) {405      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);406      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {407        victim = list;408      }409    }410    if (!victim) break;411    const oldest = victim.find((r) => !dropped.has(r));412    if (!oldest) break;413    dropped.add(oldest);414  }415  return records.filter((r) => !dropped.has(r));416}417418function isDeviceRevocation(x: unknown): x is DeviceRevocation {419  if (typeof x !== "object" || x === null) return false;420  const r = x as Record<string, unknown>;421  return (422    typeof r.deviceId === "string" &&423    !!r.deviceId &&424    typeof r.longTermPublicKeyHex === "string" &&425    typeof r.revokedAt === "number" &&426    typeof r.revokedByDeviceId === "string"427  );428}429430export async function clearPairedDevices(): Promise<void> {431  await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));432}433434export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {435  await enqueueDeviceMutation(async () => {436  const devices = await listPairedDevicesRaw();437  const idx = devices.findIndex((d) => d.deviceId === deviceId);438  if (idx < 0) return;439  devices[idx] = { ...devices[idx], lastSyncedAt: when };440  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });441  });442}443444export async function getLastSyncAt(): Promise<number | null> {445  try {446    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);447    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];448    if (typeof value === "number") return value;449    if (typeof value === "string" && value !== "") {450      const n = Number.parseInt(value, 10);451      return Number.isFinite(n) ? n : null;452    }453    return null;454  } catch {455    return null;456  }457}458459export async function setLastSyncAt(when: number): Promise<void> {460  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });461}462463/**464 * The last sync ATTEMPT, not just the last success. A 3-second flash in the465 * popup is useless for debugging a real phone, so every terminating path466 * writes this and Settings reads it back after the fact — including across an467 * MV3 service-worker restart.468 */469export interface SyncResult {470  outcome: SyncOutcome;471  detail?: string;472  /** When the attempt finished, success or not. */473  at: number;474  entriesReceived: number;475  entriesSent: number;476}477478export async function getLastSyncResult(): Promise<SyncResult | null> {479  try {480    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);481    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];482    if (483      value &&484      typeof value === "object" &&485      typeof (value as Record<string, unknown>).outcome === "string" &&486      typeof (value as Record<string, unknown>).at === "number" &&487      typeof (value as Record<string, unknown>).entriesReceived === "number" &&488      typeof (value as Record<string, unknown>).entriesSent === "number"489    ) {490      return value as SyncResult;491    }492    return null;493  } catch {494    return null;495  }496}497498export async function setLastSyncResult(result: SyncResult): Promise<void> {499  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });500}501502function isPairedDevice(x: unknown): x is PairedDevice {503  if (typeof x !== "object" || x === null) return false;504  const r = x as Record<string, unknown>;505  if (typeof r.deviceId !== "string" || !r.deviceId) return false;506  if (typeof r.pairedAt !== "number") return false;507  if (508    r.localAlias !== undefined &&509    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)510  ) {511    return false;512  }513  if (514    r.rendezvousSecretHex !== undefined &&515    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))516  ) {517    return false;518  }519  if (r.relayUrl !== undefined) {520    if (typeof r.relayUrl !== "string") return false;521    try {522      if (new URL(r.relayUrl).protocol !== "wss:") return false;523    } catch {524      return false;525    }526  }527  return true;528}