sync-runner.ts

6.3 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      return {127        ok: true,128        entriesReceived: outcome.entriesReceived,129        entriesSent: outcome.entriesSent,130      };131    }132    if (outcome.status === "vault_mismatch") {133      throw new Error("vault_id_mismatch");134    }135    if (outcome.status === "timeout") {136      throw new Error("sync_timeout");137    }138    throw new Error(outcome.error ?? "sync_errored");139  } finally {140    // The coordinator does NOT close the session (it returns and leaves the141    // session open). This runner is the sole owner of session lifetime.142    await session.close().catch(() => undefined);143  }144}145146async function connectRelay(147  relayUrl: string,148  expectedRemoteHex: string,149  ourIdentity: SyncPeerIdentity,150  rendezvousIds: string[],151  openRelaySession: SyncRunnerDeps["openRelaySession"],152): Promise<SyncSession> {153  let lastError: unknown;154  for (const rendezvousId of rendezvousIds) {155    try {156      return await openRelaySession(157        relayUrl,158        ourIdentity,159        expectedRemoteHex,160        rendezvousId,161      );162    } catch (error) {163      lastError = error;164    }165  }166  const detail = lastError instanceof Error ? lastError.message : "unreachable";167  throw new Error(`relay_sync_failed:${detail}`);168}169170function hexToBytesLocal(hex: string): Uint8Array {171  const out = new Uint8Array(hex.length / 2);172  for (let i = 0; i < out.length; i++) {173    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);174  }175  return out;176}177178function bytesToHex(bytes: Uint8Array): string {179  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");180}