paired-devices.ts

26.8 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). Its cryptographic identity keys are10// immutable once persisted; a direct re-pair must use a fresh deviceId.11//12// The registry has only ever lived in `chrome.storage.local` for released13// builds, so no cross-profile evacuation path is needed — the public keys14// never leave the device via a third-party sync back-end. Unpairing is15// local-only; Noise KK rejection on the next connection is the security16// boundary (see removePairedDeviceById).1718import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";19import { constantTimeEqualHex } from "../shared/constant-time";20import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";21import type { SyncOutcome } from "../shared/sync-outcome";2223const REGISTRY_KEY = "peach_paired_devices_v3";24const REVOCATIONS_KEY = "peach_device_revocations_v1";25/** Bound the list so a hostile peer cannot grow storage without limit. */26const MAX_REVOCATIONS = 500;27const LAST_SYNC_AT_KEY = "peach_last_sync_at";28const LAST_SYNC_RESULT_KEY = "peach_last_sync_result";29let deviceMutationTail: Promise<void> = Promise.resolve();3031function enqueueDeviceMutation<T>(mutation: () => Promise<T>): Promise<T> {32  const result = deviceMutationTail.then(mutation, mutation);33  deviceMutationTail = result.then(() => undefined, () => undefined);34  return result;35}3637async function listPairedDevicesRaw(): Promise<PairedDevice[]> {38  try {39    const result = await chrome.storage.local.get(REGISTRY_KEY);40    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];41    if (Array.isArray(devices)) return devices.filter(isPairedDevice);42    return [];43  } catch {44    return [];45  }46}4748export async function listPairedDevices(): Promise<PairedDevice[]> {49  await deviceMutationTail;50  return listPairedDevicesRaw();51}5253export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {54  const devices = await listPairedDevices();55  return devices.find((d) => d.deviceId === deviceId) ?? null;56}5758export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {59  const devices = await listPairedDevices();60  return devices.find((d) =>61    d.longTermPublicKeyHex !== undefined &&62    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex)63  ) ?? null;64}6566/**67 * Durable registry write. PB-26-213: the reusable rendezvous secret must68 * never reach this registry — it lives in the encrypted pair-secret record69 * (see pair-secrets.ts). Any secret on an inbound row is refused here, so a70 * missed writer fails closed to a secretless row instead of plaintext disk.71 */72export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {73  await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));74}7576async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {77  await chrome.storage.local.set({78    [REGISTRY_KEY]: devices.map((device) => {79      if (!isValidSecret(device.rendezvousSecretHex)) return device;80      const scrubbed = { ...device };81      delete scrubbed.rendezvousSecretHex;82      return scrubbed;83    }),84  });85}8687function isValidSecret(value: unknown): value is string {88  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);89}9091/**92 * Add or update a device by `deviceId`. Existing cryptographic identity keys93 * are immutable; metadata-only updates merge into the stored row.94 */95export async function upsertPairedDevice(device: PairedDevice): Promise<void> {96  if (!device.deviceId) throw new Error("device.deviceId is required");97  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");98  if (!device.deviceName) throw new Error("device.deviceName is required");99  if (!device.deviceType) throw new Error("device.deviceType is required");100  await upsertPairedDevices([device]);101}102103export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {104  await enqueueDeviceMutation(async () => {105    const stored = await listPairedDevicesRaw();106    const incomingById = new Map<string, PairedDevice>();107    for (const device of devices) {108      if (!device.deviceId) throw new Error("device.deviceId is required");109      if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");110      if (!device.deviceName) throw new Error("device.deviceName is required");111      if (!device.deviceType) throw new Error("device.deviceType is required");112      const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };113      const previous = incomingById.get(normalized.deviceId);114      if (previous) {115        assertIdentityBinding(previous, normalized);116        throw new Error("device_identity_duplicate");117      }118      incomingById.set(normalized.deviceId, normalized);119      const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);120      if (existing) assertIdentityBinding(existing, normalized);121    }122    for (const device of incomingById.values()) {123      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);124      if (index >= 0) {125        const existing = stored[index];126        const merged = { ...existing, ...device };127        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {128          merged.signingPublicKeyHex = existing.signingPublicKeyHex;129        }130        stored[index] = merged;131      } else {132        stored.push(device);133      }134    }135    await writePairedDevicesRaw(stored);136  });137}138139/** Validate identity bindings without changing the registry. */140export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {141  await enqueueDeviceMutation(async () => {142    const stored = await listPairedDevicesRaw();143    const incomingById = new Map<string, PairedDevice>();144    for (const device of devices) {145      const previous = incomingById.get(device.deviceId);146      if (previous) {147        assertIdentityBinding(previous, device);148        throw new Error("device_identity_duplicate");149      }150      incomingById.set(device.deviceId, device);151      const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);152      if (existing) assertIdentityBinding(existing, device);153    }154  });155}156157function validateAndIndexDevices(158  stored: PairedDevice[],159  devices: PairedDevice[],160): Map<string, PairedDevice> {161  const incomingById = new Map<string, PairedDevice>();162  for (const device of devices) {163    if (!device.deviceId) throw new Error("device.deviceId is required");164    if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");165    if (!device.deviceName) throw new Error("device.deviceName is required");166    if (!device.deviceType) throw new Error("device.deviceType is required");167    const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };168    const previous = incomingById.get(normalized.deviceId);169    if (previous) {170      assertIdentityBinding(previous, normalized);171      throw new Error("device_identity_duplicate");172    }173    incomingById.set(normalized.deviceId, normalized);174    const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);175    if (existing) assertIdentityBinding(existing, normalized);176  }177  return incomingById;178}179180function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {181  if (182    typeof existing.longTermPublicKeyHex === "string" &&183    typeof incoming.longTermPublicKeyHex === "string" &&184    !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)185  ) {186    throw new Error("device_identity_immutable");187  }188  if (189    typeof existing.signingPublicKeyHex === "string" &&190    typeof incoming.signingPublicKeyHex === "string" &&191    !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)192  ) {193    throw new Error("device_identity_immutable");194  }195}196197export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {198  const alias = name.trim();199  if (!alias) throw new Error("device name must not be empty");200  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");201  await enqueueDeviceMutation(async () => {202    const devices = await listPairedDevicesRaw();203    const index = devices.findIndex((device) => device.deviceId === deviceId);204    if (index < 0) throw new Error("device_not_found");205    devices[index] = { ...devices[index], localAlias: alias };206    await chrome.storage.local.set({ [REGISTRY_KEY]: devices });207  });208}209210/**211 * Move every existing pair to the user-selected mesh relay.212 *213 * Reconnect prefers the relay stored on each pair over the global fallback, so214 * changing only the setting would leave established devices on the old relay.215 */216export async function setRelayUrlForAllPairedDevices(217  relayUrl: string,218): Promise<void> {219  await enqueueDeviceMutation(async () => {220    const devices = await listPairedDevicesRaw();221    if (devices.length === 0) return;222    await chrome.storage.local.set({223      [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),224    });225  });226  // A relay change is mesh state — the next alarm tick or popup open will dial227  // with the new relay. No fire-and-forget trigger here; the in-flight guard228  // would just drop it on the burst of upserts that often follows.229}230231/**232 * Removes the device from the LOCAL paired-devices registry only, leaving no233 * record behind. Prefer `revokeDeviceById` for anything the user would call234 * "remove this device" — a plain removal is silently undone the next time any235 * peer vouches the device back through a manifest.236 *237 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from238 * syncing with OTHER peers in the device graph, and the device RETAINS239 * its copy of the vault root key (syncMasterKey) — it can still decrypt240 * anything it already holds and anything other peers send it. For a241 * lost or stolen device, removal is insufficient: use "Rotate sync242 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so243 * the old device's key material is useless.244 */245export async function removePairedDeviceById(deviceId: string): Promise<void> {246  await enqueueDeviceMutation(async () => {247    const devices = await listPairedDevicesRaw();248    await chrome.storage.local.set({249      [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),250    });251  });252  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE253  // handler — see upsertPairedDevice for why this lives there, not here.254}255256// ---------------------------------------------------------------------------257// Device revocation (Part 5 §C) — a removal that propagates and stays removed.258// ---------------------------------------------------------------------------259260async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {261  try {262    const result = await chrome.storage.local.get(REVOCATIONS_KEY);263    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];264    if (Array.isArray(value)) return value.filter(isDeviceRevocation);265    return [];266  } catch {267    return [];268  }269}270271export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {272  await deviceMutationTail;273  return listDeviceRevocationsRaw();274}275276/**277 * True when either the id or the long-term key is under a terminal revocation.278 *279 * `deviceId` is a self-asserted string in a manifest, so keying terminality on280 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.281 * The key is the thing a revoked device actually still holds, so it is the282 * thing terminality has to follow.283 */284export async function isDeviceRevoked(285  deviceId: string,286  longTermPublicKeyHex?: string,287): Promise<boolean> {288  // The all-zero sentinel stands for "we never stored a key for this device"289  // (see revokeDeviceById) and must not match a real one.290  const key = longTermPublicKeyHex?.toLowerCase();291  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);292  await deviceMutationTail;293  return (await listDeviceRevocationsRaw()).some(294    (r) =>295      r.deviceId === deviceId ||296      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),297  );298}299300/**301 * Revoke a device: record it locally, then drop it from the registry. The302 * record rides out in every manifest this device signs from now on, and blocks303 * the device being vouched back in. A stale entry may survive in the304 * pending-vouch queue; the confirm handler re-checks revocation before305 * promoting anything out of it.306 */307export async function revokeDeviceById(308  deviceId: string,309  revokedByDeviceId: string,310  options: { now?: number } = {},311): Promise<DeviceRevocation | null> {312  return enqueueDeviceMutation(async () => {313  const devices = await listPairedDevicesRaw();314  const device = devices.find((candidate) => candidate.deviceId === deviceId);315  const existing = await listDeviceRevocationsRaw();316  if (existing.some((r) => r.deviceId === deviceId)) {317    // A direct re-pair in older builds could restore the paired row without318    // clearing its terminal revocation. "Remove" then returned early here and319    // left the row visible forever. Idempotency applies to the revocation320    // record, not to removing any stale live row that overlaps it.321    if (device) await chrome.storage.local.set({322      [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),323    });324    return null;325  }326327  const record: DeviceRevocation = {328    deviceId,329    // A device with no stored key was never usable as a sync peer; the330    // all-zero key keeps the record well-formed on the wire without331    // pretending to name a key we never saw.332    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),333    revokedAt: options.now ?? Date.now(),334    revokedByDeviceId,335  };336  await chrome.storage.local.set({337    [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),338    [REVOCATIONS_KEY]: capRevocations([...existing, record]),339  });340  return record;341  });342}343344/**345 * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed346 * re-pair — a physically present user on both devices. Never call this from347 * a path a remote peer can reach.348 */349export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {350  if (!device.deviceId) throw new Error("device.deviceId is required");351  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");352  if (!device.deviceName) throw new Error("device.deviceName is required");353  if (!device.deviceType) throw new Error("device.deviceType is required");354  await enqueueDeviceMutation(async () => {355    const devices = await listPairedDevicesRaw();356    const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);357    const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };358    if (stored) assertIdentityBinding(stored, incoming);359    const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);360    if (index >= 0) {361      const merged = { ...devices[index], ...incoming };362      if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {363        merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;364      }365      devices[index] = merged;366    } else {367      devices.push(incoming);368    }369    const revocations = await listDeviceRevocationsRaw();370    const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);371    await chrome.storage.local.set({372      [REGISTRY_KEY]: scrubRegistryRows(devices),373      [REVOCATIONS_KEY]: capRevocations(filtered),374    });375  });376}377378export async function upsertPairedDevicesAndMergeRevocations(379  devices: PairedDevice[],380  incomingRevocations: DeviceRevocation[],381): Promise<string[]> {382  return enqueueDeviceMutation(async () => {383    const stored = await listPairedDevicesRaw();384    const incomingById = validateAndIndexDevices(stored, devices);385    const existingRevocations = await listDeviceRevocationsRaw();386    const knownById = new Set(existingRevocations.map((record) => record.deviceId));387    const fresh = incomingRevocations388      .filter((record) => !knownById.has(record.deviceId))389      .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));390    const allRevocations = [...existingRevocations, ...fresh];391    const revokedIds = new Set(allRevocations.map((record) => record.deviceId));392    for (const device of incomingById.values()) {393      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);394      if (index >= 0) {395        const existing = stored[index];396        const merged = { ...existing, ...device };397        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {398          merged.signingPublicKeyHex = existing.signingPublicKeyHex;399        }400        stored[index] = merged;401      } else {402        stored.push(device);403      }404    }405    const revokedKeys = new Set(406      allRevocations407        .map((record) => record.longTermPublicKeyHex.toLowerCase())408        .filter((key) => key !== "0".repeat(64)),409    );410    const registry = scrubRegistryRows(411      stored.filter(412        (device) =>413          !revokedIds.has(device.deviceId) &&414          !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),415      ),416    );417    await chrome.storage.local.set({418      [REGISTRY_KEY]: registry,419      [REVOCATIONS_KEY]: capRevocations(allRevocations),420    });421    return fresh.map((record) => record.deviceId);422  });423}424425function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {426  return devices.map((entry) => {427    if (!isValidSecret(entry.rendezvousSecretHex)) return entry;428    const scrubbed = { ...entry };429    delete scrubbed.rendezvousSecretHex;430    return scrubbed;431  });432}433434/**435 * True when the incoming identity matches an existing paired device's deviceId436 * OR a terminal revocation record (by deviceId or long-term public key).437 * A fresh pairing ceremony must not reuse terminally revoked identity material.438 */439export async function hasAdoptionIdentityConflict(440  deviceId: string,441  longTermPublicKeyHex: string,442): Promise<boolean> {443  await deviceMutationTail;444  const devices = await listPairedDevicesRaw();445  if (devices.some((d) => d.deviceId === deviceId)) return true;446  const key = longTermPublicKeyHex.toLowerCase();447  return (await listDeviceRevocationsRaw()).some((record) =>448    record.deviceId === deviceId ||449    (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)450  );451}452453/**454 * Merge revocations learned from a peer's manifest. Returns the deviceIds that455 * were newly revoked, so the caller can evict them.456 *457 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is458 * terminal, so the timestamp is display metadata only and cannot be used to459 * win or lose a race (HOLE 2).460 */461export async function mergeDeviceRevocations(462  incoming: DeviceRevocation[],463): Promise<string[]> {464  return enqueueDeviceMutation(async () => {465  const existing = await listDeviceRevocationsRaw();466  const known = new Set(existing.map((r) => r.deviceId));467  const fresh = incoming468    .filter((r) => !known.has(r.deviceId))469    // Clamped for display sanity only — a year-9999 revokedAt would render as470    // nonsense. The protection against ordering games is that revocation is471    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).472    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));473  if (fresh.length === 0) return [];474  await storeRevocationsRaw([...existing, ...fresh]);475  return fresh.map((r) => r.deviceId);476  });477}478479/**480 * Repair the invalid paired+revoked overlap produced by older direct-pair481 * handlers.482 *483 * A newer direct, SAS-confirmed pair is the only state allowed to lift the484 * older revocation. Every other overlap resolves in favour of the revocation,485 * including vouched devices and pairs that predate the removal.486 */487export async function repairPairedRevocationOverlaps(): Promise<{488  revocationsCleared: number;489  pairedRowsRemoved: number;490}> {491  return enqueueDeviceMutation(async () => {492  const devices = await listPairedDevicesRaw();493  const revocations = await listDeviceRevocationsRaw();494  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));495  const revocationIdsToClear = new Set<string>();496  const pairedIdsToRemove = new Set<string>();497498  for (const device of devices) {499    const revocation = revocationById.get(device.deviceId);500    if (!revocation) continue;501    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {502      revocationIdsToClear.add(device.deviceId);503    } else {504      pairedIdsToRemove.add(device.deviceId);505    }506  }507508  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {509    return { revocationsCleared: 0, pairedRowsRemoved: 0 };510  }511512  await chrome.storage.local.set({513    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),514    [REVOCATIONS_KEY]: revocations.filter(515      (record) => !revocationIdsToClear.has(record.deviceId),516    ),517  });518  return {519    revocationsCleared: revocationIdsToClear.size,520    pairedRowsRemoved: pairedIdsToRemove.size,521  };522  });523}524525async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {526  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });527}528529/**530 * PB-26-110: bound the ledger by evicting from whichever issuer currently holds531 * the most records, oldest first.532 *533 * Evicting globally-oldest-first let one early flood consume the whole cap and534 * permanently starve every other issuer, including genuine revocations, because535 * declining to evict at all is not a bounded option. Largest-holder-first keeps536 * the bound while guaranteeing a flood can only ever displace its own records:537 * once an issuer holds more than anyone else, everything it adds costs it one of538 * its own oldest.539 *540 * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a541 * 500-record cap; switch to a live-count map if the cap ever grows by orders of542 * magnitude.543 */544function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {545  if (records.length <= MAX_REVOCATIONS) return records;546  const byIssuer = new Map<string, DeviceRevocation[]>();547  for (const record of records) {548    const key = record.revokedByDeviceId ?? "";549    const list = byIssuer.get(key);550    if (list) list.push(record);551    else byIssuer.set(key, [record]);552  }553  const dropped = new Set<DeviceRevocation>();554  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {555    let victim: DeviceRevocation[] | undefined;556    for (const list of byIssuer.values()) {557      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);558      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {559        victim = list;560      }561    }562    if (!victim) break;563    const oldest = victim.find((r) => !dropped.has(r));564    if (!oldest) break;565    dropped.add(oldest);566  }567  return records.filter((r) => !dropped.has(r));568}569570function isDeviceRevocation(x: unknown): x is DeviceRevocation {571  if (typeof x !== "object" || x === null) return false;572  const r = x as Record<string, unknown>;573  return (574    typeof r.deviceId === "string" &&575    !!r.deviceId &&576    typeof r.longTermPublicKeyHex === "string" &&577    typeof r.revokedAt === "number" &&578    typeof r.revokedByDeviceId === "string"579  );580}581582export async function clearPairedDevices(): Promise<void> {583  await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));584}585586export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {587  await enqueueDeviceMutation(async () => {588  const devices = await listPairedDevicesRaw();589  const idx = devices.findIndex((d) => d.deviceId === deviceId);590  if (idx < 0) return;591  devices[idx] = { ...devices[idx], lastSyncedAt: when };592  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });593  });594}595596export async function getLastSyncAt(): Promise<number | null> {597  try {598    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);599    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];600    if (typeof value === "number") return value;601    if (typeof value === "string" && value !== "") {602      const n = Number.parseInt(value, 10);603      return Number.isFinite(n) ? n : null;604    }605    return null;606  } catch {607    return null;608  }609}610611export async function setLastSyncAt(when: number): Promise<void> {612  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });613}614615/**616 * The last sync ATTEMPT, not just the last success. A 3-second flash in the617 * popup is useless for debugging a real phone, so every terminating path618 * writes this and Settings reads it back after the fact — including across an619 * MV3 service-worker restart.620 */621export interface SyncResult {622  outcome: SyncOutcome;623  detail?: string;624  /** When the attempt finished, success or not. */625  at: number;626  entriesReceived: number;627  entriesSent: number;628}629630export async function getLastSyncResult(): Promise<SyncResult | null> {631  try {632    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);633    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];634    if (635      value &&636      typeof value === "object" &&637      typeof (value as Record<string, unknown>).outcome === "string" &&638      typeof (value as Record<string, unknown>).at === "number" &&639      typeof (value as Record<string, unknown>).entriesReceived === "number" &&640      typeof (value as Record<string, unknown>).entriesSent === "number"641    ) {642      return value as SyncResult;643    }644    return null;645  } catch {646    return null;647  }648}649650export async function setLastSyncResult(result: SyncResult): Promise<void> {651  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });652}653654function isPairedDevice(x: unknown): x is PairedDevice {655  if (typeof x !== "object" || x === null) return false;656  const r = x as Record<string, unknown>;657  if (typeof r.deviceId !== "string" || !r.deviceId) return false;658  if (typeof r.pairedAt !== "number") return false;659  if (660    r.localAlias !== undefined &&661    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)662  ) {663    return false;664  }665  if (666    r.rendezvousSecretHex !== undefined &&667    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))668  ) {669    return false;670  }671  if (r.relayUrl !== undefined) {672    if (typeof r.relayUrl !== "string") return false;673    try {674      if (new URL(r.relayUrl).protocol !== "wss:") return false;675    } catch {676      return false;677    }678  }679  return true;680}