sync-runner.ts

6.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 {21  openRelaySyncSession,22  type SyncSession,23  type SyncPeerIdentity,24} from "../shared/sync-transport";25import { hexToPubkey } from "../shared/sync-identity";26import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";27import { constantTimeEqualHex } from "../shared/constant-time";28import { getRootKeyHex, isUnlocked } from "./vault";29import type { PairedDevice } from "../shared/sync-types";30import type { SyncRunResult } from "./sync-trigger";31import {32  dialingRendezvousIds,33  rendezvousSecretFromHex,34} from "../shared/rendezvous";35import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";3637/**38 * Test/injection seam. Production wires in the real `openSyncSession`;39 * unit tests inject a fake to avoid real WebSocket + Noise KK.40 */41export interface SyncRunnerDeps {42  openRelaySession: (43    relayUrl: string,44    ourIdentity: SyncPeerIdentity,45    expectedRemoteHex: string,46    rendezvousId: string,47  ) => Promise<SyncSession>;48  getSyncSettings: () => Promise<SyncSettings>;49  createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;50}5152const productionDeps: SyncRunnerDeps = {53  openRelaySession: (relayUrl, identity, expected, rendezvousId) =>54    openRelaySyncSession(relayUrl, identity, expected, rendezvousId),55  getSyncSettings,56  createAdapter: (context) => createSyncVaultAdapter(context),57};5859export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {60  return runWithDeps(device, productionDeps);61}6263/**64 * Inner runner with explicit deps — exported so tests can inject fakes.65 */66export async function runWithDeps(67  device: PairedDevice,68  deps: SyncRunnerDeps,69): Promise<SyncRunResult> {70  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };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  const masterKeyHex = getRootKeyHex();85  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };86  const masterKey = hexToBytesLocal(masterKeyHex);87  const publicIdentity = await buildLocalIdentity(masterKey);88  const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);89  if (!constantTimeEqualHex(90    publicIdentity.longTermPublicKeyHex,91    bytesToHex(localKeys.publicKey),92  )) {93    masterKey.fill(0);94    localKeys.secretKey.fill(0);95    throw new Error("derived reconnect identity mismatch");96  }97  const ourIdentity: SyncPeerIdentity = {98    ...publicIdentity,99    longTermPrivateKey: localKeys.secretKey,100  };101102  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.103  void hexToPubkey(expectedRemoteHex);104105  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.106  const adapter = deps.createAdapter({ deviceName: device.deviceName });107  let session: SyncSession;108  try {109    const settings = await deps.getSyncSettings();110    const configuredRelay = relayInForce(settings);111    session = await connectRelay(112      device.relayUrl ?? configuredRelay,113      expectedRemoteHex,114      ourIdentity,115      reconnectIds,116      deps.openRelaySession,117    );118  } finally {119    masterKey.fill(0);120    localKeys.secretKey.fill(0);121  }122123  try {124    const outcome = await runSyncInitiator(session, adapter);125    if (outcome.status !== "completed") {126      console.info(127        `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${128          outcome.error ? `: ${outcome.error}` : ""129        }`,130      );131    }132    if (outcome.status === "completed") {133      return {134        ok: true,135        entriesReceived: outcome.entriesReceived,136        entriesSent: outcome.entriesSent,137      };138    }139    if (outcome.status === "vault_mismatch") {140      throw new Error("vault_id_mismatch");141    }142    if (outcome.status === "timeout") {143      throw new Error(outcome.error ?? "sync_timeout");144    }145    throw new Error(outcome.error ?? "sync_errored");146  } finally {147    // The coordinator does NOT close the session (it returns and leaves the148    // session open). This runner is the sole owner of session lifetime.149    await session.close().catch(() => undefined);150  }151}152153async function connectRelay(154  relayUrl: string,155  expectedRemoteHex: string,156  ourIdentity: SyncPeerIdentity,157  rendezvousIds: string[],158  openRelaySession: SyncRunnerDeps["openRelaySession"],159): Promise<SyncSession> {160  let lastError: unknown;161  for (const rendezvousId of rendezvousIds) {162    try {163      return await openRelaySession(164        relayUrl,165        ourIdentity,166        expectedRemoteHex,167        rendezvousId,168      );169    } catch (error) {170      lastError = error;171      // Each clock-skew window fails separately and only the last one survives172      // into the thrown message; on a real device the earlier ones are often173      // the informative ones.174      console.info(175        `[Peach sync] relay dial failed — relay=${relayUrl} rendezvous=${rendezvousId}: ${176          error instanceof Error ? error.message : String(error)177        }`,178      );179    }180  }181  const detail = lastError instanceof Error ? lastError.message : "unreachable";182  throw new Error(`relay_sync_failed:${detail}`);183}184185function hexToBytesLocal(hex: string): Uint8Array {186  const out = new Uint8Array(hex.length / 2);187  for (let i = 0; i < out.length; i++) {188    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);189  }190  return out;191}192193function bytesToHex(bytes: Uint8Array): string {194  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");195}