sync-runner.ts

7.5 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 { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";28import { constantTimeEqualHex } from "../shared/constant-time";29import { getRootKeyHex, isUnlocked } from "./vault";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};5960export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {61  return runWithDeps(device, productionDeps);62}6364/**65 * Inner runner with explicit deps — exported so tests can inject fakes.66 */67export async function runWithDeps(68  device: PairedDevice,69  deps: SyncRunnerDeps,70): Promise<SyncRunResult> {71  if (!device.longTermPublicKeyHex) {72    throw new Error("paired_device_missing_pubkey");73  }74  if (!device.rendezvousSecretHex) {75    throw new Error("paired_device_missing_rendezvous_secret");76  }77  const expectedRemoteHex = device.longTermPublicKeyHex;78  const reconnectIds = dialingRendezvousIds(79    rendezvousSecretFromHex(device.rendezvousSecretHex),80    Date.now() / 1000,81  );82  // Sync identity derives from the stable root key, not the password-derived83  // key. Reconnect uses both halves directly in standard Noise KK.84  let masterKey: Uint8Array | null = null;85  let privateKey: Uint8Array | null = null;86  let ourIdentity: SyncPeerIdentity;87  let adapter: SyncVaultAdapter;88  if (isUnlocked()) {89    const masterKeyHex = getRootKeyHex();90    if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };91    masterKey = hexToBytesLocal(masterKeyHex);92    const publicIdentity = await buildLocalIdentity(masterKey);93    const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);94    privateKey = localKeys.secretKey;95    if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {96      masterKey.fill(0);97      privateKey.fill(0);98      throw new Error("derived reconnect identity mismatch");99    }100    ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };101    adapter = deps.createAdapter({ deviceName: device.deviceName });102  } else {103    const cachedIdentity = await getLockedSyncIdentity();104    const cachedAdapter = await createLockedSyncVaultAdapter();105    if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };106    ourIdentity = cachedIdentity;107    privateKey = cachedIdentity.longTermPrivateKey;108    adapter = cachedAdapter;109  }110111  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.112  void hexToPubkey(expectedRemoteHex);113114  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.115  let session: SyncSession;116  try {117    const settings = await deps.getSyncSettings();118    const configuredRelay = relayInForce(settings);119    session = await connectRelay(120      device.relayUrl ?? configuredRelay,121      expectedRemoteHex,122      ourIdentity,123      reconnectIds,124      deps.openRelaySession,125    );126  } finally {127    masterKey?.fill(0);128    privateKey?.fill(0);129  }130131  try {132    const outcome = await runSyncInitiator(session, adapter);133    if (outcome.status !== "completed") {134      console.info(135        `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${136          outcome.error ? `: ${outcome.error}` : ""137        }`,138      );139    }140    if (outcome.status === "completed") {141      return {142        ok: true,143        entriesReceived: outcome.entriesReceived,144        entriesSent: outcome.entriesSent,145      };146    }147    if (outcome.status === "vault_mismatch") {148      throw new Error("vault_id_mismatch");149    }150    if (outcome.status === "timeout") {151      throw new Error(outcome.error ?? "sync_timeout");152    }153    throw new Error(outcome.error ?? "sync_errored");154  } finally {155    // The coordinator does NOT close the session (it returns and leaves the156    // session open). This runner is the sole owner of session lifetime.157    await session.close().catch(() => undefined);158  }159}160161async function connectRelay(162  relayUrl: string,163  expectedRemoteHex: string,164  ourIdentity: SyncPeerIdentity,165  rendezvousIds: string[],166  openRelaySession: SyncRunnerDeps["openRelaySession"],167): Promise<SyncSession> {168  let lastError: unknown;169  for (const rendezvousId of rendezvousIds) {170    try {171      return await openRelaySession(172        relayUrl,173        ourIdentity,174        expectedRemoteHex,175        rendezvousId,176      );177    } catch (error) {178      lastError = error;179      // Each clock-skew window fails separately and only the last one survives180      // into the thrown message; on a real device the earlier ones are often181      // the informative ones. The URL and rendezvous capability are182      // deliberately omitted because either may reveal private routing state.183      console.info(184        `[Peach sync] relay dial failed — reason=${185          error instanceof Error ? error.message : String(error)186        }`,187      );188    }189  }190  const detail = lastError instanceof Error ? lastError.message : "unreachable";191  throw new Error(`relay_sync_failed:${detail}`);192}193194function hexToBytesLocal(hex: string): Uint8Array {195  const out = new Uint8Array(hex.length / 2);196  for (let i = 0; i < out.length; i++) {197    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);198  }199  return out;200}201202function bytesToHex(bytes: Uint8Array): string {203  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");204}