paired-devices.ts

16.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// `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";3031export async function listPairedDevices(): Promise<PairedDevice[]> {32  try {33    const result = await chrome.storage.local.get(REGISTRY_KEY);34    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];35    if (Array.isArray(devices)) return devices.filter(isPairedDevice);36    return [];37  } catch {38    return [];39  }40}4142export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {43  const devices = await listPairedDevices();44  return devices.find((d) => d.deviceId === deviceId) ?? null;45}4647export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {48  const devices = await listPairedDevices();49  return devices.find((d) =>50    d.longTermPublicKeyHex !== undefined &&51    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)52  ) ?? null;53}5455/**56 * Add or update a device by `deviceId`. On re-pair the new pubkey57 * silently replaces the old one; the deviceId is stable across the58 * other device's reinstall.59 */60export async function upsertPairedDevice(device: PairedDevice): Promise<void> {61  if (!device.deviceId) throw new Error("device.deviceId is required");62  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");63  if (!device.deviceName) throw new Error("device.deviceName is required");64  if (!device.deviceType) throw new Error("device.deviceType is required");6566  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile67  // peer cannot grow the registry without limit; the local name already68  // obeys the same bound at the Settings write path.69  const stored = { ...device, deviceName: capDeviceName(device.deviceName) };70  const devices = await listPairedDevices();71  const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);72  if (idx >= 0) {73    devices[idx] = { ...devices[idx], ...stored };74  } else {75    devices.push(stored);76  }77  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });78  // The device-list-changed sync trigger fires from the messages.ts handlers79  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they80  // own the post-mutation contract and have the full payload available. Firing81  // here would race with the test setup pattern (setSyncRunner AFTER upsert).82}8384export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {85  const alias = name.trim();86  if (!alias) throw new Error("device name must not be empty");87  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");88  const devices = await listPairedDevices();89  const index = devices.findIndex((device) => device.deviceId === deviceId);90  if (index < 0) throw new Error("device_not_found");91  devices[index] = { ...devices[index], localAlias: alias };92  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });93}9495/**96 * Move every existing pair to the user-selected mesh relay.97 *98 * Reconnect prefers the relay stored on each pair over the global fallback, so99 * changing only the setting would leave established devices on the old relay.100 */101export async function setRelayUrlForAllPairedDevices(102  relayUrl: string,103): Promise<void> {104  const devices = await listPairedDevices();105  if (devices.length === 0) return;106  await chrome.storage.local.set({107    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),108  });109  // A relay change is mesh state — the next alarm tick or popup open will dial110  // with the new relay. No fire-and-forget trigger here; the in-flight guard111  // would just drop it on the burst of upserts that often follows.112}113114/**115 * Removes the device from the LOCAL paired-devices registry only, leaving no116 * record behind. Prefer `revokeDeviceById` for anything the user would call117 * "remove this device" — a plain removal is silently undone the next time any118 * peer vouches the device back through a manifest.119 *120 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from121 * syncing with OTHER peers in the device graph, and the device RETAINS122 * its copy of the vault root key (syncMasterKey) — it can still decrypt123 * anything it already holds and anything other peers send it. For a124 * lost or stolen device, removal is insufficient: use "Rotate sync125 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so126 * the old device's key material is useless.127 */128export async function removePairedDeviceById(deviceId: string): Promise<void> {129  const devices = await listPairedDevices();130  const filtered = devices.filter((d) => d.deviceId !== deviceId);131  // Unpairing is local-only — there is no remote unpair RPC. The remote132  // device retains this device in its own registry. Noise KK rejection on133  // the next connection attempt is the security boundary.134  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });135  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE136  // handler — see upsertPairedDevice for why this lives there, not here.137}138139// ---------------------------------------------------------------------------140// Device revocation (Part 5 §C) — a removal that propagates and stays removed.141// ---------------------------------------------------------------------------142143export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {144  try {145    const result = await chrome.storage.local.get(REVOCATIONS_KEY);146    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];147    if (Array.isArray(value)) return value.filter(isDeviceRevocation);148    return [];149  } catch {150    return [];151  }152}153154/**155 * True when either the id or the long-term key is under a terminal revocation.156 *157 * `deviceId` is a self-asserted string in a manifest, so keying terminality on158 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.159 * The key is the thing a revoked device actually still holds, so it is the160 * thing terminality has to follow.161 */162export async function isDeviceRevoked(163  deviceId: string,164  longTermPublicKeyHex?: string,165): Promise<boolean> {166  // The all-zero sentinel stands for "we never stored a key for this device"167  // (see revokeDeviceById) and must not match a real one.168  const key = longTermPublicKeyHex?.toLowerCase();169  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);170  return (await listDeviceRevocations()).some(171    (r) =>172      r.deviceId === deviceId ||173      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),174  );175}176177/**178 * Revoke a device: record it locally, then drop it from the registry. The179 * record rides out in every manifest this device signs from now on, and blocks180 * the device being vouched back in. A stale entry may survive in the181 * pending-vouch queue; the confirm handler re-checks revocation before182 * promoting anything out of it.183 */184export async function revokeDeviceById(185  deviceId: string,186  revokedByDeviceId: string,187  options: { now?: number } = {},188): Promise<DeviceRevocation | null> {189  const device = await findPairedDeviceById(deviceId);190  const existing = await listDeviceRevocations();191  if (existing.some((r) => r.deviceId === deviceId)) {192    // A direct re-pair in older builds could restore the paired row without193    // clearing its terminal revocation. "Remove" then returned early here and194    // left the row visible forever. Idempotency applies to the revocation195    // record, not to removing any stale live row that overlaps it.196    if (device) await removePairedDeviceById(deviceId);197    return null;198  }199200  const record: DeviceRevocation = {201    deviceId,202    // A device with no stored key was never usable as a sync peer; the203    // all-zero key keeps the record well-formed on the wire without204    // pretending to name a key we never saw.205    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),206    revokedAt: options.now ?? Date.now(),207    revokedByDeviceId,208  };209  const removeIndex = existing.length >= MAX_REVOCATIONS210    ? (() => {211        const otherIssuer = existing.findIndex((r) => r.revokedByDeviceId !== revokedByDeviceId);212        return otherIssuer >= 0 ? otherIssuer : 0;213      })()214    : -1;215  const retained = removeIndex >= 0216    ? existing.filter((_, index) => index !== removeIndex)217    : existing;218  await storeRevocations([...retained, record]);219  await removePairedDeviceById(deviceId);220  return record;221}222223/**224 * Merge revocations learned from a peer's manifest. Returns the deviceIds that225 * were newly revoked, so the caller can evict them.226 *227 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is228 * terminal, so the timestamp is display metadata only and cannot be used to229 * win or lose a race (HOLE 2).230 */231export async function mergeDeviceRevocations(232  incoming: DeviceRevocation[],233): Promise<string[]> {234  const existing = await listDeviceRevocations();235  const known = new Set(existing.map((r) => r.deviceId));236  const fresh = incoming237    .filter((r) => !known.has(r.deviceId))238    // Clamped for display sanity only — a year-9999 revokedAt would render as239    // nonsense. The protection against ordering games is that revocation is240    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).241    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));242  if (fresh.length === 0) return [];243  // Peer manifests may add records but never evict terminal records already244  // held locally. A full ledger drops only newly learned records; the local245  // revoke path remains responsible for administrative revocations.246  const admitted = fresh.slice(0, Math.max(0, MAX_REVOCATIONS - existing.length));247  if (admitted.length > 0) await storeRevocations([...existing, ...admitted]);248  return admitted.map((r) => r.deviceId);249}250251/**252 * Repair the invalid paired+revoked overlap produced by older direct-pair253 * handlers.254 *255 * A newer direct, SAS-confirmed pair is the only state allowed to lift the256 * older revocation. Every other overlap resolves in favour of the revocation,257 * including vouched devices and pairs that predate the removal.258 */259export async function repairPairedRevocationOverlaps(): Promise<{260  revocationsCleared: number;261  pairedRowsRemoved: number;262}> {263  const devices = await listPairedDevices();264  const revocations = await listDeviceRevocations();265  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));266  const revocationIdsToClear = new Set<string>();267  const pairedIdsToRemove = new Set<string>();268269  for (const device of devices) {270    const revocation = revocationById.get(device.deviceId);271    if (!revocation) continue;272    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {273      revocationIdsToClear.add(device.deviceId);274    } else {275      pairedIdsToRemove.add(device.deviceId);276    }277  }278279  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {280    return { revocationsCleared: 0, pairedRowsRemoved: 0 };281  }282283  await chrome.storage.local.set({284    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),285    [REVOCATIONS_KEY]: revocations.filter(286      (record) => !revocationIdsToClear.has(record.deviceId),287    ),288  });289  return {290    revocationsCleared: revocationIdsToClear.size,291    pairedRowsRemoved: pairedIdsToRemove.size,292  };293}294295async function storeRevocations(records: DeviceRevocation[]): Promise<void> {296  await chrome.storage.local.set({ [REVOCATIONS_KEY]: records });297}298299function isDeviceRevocation(x: unknown): x is DeviceRevocation {300  if (typeof x !== "object" || x === null) return false;301  const r = x as Record<string, unknown>;302  return (303    typeof r.deviceId === "string" &&304    !!r.deviceId &&305    typeof r.longTermPublicKeyHex === "string" &&306    typeof r.revokedAt === "number" &&307    typeof r.revokedByDeviceId === "string"308  );309}310311export async function clearPairedDevices(): Promise<void> {312  await chrome.storage.local.remove(REGISTRY_KEY);313}314315export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {316  const devices = await listPairedDevices();317  const idx = devices.findIndex((d) => d.deviceId === deviceId);318  if (idx < 0) return;319  devices[idx] = { ...devices[idx], lastSyncedAt: when };320  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });321}322323export async function getLastSyncAt(): Promise<number | null> {324  try {325    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);326    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];327    if (typeof value === "number") return value;328    if (typeof value === "string" && value !== "") {329      const n = Number.parseInt(value, 10);330      return Number.isFinite(n) ? n : null;331    }332    return null;333  } catch {334    return null;335  }336}337338export async function setLastSyncAt(when: number): Promise<void> {339  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });340}341342/**343 * The last sync ATTEMPT, not just the last success. A 3-second flash in the344 * popup is useless for debugging a real phone, so every terminating path345 * writes this and Settings reads it back after the fact — including across an346 * MV3 service-worker restart.347 */348export interface SyncResult {349  outcome: SyncOutcome;350  detail?: string;351  /** When the attempt finished, success or not. */352  at: number;353  entriesReceived: number;354  entriesSent: number;355}356357export async function getLastSyncResult(): Promise<SyncResult | null> {358  try {359    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);360    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];361    if (362      value &&363      typeof value === "object" &&364      typeof (value as Record<string, unknown>).outcome === "string" &&365      typeof (value as Record<string, unknown>).at === "number" &&366      typeof (value as Record<string, unknown>).entriesReceived === "number" &&367      typeof (value as Record<string, unknown>).entriesSent === "number"368    ) {369      return value as SyncResult;370    }371    return null;372  } catch {373    return null;374  }375}376377export async function setLastSyncResult(result: SyncResult): Promise<void> {378  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });379}380381function isPairedDevice(x: unknown): x is PairedDevice {382  if (typeof x !== "object" || x === null) return false;383  const r = x as Record<string, unknown>;384  if (typeof r.deviceId !== "string" || !r.deviceId) return false;385  if (typeof r.pairedAt !== "number") return false;386  if (387    r.localAlias !== undefined &&388    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)389  ) {390    return false;391  }392  if (393    r.rendezvousSecretHex !== undefined &&394    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))395  ) {396    return false;397  }398  if (r.relayUrl !== undefined) {399    if (typeof r.relayUrl !== "string") return false;400    try {401      if (new URL(r.relayUrl).protocol !== "wss:") return false;402    } catch {403      return false;404    }405  }406  return true;407}