mesh-relay-listener.ts

8.7 KB · raw

1import type { PairedDevice } from "../shared/sync-types";2import {3  isRouteCapableMeshPeer,4  MESH_WAITER_RETRY_DELAY_MS,5  nextMeshWaiterRetryDelay,6  selectDialTargets,7  weDialMeshPeer,8} from "../shared/mesh-peer-role";9import {10  openRelaySyncResponderSession,11  type SyncPeerIdentity,12} from "../shared/sync-transport";13import {14  rendezvousSecretFromHex,15  waitingRendezvousIds,16} from "../shared/rendezvous";17import { runSyncResponder } from "./sync-coordinator";18import { createSyncVaultAdapter } from "./sync-vault-adapter";19import {20  createLockedSyncVaultAdapter,21  getLockedSyncIdentity,22} from "./locked-sync-spool";23import { getLocalPairingIdentity } from "./device-identity";24import {25  findPairedDeviceById,26  listPairedDevices,27  markLastSyncedAt,28} from "./paired-devices";29import { getSyncSettings, relayInForce } from "./sync-settings";30import { isUnlocked, isDecoyMode, getRootKeyHex } from "./vault";31import { getPairSecret } from "./pair-secrets";32import { recordInboundSyncCompletion } from "./sync-trigger";3334const RECONCILE_INTERVAL_MS = 60_000;3536interface MeshIdentity {37  identity: Omit<SyncPeerIdentity, "longTermPrivateKey">;38  privateKey: Uint8Array;39  publicKey: Uint8Array;40  signingPrivateKey?: CryptoKey;41}4243interface WaiterHandle {44  abort: AbortController;45}4647interface MeshListenerState {48  installed: boolean;49  reconcileQueued: boolean;50  timer: ReturnType<typeof setTimeout> | null;51  waiters: Map<string, WaiterHandle>;52  activeSessions: Set<{ close(): Promise<void> }>;53}5455function state(): MeshListenerState {56  const global = globalThis as typeof globalThis & {57    __peachMeshListenerState?: MeshListenerState;58  };59  if (!global.__peachMeshListenerState) {60    global.__peachMeshListenerState = {61      installed: false,62      reconcileQueued: false,63      timer: null,64      waiters: new Map(),65      activeSessions: new Set(),66    };67  }68  return global.__peachMeshListenerState;69}7071export async function selectProductionDialTargets(72  devices: PairedDevice[],73): Promise<PairedDevice[]> {74  const local = await getMeshIdentity();75  if (!local) return devices;76  try {77    return selectDialTargets(78      devices,79      local.identity.longTermPublicKeyHex,80    );81  } finally {82    local.privateKey.fill(0);83    local.publicKey.fill(0);84  }85}8687export function setupMeshRelayListener(): void {88  const listener = state();89  if (listener.installed) return;90  listener.installed = true;91  chrome.storage?.onChanged?.addListener(() => requestMeshRelayReconcile());92  requestMeshRelayReconcile();93}9495export function abortMeshRelaySessions(): void {96  const listener = state();97  abortAll(listener);98  for (const session of listener.activeSessions) void session.close().catch(() => undefined);99  listener.activeSessions.clear();100}101102export function requestMeshRelayReconcile(): void {103  const listener = state();104  if (listener.reconcileQueued) return;105  listener.reconcileQueued = true;106  queueMicrotask(() => {107    listener.reconcileQueued = false;108    void reconcile().catch(() => undefined);109  });110}111112async function reconcile(): Promise<void> {113  const listener = state();114  if (isDecoyMode()) {115    abortAll(listener);116    return;117  }118  if (listener.timer !== null) clearTimeout(listener.timer);119  listener.timer = setTimeout(requestMeshRelayReconcile, RECONCILE_INTERVAL_MS);120121  const local = await getMeshIdentity();122  if (!local) { abortAll(listener); return; }123  const desired = new Map<string, {124    peer: PairedDevice;125    relayUrl: string;126    rendezvousId: string;127  }>();128  try {129    const settings = await getSyncSettings();130    if (isDecoyMode()) { abortAll(listener); return; }131    const fallbackRelay = relayInForce(settings);132    const nowSeconds = Date.now() / 1000;133    for (const peer of await listPairedDevices()) {134      if (isDecoyMode()) { abortAll(listener); return; }135      if (136        !isRouteCapableMeshPeer(peer) ||137        weDialMeshPeer(138          local.identity.longTermPublicKeyHex,139          peer.longTermPublicKeyHex!,140        )141      ) {142        continue;143      }144      const relayUrl = peer.relayUrl ?? fallbackRelay;145      // PB-26-213: the registry carries no secrets; a peer without a146      // resolvable secret is skipped until the next unlock reconciles it.147      const secretHex = await getPairSecret(peer.deviceId, isUnlocked() ? getRootKeyHex() : null);148      if (!secretHex) continue;149      const secret = rendezvousSecretFromHex(secretHex);150      for (const rendezvousId of waitingRendezvousIds(secret, nowSeconds)) {151        desired.set(`${peer.deviceId}|${relayUrl}|${rendezvousId}`, {152          peer,153          relayUrl,154          rendezvousId,155        });156      }157    }158  } finally {159    local.privateKey.fill(0);160    local.publicKey.fill(0);161  }162163  for (const [key, handle] of listener.waiters) {164    if (desired.has(key)) continue;165    handle.abort.abort();166    listener.waiters.delete(key);167  }168  for (const [key, target] of desired) {169    if (listener.waiters.has(key)) continue;170    const abort = new AbortController();171    listener.waiters.set(key, { abort });172    void holdWaiter(target, abort.signal).finally(() => {173      if (listener.waiters.get(key)?.abort === abort) {174        listener.waiters.delete(key);175      }176    });177  }178}179180async function holdWaiter(181  target: { peer: PairedDevice; relayUrl: string; rendezvousId: string },182  signal: AbortSignal,183): Promise<void> {184  let retryDelayMs = MESH_WAITER_RETRY_DELAY_MS;185  while (!signal.aborted && !isDecoyMode()) {186    const current = await findPairedDeviceById(target.peer.deviceId);187    if (isDecoyMode()) return;188    if (!current || !isRouteCapableMeshPeer(current)) return;189    let session: Awaited<ReturnType<typeof openRelaySyncResponderSession>> | null = null;190    let local: MeshIdentity | null = null;191    let succeeded = false;192    try {193      local = await getMeshIdentity();194      if (!local) return;195      const identity: SyncPeerIdentity = {196        ...local.identity,197        longTermPrivateKey: local.privateKey,198      };199      session = await openRelaySyncResponderSession(200        target.relayUrl,201        identity,202        current.longTermPublicKeyHex!,203        target.rendezvousId,204        { signal },205      );206      state().activeSessions.add(session);207      if (isDecoyMode()) {208        state().activeSessions.delete(session);209        await session.close().catch(() => undefined);210        return;211      }212      const adapter = isUnlocked()213        ? createSyncVaultAdapter({ deviceName: current.deviceName })214        : await createLockedSyncVaultAdapter();215      if (!adapter || isDecoyMode()) return;216      const outcome = await runSyncResponder(session, adapter);217      if (isDecoyMode()) return;218      if (outcome.status === "completed") {219        if (isDecoyMode()) return;220        await markLastSyncedAt(current.deviceId, Date.now());221        recordInboundSyncCompletion(outcome.entriesReceived, outcome.entriesSent);222        succeeded = true;223      }224      if (!isDecoyMode()) requestMeshRelayReconcile();225    } catch {226    } finally {227      if (session) state().activeSessions.delete(session);228      local?.privateKey.fill(0);229      local?.publicKey.fill(0);230      await session?.close().catch(() => undefined);231    }232    await waitForRetry(signal, retryDelayMs);233    retryDelayMs = nextMeshWaiterRetryDelay(retryDelayMs, succeeded);234  }235}236237async function getMeshIdentity(): Promise<{238  identity: Omit<SyncPeerIdentity, "longTermPrivateKey">;239  privateKey: Uint8Array;240  publicKey: Uint8Array;241  signingPrivateKey?: CryptoKey;242} | null> {243  if (isUnlocked()) return getLocalPairingIdentity();244  const cached = await getLockedSyncIdentity();245  if (!cached) return null;246  return {247    identity: {248      deviceId: cached.deviceId,249      deviceName: cached.deviceName,250      deviceType: cached.deviceType,251      longTermPublicKeyHex: cached.longTermPublicKeyHex,252    },253    privateKey: cached.longTermPrivateKey,254    publicKey: hexToBytes(cached.longTermPublicKeyHex),255  };256}257258function hexToBytes(hex: string): Uint8Array {259  const out = new Uint8Array(hex.length / 2);260  for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);261  return out;262}263264function waitForRetry(signal: AbortSignal, delayMs: number): Promise<void> {265  if (signal.aborted) return Promise.resolve();266  return new Promise((resolve) => {267    const onAbort = () => {268      clearTimeout(timer);269      resolve();270    };271    const timer = setTimeout(() => {272      signal.removeEventListener("abort", onAbort);273      resolve();274    }, delayMs);275    signal.addEventListener("abort", onAbort, { once: true });276  });277}278279function abortAll(listener: MeshListenerState): void {280  for (const handle of listener.waiters.values()) handle.abort.abort();281  listener.waiters.clear();282  for (const session of listener.activeSessions) void session.close().catch(() => undefined);283  listener.activeSessions.clear();284}