sync-runner.ts

7.9 KB · raw

1// Production sync runner — composes the live vault adapter, WebSocket2// transport, and coordinator into the function the dispatcher injects via3// `setSyncRunner`. The runner is responsible for opening one Noise KK4// session against a single paired device, driving the sync protocol, and5// surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in6// sync-trigger.ts iterates over hosts and aggregates results.7//8// Connection strategy: dial the mesh's relay, trying each clock-skew9// rendezvous window in turn. There is one transport. LAN peer-to-peer sync is10// retired (docs/sync-lan-retirement.md), so there is no address cache, no11// subnet probe and no fallback ladder to reason about.12//13// We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange14// does that (returns `status: "vault_mismatch"` if the peer holds a15// different vault). The runner just translates the coordinator's outcome16// into the throw-or-return contract the dispatcher expects.1718import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";19import { createSyncVaultAdapter } from "./sync-vault-adapter";20import { createLockedSyncVaultAdapter, getLockedSyncIdentity } from "./locked-sync-spool";21import {22  openRelaySyncSession,23  type SyncSession,24  type SyncPeerIdentity,25} from "../shared/sync-transport";26import { hexToPubkey } from "../shared/sync-identity";27import { getLocalPairingIdentity } from "./device-identity";28import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault";29import { getPairSecret } from "./pair-secrets";30import type { PairedDevice } from "../shared/sync-types";31import type { SyncRunResult } from "./sync-trigger";32import {33  dialingRendezvousIds,34  rendezvousSecretFromHex,35} from "../shared/rendezvous";36import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";3738/**39 * Test/injection seam. Production wires in the real `openRelaySyncSession`;40 * unit tests inject a fake to avoid real WebSocket + Noise KK.41 */42export interface SyncRunnerDeps {43  openRelaySession: (44    relayUrl: string,45    ourIdentity: SyncPeerIdentity,46    expectedRemoteHex: string,47    rendezvousId: string,48  ) => Promise<SyncSession>;49  getSyncSettings: () => Promise<SyncSettings>;50  createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;51}5253const productionDeps: SyncRunnerDeps = {54  openRelaySession: (relayUrl, identity, expected, rendezvousId) =>55    openRelaySyncSession(relayUrl, identity, expected, rendezvousId),56  getSyncSettings,57  createAdapter: (context) => createSyncVaultAdapter(context),58};5960const activeInitiatorSessions = new Set<SyncSession>();6162/** Close initiator sessions that survived into a decoy-vault transition. */63export function abortSyncRunnerSessions(): void {64  for (const session of activeInitiatorSessions) void session.close().catch(() => undefined);65  activeInitiatorSessions.clear();66}6768export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {69  return runWithDeps(device, productionDeps);70}7172/**73 * Inner runner with explicit deps — exported so tests can inject fakes.74 */75export async function runWithDeps(76  device: PairedDevice,77  deps: SyncRunnerDeps,78): Promise<SyncRunResult> {79  if (!device.longTermPublicKeyHex) {80    throw new Error("paired_device_missing_pubkey");81  }82  // PB-26-213: the secret resolves from the encrypted pair-secret record83  // (unlocked) or the trusted-session copy (locked), never from the device84  // row — the public registry carries no reusable secrets.85  const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);86  if (!secretHex) {87    // Locked without a session copy: dormant until the next unlock, same as88    // any other locked-vault sync attempt. Unlocked without a secret is a89    // corrupt pairing that must surface loudly.90    if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };91    throw new Error("paired_device_missing_rendezvous_secret");92  }93  const expectedRemoteHex = device.longTermPublicKeyHex;94  const reconnectIds = dialingRendezvousIds(95    rendezvousSecretFromHex(secretHex),96    Date.now() / 1000,97  );98  let privateKey: Uint8Array | null = null;99  let ourIdentity: SyncPeerIdentity;100  let adapter: SyncVaultAdapter;101  if (isUnlocked()) {102    const localPairing = await getLocalPairingIdentity();103    const publicIdentity = localPairing.identity;104    privateKey = localPairing.privateKey;105    if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localPairing.publicKey)) {106      privateKey.fill(0);107      localPairing.publicKey.fill(0);108      return { ok: false, error: "IDENTITY_MISMATCH" };109    }110    ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };111    adapter = deps.createAdapter({ deviceName: device.deviceName });112  } else {113    const cachedIdentity = await getLockedSyncIdentity();114    const cachedAdapter = await createLockedSyncVaultAdapter();115    if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };116    ourIdentity = cachedIdentity;117    privateKey = cachedIdentity.longTermPrivateKey;118    adapter = cachedAdapter;119  }120121  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.122  void hexToPubkey(expectedRemoteHex);123124  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.125  let session: SyncSession;126  try {127    const settings = await deps.getSyncSettings();128    const configuredRelay = relayInForce(settings);129    session = await connectRelay(130      device.relayUrl ?? configuredRelay,131      expectedRemoteHex,132      ourIdentity,133      reconnectIds,134      deps.openRelaySession,135    );136    activeInitiatorSessions.add(session);137  } finally {138    privateKey?.fill(0);139  }140141  try {142    if (isDecoyMode()) throw new Error("vault_locked");143    const outcome = await runSyncInitiator(session, adapter);144    if (outcome.status !== "completed") {145      console.info(146        `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${147          outcome.error ? `: ${outcome.error}` : ""148        }`,149      );150    }151    if (outcome.status === "completed") {152      return {153        ok: true,154        entriesReceived: outcome.entriesReceived,155        entriesSent: outcome.entriesSent,156      };157    }158    if (outcome.status === "vault_mismatch") {159      throw new Error("vault_id_mismatch");160    }161    if (outcome.status === "timeout") {162      throw new Error(outcome.error ?? "sync_timeout");163    }164    throw new Error(outcome.error ?? "sync_errored");165  } finally {166    activeInitiatorSessions.delete(session);167    // The coordinator does NOT close the session (it returns and leaves the168    // session open). This runner is the sole owner of session lifetime.169    await session.close().catch(() => undefined);170  }171}172173async function connectRelay(174  relayUrl: string,175  expectedRemoteHex: string,176  ourIdentity: SyncPeerIdentity,177  rendezvousIds: string[],178  openRelaySession: SyncRunnerDeps["openRelaySession"],179): Promise<SyncSession> {180  let lastError: unknown;181  for (const rendezvousId of rendezvousIds) {182    try {183      return await openRelaySession(184        relayUrl,185        ourIdentity,186        expectedRemoteHex,187        rendezvousId,188      );189    } catch (error) {190      lastError = error;191      // Each clock-skew window fails separately and only the last one survives192      // into the thrown message; on a real device the earlier ones are often193      // the informative ones. The URL and rendezvous capability are194      // deliberately omitted because either may reveal private routing state.195      console.info(196        `[Peach sync] relay dial failed — reason=${197          error instanceof Error ? error.message : String(error)198        }`,199      );200    }201  }202  const detail = lastError instanceof Error ? lastError.message : "unreachable";203  throw new Error(`relay_sync_failed:${detail}`);204}205206207function bytesToHex(bytes: Uint8Array): string {208  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");209}