chirp-sync-runner.ts

6.2 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 XX4// 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:9//   1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10//   2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11//      probe is itself rate-limited (60s per-pubkey cooldown) inside the12//      transport, so we don't add another layer here.13//   3. On a successful session, persist the IP we connected on so the next14//      sync round skips the probe.15//16// We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange17// does that (returns `status: "vault_mismatch"` if the peer holds a18// different vault). The runner just translates the coordinator's outcome19// into the throw-or-return contract the dispatcher expects.2021import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";22import { createChirpSyncVaultAdapter } from "./chirp-sync-vault-adapter";23import {24  getLastKnownIp,25  setLastKnownIp,26} from "./paired-devices";27import {28  openChirpSyncSession,29  probeSubnetForPeer,30  type ChirpSyncSession,31  type SyncEndpoint,32} from "../shared/chirp-sync-transport";33import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34import { getLocalSubnet } from "../shared/local-network";35import { getOrCreateDeviceId } from "./chirp-identity";36import { getRootKeyHex, isUnlocked } from "./vault";37import type { PairedDevice } from "../shared/sync-types";38import type { SyncRunResult } from "./sync-trigger";3940const DEFAULT_PORT = 8765;4142/**43 * Test/injection seam. Production wires in the real `openChirpSyncSession`;44 * unit tests inject a fake to avoid real WebSocket + Noise XX.45 */46export interface SyncRunnerDeps {47  openSession: (48    endpoint: SyncEndpoint,49    ourPriv: Uint8Array,50    ourPub: Uint8Array,51    expectedRemoteHex: string,52  ) => Promise<ChirpSyncSession>;53  probeSubnet: (54    subnet: string,55    port: number,56    ourPriv: Uint8Array,57    ourPub: Uint8Array,58    expectedRemoteHex: string,59  ) => Promise<SyncEndpoint | null>;60  getSubnet: () => Promise<string | null>;61  createAdapter: (context?: { deviceName?: string }) => ChirpSyncVaultAdapter;62}6364const productionDeps: SyncRunnerDeps = {65  openSession: (endpoint, priv, pub, expected) =>66    openChirpSyncSession(endpoint, priv, pub, expected),67  probeSubnet: (subnet, port, priv, pub, expected) =>68    probeSubnetForPeer(subnet, port, priv, pub, expected),69  getSubnet: getLocalSubnet,70  createAdapter: (context) => createChirpSyncVaultAdapter(context),71};7273export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {74  return runWithDeps(device, productionDeps);75}7677/**78 * Inner runner with explicit deps — exported so tests can inject fakes.79 */80export async function runWithDeps(81  device: PairedDevice,82  deps: SyncRunnerDeps,83): Promise<SyncRunResult> {84  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };85  if (!device.longTermPublicKeyHex) {86    throw new Error("paired_device_missing_pubkey");87  }88  const expectedRemoteHex = device.longTermPublicKeyHex;89  const port = device.wsPort ?? DEFAULT_PORT;9091  // Sync identity derives from the stable root key, not the password-derived92  // key — keeps the Noise XX static key constant across password changes (SEV-2-B).93  const masterKeyHex = getRootKeyHex();94  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };95  const masterKey = hexToBytesLocal(masterKeyHex);96  const ourDeviceId = await getOrCreateDeviceId();97  const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);9899  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.100  void hexToPubkey(expectedRemoteHex);101102  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.103  const adapter = deps.createAdapter({ deviceName: device.deviceName });104  const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);105106  try {107    const outcome = await runChirpSyncInitiator(session, adapter);108    if (outcome.status === "completed") {109      return {110        ok: true,111        entriesReceived: outcome.entriesReceived,112        entriesSent: outcome.entriesSent,113      };114    }115    if (outcome.status === "vault_mismatch") {116      throw new Error("vault_id_mismatch");117    }118    if (outcome.status === "timeout") {119      throw new Error("sync_timeout");120    }121    throw new Error(outcome.error ?? "sync_errored");122  } finally {123    // The coordinator does NOT close the session (it returns and leaves the124    // session open). This runner is the sole owner of session lifetime.125    await session.close().catch(() => undefined);126  }127}128129async function connect(130  _device: PairedDevice,131  expectedRemoteHex: string,132  port: number,133  ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },134  deps: SyncRunnerDeps,135): Promise<ChirpSyncSession> {136  const cached = await getLastKnownIp(expectedRemoteHex);137  if (cached) {138    try {139      const session = await deps.openSession(140        { ip: cached, port },141        ourKeypair.secretKey,142        ourKeypair.publicKey,143        expectedRemoteHex,144      );145      return session;146    } catch {147      // Cached IP stale — fall through to the subnet probe.148    }149  }150151  const subnet = await deps.getSubnet();152  if (!subnet) {153    throw new Error(cached ? "host_unreachable" : "subnet_unknown");154  }155156  const found = await deps.probeSubnet(157    subnet,158    port,159    ourKeypair.secretKey,160    ourKeypair.publicKey,161    expectedRemoteHex,162  );163  if (!found) throw new Error("host_unreachable");164165  const session = await deps.openSession(166    found,167    ourKeypair.secretKey,168    ourKeypair.publicKey,169    expectedRemoteHex,170  );171  await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);172  return session;173}174175function hexToBytesLocal(hex: string): Uint8Array {176  const out = new Uint8Array(hex.length / 2);177  for (let i = 0; i < out.length; i++) {178    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);179  }180  return out;181}