paired-devices.ts

28.0 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      const normalized = withSigningKeyHistory(device);80      if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized;81      const scrubbed = { ...normalized };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`. Existing cryptographic identity keys94 * are immutable; metadata-only updates merge into the stored row.95 */96export async function upsertPairedDevice(device: PairedDevice): Promise<void> {97  if (!device.deviceId) throw new Error("device.deviceId is required");98  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");99  if (!device.deviceName) throw new Error("device.deviceName is required");100  if (!device.deviceType) throw new Error("device.deviceType is required");101  await upsertPairedDevices([device]);102}103104export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {105  await enqueueDeviceMutation(async () => {106    const stored = await listPairedDevicesRaw();107    const incomingById = new Map<string, PairedDevice>();108    for (const device of devices) {109      if (!device.deviceId) throw new Error("device.deviceId is required");110      if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");111      if (!device.deviceName) throw new Error("device.deviceName is required");112      if (!device.deviceType) throw new Error("device.deviceType is required");113      const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };114      const previous = incomingById.get(normalized.deviceId);115      if (previous) {116        assertIdentityBinding(previous, normalized);117        throw new Error("device_identity_duplicate");118      }119      incomingById.set(normalized.deviceId, normalized);120      const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);121      if (existing) assertIdentityBinding(existing, normalized);122    }123    for (const device of incomingById.values()) {124      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);125      if (index >= 0) {126        const existing = stored[index];127        const merged = { ...existing, ...device };128        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {129          merged.signingPublicKeyHex = existing.signingPublicKeyHex;130        }131        stored[index] = merged;132      } else {133        stored.push(device);134      }135    }136    await writePairedDevicesRaw(stored);137  });138}139140/** Validate identity bindings without changing the registry. */141export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {142  await enqueueDeviceMutation(async () => {143    const stored = await listPairedDevicesRaw();144    const incomingById = new Map<string, PairedDevice>();145    for (const device of devices) {146      const previous = incomingById.get(device.deviceId);147      if (previous) {148        assertIdentityBinding(previous, device);149        throw new Error("device_identity_duplicate");150      }151      incomingById.set(device.deviceId, device);152      const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);153      if (existing) assertIdentityBinding(existing, device);154    }155  });156}157158function validateAndIndexDevices(159  stored: PairedDevice[],160  devices: PairedDevice[],161): Map<string, PairedDevice> {162  const incomingById = new Map<string, PairedDevice>();163  for (const device of devices) {164    if (!device.deviceId) throw new Error("device.deviceId is required");165    if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");166    if (!device.deviceName) throw new Error("device.deviceName is required");167    if (!device.deviceType) throw new Error("device.deviceType is required");168    const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };169    const previous = incomingById.get(normalized.deviceId);170    if (previous) {171      assertIdentityBinding(previous, normalized);172      throw new Error("device_identity_duplicate");173    }174    incomingById.set(normalized.deviceId, normalized);175    const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);176    if (existing) assertIdentityBinding(existing, normalized);177  }178  return incomingById;179}180181function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {182  if (183    typeof existing.longTermPublicKeyHex === "string" &&184    typeof incoming.longTermPublicKeyHex === "string" &&185    !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)186  ) {187    throw new Error("device_identity_immutable");188  }189  if (190    typeof existing.signingPublicKeyHex === "string" &&191    typeof incoming.signingPublicKeyHex === "string" &&192    !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)193  ) {194    throw new Error("device_identity_immutable");195  }196}197function withSigningKeyHistory(device: PairedDevice): PairedDevice {198  if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device;199  const current = device.signingPublicKeyHex.toLowerCase();200  const history = device.signingKeyHistory201    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))202    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));203  return { ...device, signingKeyHistory: [...history.filter((key) => key.publicKeyHex !== current), { version: history.find((key) => key.publicKeyHex === current)?.version ?? history.reduce((max, key) => Math.max(max, key.version), 0) + 1, publicKeyHex: current }] };204}205206export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {207  const alias = name.trim();208  if (!alias) throw new Error("device name must not be empty");209  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");210  await enqueueDeviceMutation(async () => {211    const devices = await listPairedDevicesRaw();212    const index = devices.findIndex((device) => device.deviceId === deviceId);213    if (index < 0) throw new Error("device_not_found");214    devices[index] = { ...devices[index], localAlias: alias };215    await chrome.storage.local.set({ [REGISTRY_KEY]: devices });216  });217}218219/**220 * Move every existing pair to the user-selected mesh relay.221 *222 * Reconnect prefers the relay stored on each pair over the global fallback, so223 * changing only the setting would leave established devices on the old relay.224 */225export async function setRelayUrlForAllPairedDevices(226  relayUrl: string,227): Promise<void> {228  await enqueueDeviceMutation(async () => {229    const devices = await listPairedDevicesRaw();230    if (devices.length === 0) return;231    await chrome.storage.local.set({232      [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),233    });234  });235  // A relay change is mesh state — the next alarm tick or popup open will dial236  // with the new relay. No fire-and-forget trigger here; the in-flight guard237  // would just drop it on the burst of upserts that often follows.238}239240/**241 * Removes the device from the LOCAL paired-devices registry only, leaving no242 * record behind. Prefer `revokeDeviceById` for anything the user would call243 * "remove this device" — a plain removal is silently undone the next time any244 * peer vouches the device back through a manifest.245 *246 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from247 * syncing with OTHER peers in the device graph, and the device RETAINS248 * its copy of the vault root key (syncMasterKey) — it can still decrypt249 * anything it already holds and anything other peers send it. For a250 * lost or stolen device, removal is insufficient: use "Rotate sync251 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so252 * the old device's key material is useless.253 */254export async function removePairedDeviceById(deviceId: string): Promise<void> {255  await enqueueDeviceMutation(async () => {256    const devices = await listPairedDevicesRaw();257    await chrome.storage.local.set({258      [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),259    });260  });261  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE262  // handler — see upsertPairedDevice for why this lives there, not here.263}264265// ---------------------------------------------------------------------------266// Device revocation (Part 5 §C) — a removal that propagates and stays removed.267// ---------------------------------------------------------------------------268269async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {270  try {271    const result = await chrome.storage.local.get(REVOCATIONS_KEY);272    const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];273    if (Array.isArray(value)) return value.filter(isDeviceRevocation);274    return [];275  } catch {276    return [];277  }278}279280export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {281  await deviceMutationTail;282  return listDeviceRevocationsRaw();283}284285/**286 * True when either the id or the long-term key is under a terminal revocation.287 *288 * `deviceId` is a self-asserted string in a manifest, so keying terminality on289 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.290 * The key is the thing a revoked device actually still holds, so it is the291 * thing terminality has to follow.292 */293export async function isDeviceRevoked(294  deviceId: string,295  longTermPublicKeyHex?: string,296): Promise<boolean> {297  // The all-zero sentinel stands for "we never stored a key for this device"298  // (see revokeDeviceById) and must not match a real one.299  const key = longTermPublicKeyHex?.toLowerCase();300  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);301  await deviceMutationTail;302  return (await listDeviceRevocationsRaw()).some(303    (r) =>304      r.deviceId === deviceId ||305      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),306  );307}308309/**310 * Revoke a device: record it locally, then drop it from the registry. The311 * record rides out in every manifest this device signs from now on, and blocks312 * the device being vouched back in. A stale entry may survive in the313 * pending-vouch queue; the confirm handler re-checks revocation before314 * promoting anything out of it.315 */316export async function revokeDeviceById(317  deviceId: string,318  revokedByDeviceId: string,319  options: { now?: number } = {},320): Promise<DeviceRevocation | null> {321  return enqueueDeviceMutation(async () => {322  const devices = await listPairedDevicesRaw();323  const device = devices.find((candidate) => candidate.deviceId === deviceId);324  const existing = await listDeviceRevocationsRaw();325  if (existing.some((r) => r.deviceId === deviceId)) {326    // A direct re-pair in older builds could restore the paired row without327    // clearing its terminal revocation. "Remove" then returned early here and328    // left the row visible forever. Idempotency applies to the revocation329    // record, not to removing any stale live row that overlaps it.330    if (device) await chrome.storage.local.set({331      [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),332    });333    return null;334  }335336  const record: DeviceRevocation = {337    deviceId,338    // A device with no stored key was never usable as a sync peer; the339    // all-zero key keeps the record well-formed on the wire without340    // pretending to name a key we never saw.341    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),342    revokedAt: options.now ?? Date.now(),343    revokedByDeviceId,344  };345  await chrome.storage.local.set({346    [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),347    [REVOCATIONS_KEY]: capRevocations([...existing, record]),348  });349  return record;350  });351}352353/**354 * Clear a revocation after an authenticated, SAS-confirmed direct re-pair.355 * A changed signing key is accepted only through this seam and the old key356 * remains in immutable history.357 */358export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {359  if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) {360    throw new Error("invalid paired device");361  }362  await enqueueDeviceMutation(async () => {363    const devices = await listPairedDevicesRaw();364    const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);365    const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };366    if (stored) {367      if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex &&368          !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) {369        throw new Error("device_identity_immutable");370      }371      if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex &&372          !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) {373        incoming.signingKeyHistory = [374          ...(stored.signingKeyHistory ?? []),375          { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1,376            publicKeyHex: stored.signingPublicKeyHex.toLowerCase() },377        ];378      }379    }380    const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);381    if (index >= 0) {382      const merged = { ...devices[index], ...incoming };383      if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {384        merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;385      }386      devices[index] = merged;387    } else {388      devices.push(incoming);389    }390    const revocations = await listDeviceRevocationsRaw();391    const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);392    await chrome.storage.local.set({393      [REGISTRY_KEY]: scrubRegistryRows(devices),394      [REVOCATIONS_KEY]: capRevocations(filtered),395    });396  });397}398399export async function upsertPairedDevicesAndMergeRevocations(400  devices: PairedDevice[],401  incomingRevocations: DeviceRevocation[],402): Promise<string[]> {403  return enqueueDeviceMutation(async () => {404    const stored = await listPairedDevicesRaw();405    const incomingById = validateAndIndexDevices(stored, devices);406    const existingRevocations = await listDeviceRevocationsRaw();407    const knownById = new Set(existingRevocations.map((record) => record.deviceId));408    const fresh = incomingRevocations409      .filter((record) => !knownById.has(record.deviceId))410      .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));411    const allRevocations = [...existingRevocations, ...fresh];412    const revokedIds = new Set(allRevocations.map((record) => record.deviceId));413    for (const device of incomingById.values()) {414      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);415      if (index >= 0) {416        const existing = stored[index];417        const merged = { ...existing, ...device };418        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {419          merged.signingPublicKeyHex = existing.signingPublicKeyHex;420        }421        stored[index] = merged;422      } else {423        stored.push(device);424      }425    }426    const revokedKeys = new Set(427      allRevocations428        .map((record) => record.longTermPublicKeyHex.toLowerCase())429        .filter((key) => key !== "0".repeat(64)),430    );431    const registry = scrubRegistryRows(432      stored.filter(433        (device) =>434          !revokedIds.has(device.deviceId) &&435          !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),436      ),437    );438    await chrome.storage.local.set({439      [REGISTRY_KEY]: registry,440      [REVOCATIONS_KEY]: capRevocations(allRevocations),441    });442    return fresh.map((record) => record.deviceId);443  });444}445446function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {447  return devices.map((entry) => {448    if (!isValidSecret(entry.rendezvousSecretHex)) return entry;449    const scrubbed = { ...entry };450    delete scrubbed.rendezvousSecretHex;451    return scrubbed;452  });453}454455/**456 * True when the incoming identity matches an existing paired device's deviceId457 * OR a terminal revocation record (by deviceId or long-term public key).458 * A fresh pairing ceremony must not reuse terminally revoked identity material.459 */460export async function hasAdoptionIdentityConflict(461  deviceId: string,462  longTermPublicKeyHex: string,463): Promise<boolean> {464  await deviceMutationTail;465  const devices = await listPairedDevicesRaw();466  if (devices.some((d) => d.deviceId === deviceId)) return true;467  const key = longTermPublicKeyHex.toLowerCase();468  return (await listDeviceRevocationsRaw()).some((record) =>469    record.deviceId === deviceId ||470    (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)471  );472}473474/**475 * Merge revocations learned from a peer's manifest. Returns the deviceIds that476 * were newly revoked, so the caller can evict them.477 *478 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is479 * terminal, so the timestamp is display metadata only and cannot be used to480 * win or lose a race (HOLE 2).481 */482export async function mergeDeviceRevocations(483  incoming: DeviceRevocation[],484): Promise<string[]> {485  return enqueueDeviceMutation(async () => {486  const existing = await listDeviceRevocationsRaw();487  const known = new Set(existing.map((r) => r.deviceId));488  const fresh = incoming489    .filter((r) => !known.has(r.deviceId))490    // Clamped for display sanity only — a year-9999 revokedAt would render as491    // nonsense. The protection against ordering games is that revocation is492    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).493    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));494  if (fresh.length === 0) return [];495  await storeRevocationsRaw([...existing, ...fresh]);496  return fresh.map((r) => r.deviceId);497  });498}499500/**501 * Repair the invalid paired+revoked overlap produced by older direct-pair502 * handlers.503 *504 * A newer direct, SAS-confirmed pair is the only state allowed to lift the505 * older revocation. Every other overlap resolves in favour of the revocation,506 * including vouched devices and pairs that predate the removal.507 */508export async function repairPairedRevocationOverlaps(): Promise<{509  revocationsCleared: number;510  pairedRowsRemoved: number;511}> {512  return enqueueDeviceMutation(async () => {513  const devices = await listPairedDevicesRaw();514  const revocations = await listDeviceRevocationsRaw();515  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));516  const revocationIdsToClear = new Set<string>();517  const pairedIdsToRemove = new Set<string>();518519  for (const device of devices) {520    const revocation = revocationById.get(device.deviceId);521    if (!revocation) continue;522    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {523      revocationIdsToClear.add(device.deviceId);524    } else {525      pairedIdsToRemove.add(device.deviceId);526    }527  }528529  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {530    return { revocationsCleared: 0, pairedRowsRemoved: 0 };531  }532533  await chrome.storage.local.set({534    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),535    [REVOCATIONS_KEY]: revocations.filter(536      (record) => !revocationIdsToClear.has(record.deviceId),537    ),538  });539  return {540    revocationsCleared: revocationIdsToClear.size,541    pairedRowsRemoved: pairedIdsToRemove.size,542  };543  });544}545546async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {547  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });548}549550/**551 * PB-26-110: bound the ledger by evicting from whichever issuer currently holds552 * the most records, oldest first.553 *554 * Evicting globally-oldest-first let one early flood consume the whole cap and555 * permanently starve every other issuer, including genuine revocations, because556 * declining to evict at all is not a bounded option. Largest-holder-first keeps557 * the bound while guaranteeing a flood can only ever displace its own records:558 * once an issuer holds more than anyone else, everything it adds costs it one of559 * its own oldest.560 *561 * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a562 * 500-record cap; switch to a live-count map if the cap ever grows by orders of563 * magnitude.564 */565function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {566  if (records.length <= MAX_REVOCATIONS) return records;567  const byIssuer = new Map<string, DeviceRevocation[]>();568  for (const record of records) {569    const key = record.revokedByDeviceId ?? "";570    const list = byIssuer.get(key);571    if (list) list.push(record);572    else byIssuer.set(key, [record]);573  }574  const dropped = new Set<DeviceRevocation>();575  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {576    let victim: DeviceRevocation[] | undefined;577    for (const list of byIssuer.values()) {578      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);579      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {580        victim = list;581      }582    }583    if (!victim) break;584    const oldest = victim.find((r) => !dropped.has(r));585    if (!oldest) break;586    dropped.add(oldest);587  }588  return records.filter((r) => !dropped.has(r));589}590591function isDeviceRevocation(x: unknown): x is DeviceRevocation {592  if (typeof x !== "object" || x === null) return false;593  const r = x as Record<string, unknown>;594  return (595    typeof r.deviceId === "string" &&596    !!r.deviceId &&597    typeof r.longTermPublicKeyHex === "string" &&598    typeof r.revokedAt === "number" &&599    typeof r.revokedByDeviceId === "string"600  );601}602603export async function clearPairedDevices(): Promise<void> {604  await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));605}606607export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {608  await enqueueDeviceMutation(async () => {609  const devices = await listPairedDevicesRaw();610  const idx = devices.findIndex((d) => d.deviceId === deviceId);611  if (idx < 0) return;612  devices[idx] = { ...devices[idx], lastSyncedAt: when };613  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });614  });615}616617export async function getLastSyncAt(): Promise<number | null> {618  try {619    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);620    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];621    if (typeof value === "number") return value;622    if (typeof value === "string" && value !== "") {623      const n = Number.parseInt(value, 10);624      return Number.isFinite(n) ? n : null;625    }626    return null;627  } catch {628    return null;629  }630}631632export async function setLastSyncAt(when: number): Promise<void> {633  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });634}635636/**637 * The last sync ATTEMPT, not just the last success. A 3-second flash in the638 * popup is useless for debugging a real phone, so every terminating path639 * writes this and Settings reads it back after the fact — including across an640 * MV3 service-worker restart.641 */642export interface SyncResult {643  outcome: SyncOutcome;644  detail?: string;645  /** When the attempt finished, success or not. */646  at: number;647  entriesReceived: number;648  entriesSent: number;649}650651export async function getLastSyncResult(): Promise<SyncResult | null> {652  try {653    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);654    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];655    if (656      value &&657      typeof value === "object" &&658      typeof (value as Record<string, unknown>).outcome === "string" &&659      typeof (value as Record<string, unknown>).at === "number" &&660      typeof (value as Record<string, unknown>).entriesReceived === "number" &&661      typeof (value as Record<string, unknown>).entriesSent === "number"662    ) {663      return value as SyncResult;664    }665    return null;666  } catch {667    return null;668  }669}670671export async function setLastSyncResult(result: SyncResult): Promise<void> {672  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });673}674675function isPairedDevice(x: unknown): x is PairedDevice {676  if (typeof x !== "object" || x === null) return false;677  const r = x as Record<string, unknown>;678  if (typeof r.deviceId !== "string" || !r.deviceId) return false;679  if (typeof r.pairedAt !== "number") return false;680  if (681    r.localAlias !== undefined &&682    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)683  ) {684    return false;685  }686  if (687    r.rendezvousSecretHex !== undefined &&688    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))689  ) {690    return false;691  }692  if (r.relayUrl !== undefined) {693    if (typeof r.relayUrl !== "string") return false;694    try {695      if (new URL(r.relayUrl).protocol !== "wss:") return false;696    } catch {697      return false;698    }699  }700  return true;701}