paired-devices.ts

17.9 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 * Durable registry write. PB-26-213: the reusable rendezvous secret must57 * never reach this registry — it lives in the encrypted pair-secret record58 * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a59 * missed writer fails closed to a secretless row instead of plaintext disk.60 */61export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {62  await chrome.storage.local.set({63    [REGISTRY_KEY]: devices.map((device) => {64      if (!isValidSecret(device.rendezvousSecretHex)) return device;65      const scrubbed = { ...device };66      delete scrubbed.rendezvousSecretHex;67      return scrubbed;68    }),69  });70}7172function isValidSecret(value: unknown): value is string {73  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);74}7576/**77 * Add or update a device by `deviceId`. On re-pair the new pubkey78 * silently replaces the old one; the deviceId is stable across the79 * other device's reinstall.80 */81export async function upsertPairedDevice(device: PairedDevice): Promise<void> {82  if (!device.deviceId) throw new Error("device.deviceId is required");83  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");84  if (!device.deviceName) throw new Error("device.deviceName is required");85  if (!device.deviceType) throw new Error("device.deviceType is required");8687  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile88  // peer cannot grow the registry without limit; the local name already89  // obeys the same bound at the Settings write path.90  const stored = { ...device, deviceName: capDeviceName(device.deviceName) };91  const devices = await listPairedDevices();92  const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);93  if (idx >= 0) {94    devices[idx] = { ...devices[idx], ...stored };95  } else {96    devices.push(stored);97  }98  await writePairedDevices(devices);99  // The device-list-changed sync trigger fires from the messages.ts handlers100  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they101  // own the post-mutation contract and have the full payload available. Firing102  // here would race with the test setup pattern (setSyncRunner AFTER upsert).103}104105export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {106  const alias = name.trim();107  if (!alias) throw new Error("device name must not be empty");108  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");109  const devices = await listPairedDevices();110  const index = devices.findIndex((device) => device.deviceId === deviceId);111  if (index < 0) throw new Error("device_not_found");112  devices[index] = { ...devices[index], localAlias: alias };113  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });114}115116/**117 * Move every existing pair to the user-selected mesh relay.118 *119 * Reconnect prefers the relay stored on each pair over the global fallback, so120 * changing only the setting would leave established devices on the old relay.121 */122export async function setRelayUrlForAllPairedDevices(123  relayUrl: string,124): Promise<void> {125  const devices = await listPairedDevices();126  if (devices.length === 0) return;127  await chrome.storage.local.set({128    [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),129  });130  // A relay change is mesh state — the next alarm tick or popup open will dial131  // with the new relay. No fire-and-forget trigger here; the in-flight guard132  // would just drop it on the burst of upserts that often follows.133}134135/**136 * Removes the device from the LOCAL paired-devices registry only, leaving no137 * record behind. Prefer `revokeDeviceById` for anything the user would call138 * "remove this device" — a plain removal is silently undone the next time any139 * peer vouches the device back through a manifest.140 *141 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from142 * syncing with OTHER peers in the device graph, and the device RETAINS143 * its copy of the vault root key (syncMasterKey) — it can still decrypt144 * anything it already holds and anything other peers send it. For a145 * lost or stolen device, removal is insufficient: use "Rotate sync146 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so147 * the old device's key material is useless.148 */149export async function removePairedDeviceById(deviceId: string): Promise<void> {150  const devices = await listPairedDevices();151  const filtered = devices.filter((d) => d.deviceId !== deviceId);152  // Unpairing is local-only — there is no remote unpair RPC. The remote153  // device retains this device in its own registry. Noise KK rejection on154  // the next connection attempt is the security boundary.155  await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });156  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE157  // handler — see upsertPairedDevice for why this lives there, not here.158}159160// ---------------------------------------------------------------------------161// Device revocation (Part 5 §C) — a removal that propagates and stays removed.162// ---------------------------------------------------------------------------163164export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {165  try {166    const result = await chrome.storage.local.get(REVOCATIONS_KEY);167    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];168    if (Array.isArray(value)) return value.filter(isDeviceRevocation);169    return [];170  } catch {171    return [];172  }173}174175/**176 * True when either the id or the long-term key is under a terminal revocation.177 *178 * `deviceId` is a self-asserted string in a manifest, so keying terminality on179 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.180 * The key is the thing a revoked device actually still holds, so it is the181 * thing terminality has to follow.182 */183export async function isDeviceRevoked(184  deviceId: string,185  longTermPublicKeyHex?: string,186): Promise<boolean> {187  // The all-zero sentinel stands for "we never stored a key for this device"188  // (see revokeDeviceById) and must not match a real one.189  const key = longTermPublicKeyHex?.toLowerCase();190  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);191  return (await listDeviceRevocations()).some(192    (r) =>193      r.deviceId === deviceId ||194      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),195  );196}197198/**199 * Revoke a device: record it locally, then drop it from the registry. The200 * record rides out in every manifest this device signs from now on, and blocks201 * the device being vouched back in. A stale entry may survive in the202 * pending-vouch queue; the confirm handler re-checks revocation before203 * promoting anything out of it.204 */205export async function revokeDeviceById(206  deviceId: string,207  revokedByDeviceId: string,208  options: { now?: number } = {},209): Promise<DeviceRevocation | null> {210  const device = await findPairedDeviceById(deviceId);211  const existing = await listDeviceRevocations();212  if (existing.some((r) => r.deviceId === deviceId)) {213    // A direct re-pair in older builds could restore the paired row without214    // clearing its terminal revocation. "Remove" then returned early here and215    // left the row visible forever. Idempotency applies to the revocation216    // record, not to removing any stale live row that overlaps it.217    if (device) await removePairedDeviceById(deviceId);218    return null;219  }220221  const record: DeviceRevocation = {222    deviceId,223    // A device with no stored key was never usable as a sync peer; the224    // all-zero key keeps the record well-formed on the wire without225    // pretending to name a key we never saw.226    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),227    revokedAt: options.now ?? Date.now(),228    revokedByDeviceId,229  };230  await storeRevocations([...existing, record]);231  await removePairedDeviceById(deviceId);232  return record;233}234235/**236 * Merge revocations learned from a peer's manifest. Returns the deviceIds that237 * were newly revoked, so the caller can evict them.238 *239 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is240 * terminal, so the timestamp is display metadata only and cannot be used to241 * win or lose a race (HOLE 2).242 */243export async function mergeDeviceRevocations(244  incoming: DeviceRevocation[],245): Promise<string[]> {246  const existing = await listDeviceRevocations();247  const known = new Set(existing.map((r) => r.deviceId));248  const fresh = incoming249    .filter((r) => !known.has(r.deviceId))250    // Clamped for display sanity only — a year-9999 revokedAt would render as251    // nonsense. The protection against ordering games is that revocation is252    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).253    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));254  if (fresh.length === 0) return [];255  await storeRevocations([...existing, ...fresh]);256  return fresh.map((r) => r.deviceId);257}258259/**260 * Repair the invalid paired+revoked overlap produced by older direct-pair261 * handlers.262 *263 * A newer direct, SAS-confirmed pair is the only state allowed to lift the264 * older revocation. Every other overlap resolves in favour of the revocation,265 * including vouched devices and pairs that predate the removal.266 */267export async function repairPairedRevocationOverlaps(): Promise<{268  revocationsCleared: number;269  pairedRowsRemoved: number;270}> {271  const devices = await listPairedDevices();272  const revocations = await listDeviceRevocations();273  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));274  const revocationIdsToClear = new Set<string>();275  const pairedIdsToRemove = new Set<string>();276277  for (const device of devices) {278    const revocation = revocationById.get(device.deviceId);279    if (!revocation) continue;280    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {281      revocationIdsToClear.add(device.deviceId);282    } else {283      pairedIdsToRemove.add(device.deviceId);284    }285  }286287  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {288    return { revocationsCleared: 0, pairedRowsRemoved: 0 };289  }290291  await chrome.storage.local.set({292    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),293    [REVOCATIONS_KEY]: revocations.filter(294      (record) => !revocationIdsToClear.has(record.deviceId),295    ),296  });297  return {298    revocationsCleared: revocationIdsToClear.size,299    pairedRowsRemoved: pairedIdsToRemove.size,300  };301}302303async function storeRevocations(records: DeviceRevocation[]): Promise<void> {304  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });305}306307/**308 * PB-26-110: bound the ledger by evicting from whichever issuer currently holds309 * the most records, oldest first.310 *311 * Evicting globally-oldest-first let one early flood consume the whole cap and312 * permanently starve every other issuer, including genuine revocations, because313 * declining to evict at all is not a bounded option. Largest-holder-first keeps314 * the bound while guaranteeing a flood can only ever displace its own records:315 * once an issuer holds more than anyone else, everything it adds costs it one of316 * its own oldest.317 *318 * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a319 * 500-record cap; switch to a live-count map if the cap ever grows by orders of320 * magnitude.321 */322function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {323  if (records.length <= MAX_REVOCATIONS) return records;324  const byIssuer = new Map<string, DeviceRevocation[]>();325  for (const record of records) {326    const key = record.revokedByDeviceId ?? "";327    const list = byIssuer.get(key);328    if (list) list.push(record);329    else byIssuer.set(key, [record]);330  }331  const dropped = new Set<DeviceRevocation>();332  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {333    let victim: DeviceRevocation[] | undefined;334    for (const list of byIssuer.values()) {335      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);336      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {337        victim = list;338      }339    }340    if (!victim) break;341    const oldest = victim.find((r) => !dropped.has(r));342    if (!oldest) break;343    dropped.add(oldest);344  }345  return records.filter((r) => !dropped.has(r));346}347348function isDeviceRevocation(x: unknown): x is DeviceRevocation {349  if (typeof x !== "object" || x === null) return false;350  const r = x as Record<string, unknown>;351  return (352    typeof r.deviceId === "string" &&353    !!r.deviceId &&354    typeof r.longTermPublicKeyHex === "string" &&355    typeof r.revokedAt === "number" &&356    typeof r.revokedByDeviceId === "string"357  );358}359360export async function clearPairedDevices(): Promise<void> {361  await chrome.storage.local.remove(REGISTRY_KEY);362}363364export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {365  const devices = await listPairedDevices();366  const idx = devices.findIndex((d) => d.deviceId === deviceId);367  if (idx < 0) return;368  devices[idx] = { ...devices[idx], lastSyncedAt: when };369  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });370}371372export async function getLastSyncAt(): Promise<number | null> {373  try {374    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);375    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];376    if (typeof value === "number") return value;377    if (typeof value === "string" && value !== "") {378      const n = Number.parseInt(value, 10);379      return Number.isFinite(n) ? n : null;380    }381    return null;382  } catch {383    return null;384  }385}386387export async function setLastSyncAt(when: number): Promise<void> {388  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });389}390391/**392 * The last sync ATTEMPT, not just the last success. A 3-second flash in the393 * popup is useless for debugging a real phone, so every terminating path394 * writes this and Settings reads it back after the fact — including across an395 * MV3 service-worker restart.396 */397export interface SyncResult {398  outcome: SyncOutcome;399  detail?: string;400  /** When the attempt finished, success or not. */401  at: number;402  entriesReceived: number;403  entriesSent: number;404}405406export async function getLastSyncResult(): Promise<SyncResult | null> {407  try {408    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);409    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];410    if (411      value &&412      typeof value === "object" &&413      typeof (value as Record<string, unknown>).outcome === "string" &&414      typeof (value as Record<string, unknown>).at === "number" &&415      typeof (value as Record<string, unknown>).entriesReceived === "number" &&416      typeof (value as Record<string, unknown>).entriesSent === "number"417    ) {418      return value as SyncResult;419    }420    return null;421  } catch {422    return null;423  }424}425426export async function setLastSyncResult(result: SyncResult): Promise<void> {427  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });428}429430function isPairedDevice(x: unknown): x is PairedDevice {431  if (typeof x !== "object" || x === null) return false;432  const r = x as Record<string, unknown>;433  if (typeof r.deviceId !== "string" || !r.deviceId) return false;434  if (typeof r.pairedAt !== "number") return false;435  if (436    r.localAlias !== undefined &&437    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)438  ) {439    return false;440  }441  if (442    r.rendezvousSecretHex !== undefined &&443    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))444  ) {445    return false;446  }447  if (r.relayUrl !== undefined) {448    if (typeof r.relayUrl !== "string") return false;449    try {450      if (new URL(r.relayUrl).protocol !== "wss:") return false;451    } catch {452      return false;453    }454  }455  return true;456}