sync-runner.ts

8.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 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 { getPairSecret } from "./pair-secrets";31import type { PairedDevice } from "../shared/sync-types";32import type { SyncRunResult } from "./sync-trigger";33import {34  dialingRendezvousIds,35  rendezvousSecretFromHex,36} from "../shared/rendezvous";37import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";3839/**40 * Test/injection seam. Production wires in the real `openRelaySyncSession`;41 * unit tests inject a fake to avoid real WebSocket + Noise KK.42 */43export interface SyncRunnerDeps {44  openRelaySession: (45    relayUrl: string,46    ourIdentity: SyncPeerIdentity,47    expectedRemoteHex: string,48    rendezvousId: string,49  ) => Promise<SyncSession>;50  getSyncSettings: () => Promise<SyncSettings>;51  createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;52}5354const productionDeps: SyncRunnerDeps = {55  openRelaySession: (relayUrl, identity, expected, rendezvousId) =>56    openRelaySyncSession(relayUrl, identity, expected, rendezvousId),57  getSyncSettings,58  createAdapter: (context) => createSyncVaultAdapter(context),59};6061export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {62  return runWithDeps(device, productionDeps);63}6465/**66 * Inner runner with explicit deps — exported so tests can inject fakes.67 */68export async function runWithDeps(69  device: PairedDevice,70  deps: SyncRunnerDeps,71): Promise<SyncRunResult> {72  if (!device.longTermPublicKeyHex) {73    throw new Error("paired_device_missing_pubkey");74  }75  // PB-26-213: the secret resolves from the encrypted pair-secret record76  // (unlocked) or the trusted-session copy (locked), never from the device77  // row — the public registry carries no reusable secrets.78  const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);79  if (!secretHex) {80    // Locked without a session copy: dormant until the next unlock, same as81    // any other locked-vault sync attempt. Unlocked without a secret is a82    // corrupt pairing that must surface loudly.83    if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };84    throw new Error("paired_device_missing_rendezvous_secret");85  }86  const expectedRemoteHex = device.longTermPublicKeyHex;87  const reconnectIds = dialingRendezvousIds(88    rendezvousSecretFromHex(secretHex),89    Date.now() / 1000,90  );91  // Sync identity derives from the stable root key, not the password-derived92  // key. Reconnect uses both halves directly in standard Noise KK.93  let masterKey: Uint8Array | null = null;94  let privateKey: Uint8Array | null = null;95  let ourIdentity: SyncPeerIdentity;96  let adapter: SyncVaultAdapter;97  if (isUnlocked()) {98    const masterKeyHex = getRootKeyHex();99    if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };100    masterKey = hexToBytesLocal(masterKeyHex);101    const publicIdentity = await buildLocalIdentity(masterKey);102    const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);103    privateKey = localKeys.secretKey;104    if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {105      masterKey.fill(0);106      privateKey.fill(0);107      throw new Error("derived reconnect identity mismatch");108    }109    ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };110    adapter = deps.createAdapter({ deviceName: device.deviceName });111  } else {112    const cachedIdentity = await getLockedSyncIdentity();113    const cachedAdapter = await createLockedSyncVaultAdapter();114    if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };115    ourIdentity = cachedIdentity;116    privateKey = cachedIdentity.longTermPrivateKey;117    adapter = cachedAdapter;118  }119120  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.121  void hexToPubkey(expectedRemoteHex);122123  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.124  let session: SyncSession;125  try {126    const settings = await deps.getSyncSettings();127    const configuredRelay = relayInForce(settings);128    session = await connectRelay(129      device.relayUrl ?? configuredRelay,130      expectedRemoteHex,131      ourIdentity,132      reconnectIds,133      deps.openRelaySession,134    );135  } finally {136    masterKey?.fill(0);137    privateKey?.fill(0);138  }139140  try {141    const outcome = await runSyncInitiator(session, adapter);142    if (outcome.status !== "completed") {143      console.info(144        `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${145          outcome.error ? `: ${outcome.error}` : ""146        }`,147      );148    }149    if (outcome.status === "completed") {150      return {151        ok: true,152        entriesReceived: outcome.entriesReceived,153        entriesSent: outcome.entriesSent,154      };155    }156    if (outcome.status === "vault_mismatch") {157      throw new Error("vault_id_mismatch");158    }159    if (outcome.status === "timeout") {160      throw new Error(outcome.error ?? "sync_timeout");161    }162    throw new Error(outcome.error ?? "sync_errored");163  } finally {164    // The coordinator does NOT close the session (it returns and leaves the165    // session open). This runner is the sole owner of session lifetime.166    await session.close().catch(() => undefined);167  }168}169170async function connectRelay(171  relayUrl: string,172  expectedRemoteHex: string,173  ourIdentity: SyncPeerIdentity,174  rendezvousIds: string[],175  openRelaySession: SyncRunnerDeps["openRelaySession"],176): Promise<SyncSession> {177  let lastError: unknown;178  for (const rendezvousId of rendezvousIds) {179    try {180      return await openRelaySession(181        relayUrl,182        ourIdentity,183        expectedRemoteHex,184        rendezvousId,185      );186    } catch (error) {187      lastError = error;188      // Each clock-skew window fails separately and only the last one survives189      // into the thrown message; on a real device the earlier ones are often190      // the informative ones. The URL and rendezvous capability are191      // deliberately omitted because either may reveal private routing state.192      console.info(193        `[Peach sync] relay dial failed — reason=${194          error instanceof Error ? error.message : String(error)195        }`,196      );197    }198  }199  const detail = lastError instanceof Error ? lastError.message : "unreachable";200  throw new Error(`relay_sync_failed:${detail}`);201}202203function hexToBytesLocal(hex: string): Uint8Array {204  const out = new Uint8Array(hex.length / 2);205  for (let i = 0; i < out.length; i++) {206    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);207  }208  return out;209}210211function bytesToHex(bytes: Uint8Array): string {212  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");213}