paired-devices.ts

28.7 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";2223export const REGISTRY_KEY = "peach_paired_devices_v3";24export const 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}3637/**38 * §18.4: a corrupt persisted registry is an integrity error, not an empty39 * list. An absent key is legitimate (no devices paired yet); a present but40 * unparseable value means trust state was damaged and must not be silently41 * cleared — that would un-revoke every device. The corrupt payload is42 * preserved (this function never writes) so a future repair can attempt43 * recovery.44 */45export class TrustDataCorruptedError extends Error {46  constructor(key: string, detail: string) {47    super(`trust_data_corrupted:${key}:${detail}`);48    this.name = "TrustDataCorruptedError";49  }50}5152async function listPairedDevicesRaw(): Promise<PairedDevice[]> {53  const result = await chrome.storage.local.get(REGISTRY_KEY);54  const devices = (result as Record<string, unknown>)[REGISTRY_KEY];55  if (devices === undefined) return [];56  if (!Array.isArray(devices)) {57    throw new TrustDataCorruptedError(REGISTRY_KEY, `expected array, got ${typeof devices}`);58  }59  return devices.filter(isPairedDevice);60}6162export async function listPairedDevices(): Promise<PairedDevice[]> {63  await deviceMutationTail;64  return listPairedDevicesRaw();65}6667export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {68  const devices = await listPairedDevices();69  return devices.find((d) => d.deviceId === deviceId) ?? null;70}7172export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {73  const devices = await listPairedDevices();74  return devices.find((d) =>75    d.longTermPublicKeyHex !== undefined &&76    constantTimeEqualHex(d.longTermPublicKeyHex, pubkeyHex),77  ) ?? null;78}7980/**81 * Durable registry write. PB-26-213: the reusable rendezvous secret must82 * never persist into the public registry — it is stripped here so a83 * missed writer fails closed to a secretless row instead of plaintext disk.84 */85export async function writePairedDevices(devices: PairedDevice[]): Promise<void> {86  await enqueueDeviceMutation(() => writePairedDevicesRaw(devices));87}8889async function writePairedDevicesRaw(devices: PairedDevice[]): Promise<void> {90  await chrome.storage.local.set({91    [REGISTRY_KEY]: devices.map((device) => {92      const normalized = withSigningKeyHistory(device);93      if (!isValidSecret(normalized.rendezvousSecretHex)) return normalized;94      const scrubbed = { ...normalized };95      delete scrubbed.rendezvousSecretHex;96      return scrubbed;97    }),98  });99}100101function isValidSecret(value: unknown): value is string {102  return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);103}104105/**106 * Add or update a device by `deviceId`. Existing cryptographic identity keys107 * are immutable; metadata-only updates merge into the stored row.108 */109export async function upsertPairedDevice(device: PairedDevice): Promise<void> {110  if (!device.deviceId) throw new Error("device.deviceId is required");111  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");112  if (!device.deviceName) throw new Error("device.deviceName is required");113  if (!device.deviceType) throw new Error("device.deviceType is required");114  await upsertPairedDevices([device]);115}116117export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {118  await enqueueDeviceMutation(async () => {119    const stored = await listPairedDevicesRaw();120    const incomingById = new Map<string, PairedDevice>();121    for (const device of devices) {122      if (!device.deviceId) throw new Error("device.deviceId is required");123      if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");124      if (!device.deviceName) throw new Error("device.deviceName is required");125      if (!device.deviceType) throw new Error("device.deviceType is required");126      const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };127      const previous = incomingById.get(normalized.deviceId);128      if (previous) {129        assertIdentityBinding(previous, normalized);130        throw new Error("device_identity_duplicate");131      }132      incomingById.set(normalized.deviceId, normalized);133      const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);134      if (existing) assertIdentityBinding(existing, normalized);135    }136    for (const device of incomingById.values()) {137      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);138      if (index >= 0) {139        const existing = stored[index];140        const merged = { ...existing, ...device };141        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {142          merged.signingPublicKeyHex = existing.signingPublicKeyHex;143        }144        stored[index] = merged;145      } else {146        stored.push(device);147      }148    }149    await writePairedDevicesRaw(stored);150  });151}152153/** Validate identity bindings without changing the registry. */154export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {155  await enqueueDeviceMutation(async () => {156    const stored = await listPairedDevicesRaw();157    const incomingById = new Map<string, PairedDevice>();158    for (const device of devices) {159      const previous = incomingById.get(device.deviceId);160      if (previous) {161        assertIdentityBinding(previous, device);162        throw new Error("device_identity_duplicate");163      }164      incomingById.set(device.deviceId, device);165      const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);166      if (existing) assertIdentityBinding(existing, device);167    }168  });169}170171function validateAndIndexDevices(172  stored: PairedDevice[],173  devices: PairedDevice[],174): Map<string, PairedDevice> {175  const incomingById = new Map<string, PairedDevice>();176  for (const device of devices) {177    if (!device.deviceId) throw new Error("device.deviceId is required");178    if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");179    if (!device.deviceName) throw new Error("device.deviceName is required");180    if (!device.deviceType) throw new Error("device.deviceType is required");181    const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };182    const previous = incomingById.get(normalized.deviceId);183    if (previous) {184      assertIdentityBinding(previous, normalized);185      throw new Error("device_identity_duplicate");186    }187    incomingById.set(normalized.deviceId, normalized);188    const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);189    if (existing) assertIdentityBinding(existing, normalized);190  }191  return incomingById;192}193194function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {195  if (196    typeof existing.longTermPublicKeyHex === "string" &&197    typeof incoming.longTermPublicKeyHex === "string" &&198    !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)199  ) {200    throw new Error("device_identity_immutable");201  }202  if (203    typeof existing.signingPublicKeyHex === "string" &&204    typeof incoming.signingPublicKeyHex === "string" &&205    !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)206  ) {207    throw new Error("device_identity_immutable");208  }209}210function withSigningKeyHistory(device: PairedDevice): PairedDevice {211  if (!device.signingPublicKeyHex || !device.signingKeyHistory?.length) return device;212  const current = device.signingPublicKeyHex.toLowerCase();213  const history = device.signingKeyHistory214    .filter((key) => Number.isInteger(key.version) && key.version > 0 && /^[0-9a-f]{64}$/i.test(key.publicKeyHex))215    .map((key) => ({ version: key.version, publicKeyHex: key.publicKeyHex.toLowerCase() }));216  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 }] };217}218219export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {220  const alias = name.trim();221  if (!alias) throw new Error("device name must not be empty");222  if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");223  await enqueueDeviceMutation(async () => {224    const devices = await listPairedDevicesRaw();225    const index = devices.findIndex((device) => device.deviceId === deviceId);226    if (index < 0) throw new Error("device_not_found");227    devices[index] = { ...devices[index], localAlias: alias };228    await chrome.storage.local.set({ [REGISTRY_KEY]: devices });229  });230}231232/**233 * Move every existing pair to the user-selected mesh relay.234 *235 * Reconnect prefers the relay stored on each pair over the global fallback, so236 * changing only the setting would leave established devices on the old relay.237 */238export async function setRelayUrlForAllPairedDevices(239  relayUrl: string,240): Promise<void> {241  await enqueueDeviceMutation(async () => {242    const devices = await listPairedDevicesRaw();243    if (devices.length === 0) return;244    await chrome.storage.local.set({245      [REGISTRY_KEY]: devices.map((device) => ({ ...device, relayUrl })),246    });247  });248  // A relay change is mesh state — the next alarm tick or popup open will dial249  // with the new relay. No fire-and-forget trigger here; the in-flight guard250  // would just drop it on the burst of upserts that often follows.251}252253/**254 * Removes the device from the LOCAL paired-devices registry only, leaving no255 * record behind. Prefer `revokeDeviceById` for anything the user would call256 * "remove this device" — a plain removal is silently undone the next time any257 * peer vouches the device back through a manifest.258 *259 * SECURITY NOTE (MV-8): this does NOT prevent the removed device from260 * syncing with OTHER peers in the device graph, and the device RETAINS261 * its copy of the vault root key (syncMasterKey) — it can still decrypt262 * anything it already holds and anything other peers send it. For a263 * lost or stolen device, removal is insufficient: use "Rotate sync264 * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so265 * the old device's key material is useless.266 */267export async function removePairedDeviceById(deviceId: string): Promise<void> {268  await enqueueDeviceMutation(async () => {269    const devices = await listPairedDevicesRaw();270    await chrome.storage.local.set({271      [REGISTRY_KEY]: devices.filter((d) => d.deviceId !== deviceId),272    });273  });274  // The device-list-changed sync trigger fires from the messages.ts UNPAIR_DEVICE275  // handler — see upsertPairedDevice for why this lives there, not here.276}277278// ---------------------------------------------------------------------------279// Device revocation (Part 5 §C) — a removal that propagates and stays removed.280// ---------------------------------------------------------------------------281282async function listDeviceRevocationsRaw(): Promise<DeviceRevocation[]> {283  const result = await chrome.storage.local.get(REVOCATIONS_KEY);284  const value = (result as Record<string, unknown>)[REVOCATIONS_KEY];285  if (value === undefined) return [];286  if (!Array.isArray(value)) {287    throw new TrustDataCorruptedError(REVOCATIONS_KEY, `expected array, got ${typeof value}`);288  }289  return value.filter(isDeviceRevocation);290}291292export async function listDeviceRevocations(): Promise<DeviceRevocation[]> {293  await deviceMutationTail;294  return listDeviceRevocationsRaw();295}296297/**298 * True when either the id or the long-term key is under a terminal revocation.299 *300 * `deviceId` is a self-asserted string in a manifest, so keying terminality on301 * it alone is bypassed by re-vouching a revoked device's key under a fresh id.302 * The key is the thing a revoked device actually still holds, so it is the303 * thing terminality has to follow.304 */305export async function isDeviceRevoked(306  deviceId: string,307  longTermPublicKeyHex?: string,308): Promise<boolean> {309  // The all-zero sentinel stands for "we never stored a key for this device"310  // (see revokeDeviceById) and must not match a real one.311  const key = longTermPublicKeyHex?.toLowerCase();312  const keyIsRevocable = key !== undefined && key !== "0".repeat(64);313  await deviceMutationTail;314  return (await listDeviceRevocationsRaw()).some(315    (r) =>316      r.deviceId === deviceId ||317      (keyIsRevocable && r.longTermPublicKeyHex.toLowerCase() === key),318  );319}320321/**322 * Revoke a device: record it locally, then drop it from the registry. The323 * record rides out in every manifest this device signs from now on, and blocks324 * the device being vouched back in. A stale entry may survive in the325 * pending-vouch queue; the confirm handler re-checks revocation before326 * promoting anything out of it.327 */328export async function revokeDeviceById(329  deviceId: string,330  revokedByDeviceId: string,331  options: { now?: number } = {},332): Promise<DeviceRevocation | null> {333  return enqueueDeviceMutation(async () => {334  const devices = await listPairedDevicesRaw();335  const device = devices.find((candidate) => candidate.deviceId === deviceId);336  const existing = await listDeviceRevocationsRaw();337  if (existing.some((r) => r.deviceId === deviceId)) {338    // A direct re-pair in older builds could restore the paired row without339    // clearing its terminal revocation. "Remove" then returned early here and340    // left the row visible forever. Idempotency applies to the revocation341    // record, not to removing any stale live row that overlaps it.342    if (device) await chrome.storage.local.set({343      [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),344    });345    return null;346  }347348  const record: DeviceRevocation = {349    deviceId,350    // A device with no stored key was never usable as a sync peer; the351    // all-zero key keeps the record well-formed on the wire without352    // pretending to name a key we never saw.353    longTermPublicKeyHex: device?.longTermPublicKeyHex ?? "0".repeat(64),354    revokedAt: options.now ?? Date.now(),355    revokedByDeviceId,356  };357  await chrome.storage.local.set({358    [REGISTRY_KEY]: devices.filter((candidate) => candidate.deviceId !== deviceId),359    [REVOCATIONS_KEY]: capRevocations([...existing, record]),360  });361  return record;362  });363}364365/**366 * Clear a revocation after an authenticated, SAS-confirmed direct re-pair.367 * A changed signing key is accepted only through this seam and the old key368 * remains in immutable history.369 */370export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {371  if (!device.deviceId || !device.longTermPublicKeyHex || !device.deviceName || !device.deviceType) {372    throw new Error("invalid paired device");373  }374  await enqueueDeviceMutation(async () => {375    const devices = await listPairedDevicesRaw();376    const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);377    const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };378    if (stored) {379      if (stored.longTermPublicKeyHex && incoming.longTermPublicKeyHex &&380          !constantTimeEqualHex(stored.longTermPublicKeyHex, incoming.longTermPublicKeyHex)) {381        throw new Error("device_identity_immutable");382      }383      if (stored.signingPublicKeyHex && incoming.signingPublicKeyHex &&384          !constantTimeEqualHex(stored.signingPublicKeyHex, incoming.signingPublicKeyHex)) {385        incoming.signingKeyHistory = [386          ...(stored.signingKeyHistory ?? []),387          { version: (stored.signingKeyHistory?.reduce((m, k) => Math.max(m, k.version), 0) ?? 0) + 1,388            publicKeyHex: stored.signingPublicKeyHex.toLowerCase() },389        ];390      }391    }392    const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);393    if (index >= 0) {394      const merged = { ...devices[index], ...incoming };395      if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {396        merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;397      }398      devices[index] = merged;399    } else {400      devices.push(incoming);401    }402    const revocations = await listDeviceRevocationsRaw();403    const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);404    await chrome.storage.local.set({405      [REGISTRY_KEY]: scrubRegistryRows(devices),406      [REVOCATIONS_KEY]: capRevocations(filtered),407    });408  });409}410411export async function upsertPairedDevicesAndMergeRevocations(412  devices: PairedDevice[],413  incomingRevocations: DeviceRevocation[],414): Promise<string[]> {415  return enqueueDeviceMutation(async () => {416    const stored = await listPairedDevicesRaw();417    const incomingById = validateAndIndexDevices(stored, devices);418    const existingRevocations = await listDeviceRevocationsRaw();419    const knownById = new Set(existingRevocations.map((record) => record.deviceId));420    const fresh = incomingRevocations421      .filter((record) => !knownById.has(record.deviceId))422      .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));423    const allRevocations = [...existingRevocations, ...fresh];424    const revokedIds = new Set(allRevocations.map((record) => record.deviceId));425    for (const device of incomingById.values()) {426      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);427      if (index >= 0) {428        const existing = stored[index];429        const merged = { ...existing, ...device };430        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {431          merged.signingPublicKeyHex = existing.signingPublicKeyHex;432        }433        stored[index] = merged;434      } else {435        stored.push(device);436      }437    }438    const revokedKeys = new Set(439      allRevocations440        .map((record) => record.longTermPublicKeyHex.toLowerCase())441        .filter((key) => key !== "0".repeat(64)),442    );443    const registry = scrubRegistryRows(444      stored.filter(445        (device) =>446          !revokedIds.has(device.deviceId) &&447          !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),448      ),449    );450    await chrome.storage.local.set({451      [REGISTRY_KEY]: registry,452      [REVOCATIONS_KEY]: capRevocations(allRevocations),453    });454    return fresh.map((record) => record.deviceId);455  });456}457458function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {459  return devices.map((entry) => {460    if (!isValidSecret(entry.rendezvousSecretHex)) return entry;461    const scrubbed = { ...entry };462    delete scrubbed.rendezvousSecretHex;463    return scrubbed;464  });465}466467/**468 * True when the incoming identity matches an existing paired device's deviceId469 * OR a terminal revocation record (by deviceId or long-term public key).470 * A fresh pairing ceremony must not reuse terminally revoked identity material.471 */472export async function hasAdoptionIdentityConflict(473  deviceId: string,474  longTermPublicKeyHex: string,475): Promise<boolean> {476  await deviceMutationTail;477  const devices = await listPairedDevicesRaw();478  if (devices.some((d) => d.deviceId === deviceId)) return true;479  const key = longTermPublicKeyHex.toLowerCase();480  return (await listDeviceRevocationsRaw()).some((record) =>481    record.deviceId === deviceId ||482    (key !== "0".repeat(64) && record.longTermPublicKeyHex.toLowerCase() === key)483  );484}485486/**487 * Merge revocations learned from a peer's manifest. Returns the deviceIds that488 * were newly revoked, so the caller can evict them.489 *490 * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is491 * terminal, so the timestamp is display metadata only and cannot be used to492 * win or lose a race (HOLE 2).493 */494export async function mergeDeviceRevocations(495  incoming: DeviceRevocation[],496): Promise<string[]> {497  return enqueueDeviceMutation(async () => {498  const existing = await listDeviceRevocationsRaw();499  const known = new Set(existing.map((r) => r.deviceId));500  const fresh = incoming501    .filter((r) => !known.has(r.deviceId))502    // Clamped for display sanity only — a year-9999 revokedAt would render as503    // nonsense. The protection against ordering games is that revocation is504    // terminal, not this clamp (HOLE 2 audit in sync-limits.ts).505    .map((r) => ({ ...r, revokedAt: clampSyncTimestamp(r.revokedAt) }));506  if (fresh.length === 0) return [];507  await storeRevocationsRaw([...existing, ...fresh]);508  return fresh.map((r) => r.deviceId);509  });510}511512/**513 * Repair the invalid paired+revoked overlap produced by older direct-pair514 * handlers.515 *516 * A newer direct, SAS-confirmed pair is the only state allowed to lift the517 * older revocation. Every other overlap resolves in favour of the revocation,518 * including vouched devices and pairs that predate the removal.519 */520export async function repairPairedRevocationOverlaps(): Promise<{521  revocationsCleared: number;522  pairedRowsRemoved: number;523}> {524  return enqueueDeviceMutation(async () => {525  const devices = await listPairedDevicesRaw();526  const revocations = await listDeviceRevocationsRaw();527  const revocationById = new Map(revocations.map((record) => [record.deviceId, record]));528  const revocationIdsToClear = new Set<string>();529  const pairedIdsToRemove = new Set<string>();530531  for (const device of devices) {532    const revocation = revocationById.get(device.deviceId);533    if (!revocation) continue;534    if (device.trustSource === "direct" && device.pairedAt > revocation.revokedAt) {535      revocationIdsToClear.add(device.deviceId);536    } else {537      pairedIdsToRemove.add(device.deviceId);538    }539  }540541  if (revocationIdsToClear.size === 0 && pairedIdsToRemove.size === 0) {542    return { revocationsCleared: 0, pairedRowsRemoved: 0 };543  }544545  await chrome.storage.local.set({546    [REGISTRY_KEY]: devices.filter((device) => !pairedIdsToRemove.has(device.deviceId)),547    [REVOCATIONS_KEY]: revocations.filter(548      (record) => !revocationIdsToClear.has(record.deviceId),549    ),550  });551  return {552    revocationsCleared: revocationIdsToClear.size,553    pairedRowsRemoved: pairedIdsToRemove.size,554  };555  });556}557558async function storeRevocationsRaw(records: DeviceRevocation[]): Promise<void> {559  await chrome.storage.local.set({ [REVOCATIONS_KEY]: capRevocations(records) });560}561562/**563 * PB-26-110: bound the ledger by evicting from whichever issuer currently holds564 * the most records, oldest first.565 *566 * Evicting globally-oldest-first let one early flood consume the whole cap and567 * permanently starve every other issuer, including genuine revocations, because568 * declining to evict at all is not a bounded option. Largest-holder-first keeps569 * the bound while guaranteeing a flood can only ever displace its own records:570 * once an issuer holds more than anyone else, everything it adds costs it one of571 * its own oldest.572 *573 * ponytail: O(evictions x issuers) rescan of the per-issuer lists. Fine at a574 * 500-record cap; switch to a live-count map if the cap ever grows by orders of575 * magnitude.576 */577function capRevocations(records: DeviceRevocation[]): DeviceRevocation[] {578  if (records.length <= MAX_REVOCATIONS) return records;579  const byIssuer = new Map<string, DeviceRevocation[]>();580  for (const record of records) {581    const key = record.revokedByDeviceId ?? "";582    const list = byIssuer.get(key);583    if (list) list.push(record);584    else byIssuer.set(key, [record]);585  }586  const dropped = new Set<DeviceRevocation>();587  for (let total = records.length; total > MAX_REVOCATIONS; total -= 1) {588    let victim: DeviceRevocation[] | undefined;589    for (const list of byIssuer.values()) {590      const live = list.reduce((count, r) => (dropped.has(r) ? count : count + 1), 0);591      if (!victim || live > victim.reduce((c, r) => (dropped.has(r) ? c : c + 1), 0)) {592        victim = list;593      }594    }595    if (!victim) break;596    const oldest = victim.find((r) => !dropped.has(r));597    if (!oldest) break;598    dropped.add(oldest);599  }600  return records.filter((r) => !dropped.has(r));601}602603function isDeviceRevocation(x: unknown): x is DeviceRevocation {604  if (typeof x !== "object" || x === null) return false;605  const r = x as Record<string, unknown>;606  return (607    typeof r.deviceId === "string" &&608    !!r.deviceId &&609    typeof r.longTermPublicKeyHex === "string" &&610    typeof r.revokedAt === "number" &&611    typeof r.revokedByDeviceId === "string"612  );613}614615export async function clearPairedDevices(): Promise<void> {616  await enqueueDeviceMutation(() => chrome.storage.local.remove(REGISTRY_KEY));617}618619export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {620  await enqueueDeviceMutation(async () => {621  const devices = await listPairedDevicesRaw();622  const idx = devices.findIndex((d) => d.deviceId === deviceId);623  if (idx < 0) return;624  devices[idx] = { ...devices[idx], lastSyncedAt: when };625  await chrome.storage.local.set({ [REGISTRY_KEY]: devices });626  });627}628629export async function getLastSyncAt(): Promise<number | null> {630  try {631    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);632    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];633    if (typeof value === "number") return value;634    if (typeof value === "string" && value !== "") {635      const n = Number.parseInt(value, 10);636      return Number.isFinite(n) ? n : null;637    }638    return null;639  } catch {640    return null;641  }642}643644export async function setLastSyncAt(when: number): Promise<void> {645  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });646}647648/**649 * The last sync ATTEMPT, not just the last success. A 3-second flash in the650 * popup is useless for debugging a real phone, so every terminating path651 * writes this and Settings reads it back after the fact — including across an652 * MV3 service-worker restart.653 */654export interface SyncResult {655  outcome: SyncOutcome;656  detail?: string;657  /** When the attempt finished, success or not. */658  at: number;659  entriesReceived: number;660  entriesSent: number;661}662663export async function getLastSyncResult(): Promise<SyncResult | null> {664  try {665    const result = await chrome.storage.local.get(LAST_SYNC_RESULT_KEY);666    const value = (result as Record<string, unknown>)[LAST_SYNC_RESULT_KEY];667    if (668      value &&669      typeof value === "object" &&670      typeof (value as Record<string, unknown>).outcome === "string" &&671      typeof (value as Record<string, unknown>).at === "number" &&672      typeof (value as Record<string, unknown>).entriesReceived === "number" &&673      typeof (value as Record<string, unknown>).entriesSent === "number"674    ) {675      return value as SyncResult;676    }677    return null;678  } catch {679    return null;680  }681}682683export async function setLastSyncResult(result: SyncResult): Promise<void> {684  await chrome.storage.local.set({ [LAST_SYNC_RESULT_KEY]: result });685}686687function isPairedDevice(x: unknown): x is PairedDevice {688  if (typeof x !== "object" || x === null) return false;689  const r = x as Record<string, unknown>;690  if (typeof r.deviceId !== "string" || !r.deviceId) return false;691  if (typeof r.pairedAt !== "number") return false;692  if (693    r.localAlias !== undefined &&694    (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)695  ) {696    return false;697  }698  if (699    r.rendezvousSecretHex !== undefined &&700    (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))701  ) {702    return false;703  }704  if (r.relayUrl !== undefined) {705    if (typeof r.relayUrl !== "string") return false;706    try {707      if (new URL(r.relayUrl).protocol !== "wss:") return false;708    } catch {709      return false;710    }711  }712  return true;713}