sync-runner.ts

6.1 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 NK4// 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 { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";22import { createSyncVaultAdapter } from "./sync-vault-adapter";23import {24  getLastKnownIp,25  setLastKnownIp,26} from "./paired-devices";27import {28  openSyncSession,29  probeSubnetForPeer,30  type SyncSession,31  type SyncEndpoint,32  type SyncPeerIdentity,33} from "../shared/sync-transport";34import { hexToPubkey } from "../shared/sync-identity";35import { getLocalSubnet } from "../shared/local-network";36import { buildLocalIdentity } from "./device-identity";37import { PAIRING_PORT } from "../shared/pairing-dial";38import { getRootKeyHex, isUnlocked } from "./vault";39import type { PairedDevice } from "../shared/sync-types";40import type { SyncRunResult } from "./sync-trigger";4142/** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */43const DEFAULT_PORT = PAIRING_PORT;4445/**46 * Test/injection seam. Production wires in the real `openSyncSession`;47 * unit tests inject a fake to avoid real WebSocket + Noise NK.48 */49export interface SyncRunnerDeps {50  openSession: (51    endpoint: SyncEndpoint,52    ourIdentity: SyncPeerIdentity,53    expectedRemoteHex: string,54  ) => Promise<SyncSession>;55  probeSubnet: (56    subnet: string,57    port: number,58    ourIdentity: SyncPeerIdentity,59    expectedRemoteHex: string,60  ) => Promise<SyncEndpoint | null>;61  getSubnet: () => Promise<string | null>;62  createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;63}6465const productionDeps: SyncRunnerDeps = {66  openSession: (endpoint, identity, expected) =>67    openSyncSession(endpoint, identity, expected),68  probeSubnet: (subnet, port, identity, expected) =>69    probeSubnetForPeer(subnet, port, identity, expected),70  getSubnet: getLocalSubnet,71  createAdapter: (context) => createSyncVaultAdapter(context),72};7374export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {75  return runWithDeps(device, productionDeps);76}7778/**79 * Inner runner with explicit deps — exported so tests can inject fakes.80 */81export async function runWithDeps(82  device: PairedDevice,83  deps: SyncRunnerDeps,84): Promise<SyncRunResult> {85  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };86  if (!device.longTermPublicKeyHex) {87    throw new Error("paired_device_missing_pubkey");88  }89  const expectedRemoteHex = device.longTermPublicKeyHex;90  const port = device.wsPort ?? DEFAULT_PORT;9192  // Sync identity derives from the stable root key, not the password-derived93  // key — keeps the long-term static key constant across password changes94  // (SEV-2-B). NK's initiator side has no static keypair in the handshake95  // itself; `ourIdentity` is only what we assert in the PAIR_IDENTITY frame.96  const masterKeyHex = getRootKeyHex();97  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };98  const masterKey = hexToBytesLocal(masterKeyHex);99  const ourIdentity = await buildLocalIdentity(masterKey);100101  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.102  void hexToPubkey(expectedRemoteHex);103104  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.105  const adapter = deps.createAdapter({ deviceName: device.deviceName });106  const session = await connect(expectedRemoteHex, port, ourIdentity, deps);107108  try {109    const outcome = await runSyncInitiator(session, adapter);110    if (outcome.status === "completed") {111      return {112        ok: true,113        entriesReceived: outcome.entriesReceived,114        entriesSent: outcome.entriesSent,115      };116    }117    if (outcome.status === "vault_mismatch") {118      throw new Error("vault_id_mismatch");119    }120    if (outcome.status === "timeout") {121      throw new Error("sync_timeout");122    }123    throw new Error(outcome.error ?? "sync_errored");124  } finally {125    // The coordinator does NOT close the session (it returns and leaves the126    // session open). This runner is the sole owner of session lifetime.127    await session.close().catch(() => undefined);128  }129}130131async function connect(132  expectedRemoteHex: string,133  port: number,134  ourIdentity: SyncPeerIdentity,135  deps: SyncRunnerDeps,136): Promise<SyncSession> {137  const cached = await getLastKnownIp(expectedRemoteHex);138  if (cached) {139    try {140      const session = await deps.openSession(141        { ip: cached, port },142        ourIdentity,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(subnet, port, ourIdentity, expectedRemoteHex);157  if (!found) throw new Error("host_unreachable");158159  const session = await deps.openSession(found, ourIdentity, expectedRemoteHex);160  await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);161  return session;162}163164function hexToBytesLocal(hex: string): Uint8Array {165  const out = new Uint8Array(hex.length / 2);166  for (let i = 0; i < out.length; i++) {167    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);168  }169  return out;170}