sync-runner.ts

8.6 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, isDecoyMode } 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};6061const activeInitiatorSessions = new Set<SyncSession>();6263/** Close initiator sessions that survived into a decoy-vault transition. */64export function abortSyncRunnerSessions(): void {65  for (const session of activeInitiatorSessions) void session.close().catch(() => undefined);66  activeInitiatorSessions.clear();67}6869export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {70  return runWithDeps(device, productionDeps);71}7273/**74 * Inner runner with explicit deps — exported so tests can inject fakes.75 */76export async function runWithDeps(77  device: PairedDevice,78  deps: SyncRunnerDeps,79): Promise<SyncRunResult> {80  if (!device.longTermPublicKeyHex) {81    throw new Error("paired_device_missing_pubkey");82  }83  // PB-26-213: the secret resolves from the encrypted pair-secret record84  // (unlocked) or the trusted-session copy (locked), never from the device85  // row — the public registry carries no reusable secrets.86  const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);87  if (!secretHex) {88    // Locked without a session copy: dormant until the next unlock, same as89    // any other locked-vault sync attempt. Unlocked without a secret is a90    // corrupt pairing that must surface loudly.91    if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };92    throw new Error("paired_device_missing_rendezvous_secret");93  }94  const expectedRemoteHex = device.longTermPublicKeyHex;95  const reconnectIds = dialingRendezvousIds(96    rendezvousSecretFromHex(secretHex),97    Date.now() / 1000,98  );99  // Sync identity derives from the stable root key, not the password-derived100  // key. Reconnect uses both halves directly in standard Noise KK.101  let masterKey: Uint8Array | null = null;102  let privateKey: Uint8Array | null = null;103  let ourIdentity: SyncPeerIdentity;104  let adapter: SyncVaultAdapter;105  if (isUnlocked()) {106    const masterKeyHex = getRootKeyHex();107    if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };108    masterKey = hexToBytesLocal(masterKeyHex);109    const publicIdentity = await buildLocalIdentity(masterKey);110    const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);111    privateKey = localKeys.secretKey;112    if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {113      masterKey.fill(0);114      privateKey.fill(0);115      throw new Error("derived reconnect identity mismatch");116    }117    ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };118    adapter = deps.createAdapter({ deviceName: device.deviceName });119  } else {120    const cachedIdentity = await getLockedSyncIdentity();121    const cachedAdapter = await createLockedSyncVaultAdapter();122    if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };123    ourIdentity = cachedIdentity;124    privateKey = cachedIdentity.longTermPrivateKey;125    adapter = cachedAdapter;126  }127128  // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.129  void hexToPubkey(expectedRemoteHex);130131  // HV-1b: pass the peer being synced so remote-mutation alerts can name it.132  let session: SyncSession;133  try {134    const settings = await deps.getSyncSettings();135    const configuredRelay = relayInForce(settings);136    session = await connectRelay(137      device.relayUrl ?? configuredRelay,138      expectedRemoteHex,139      ourIdentity,140      reconnectIds,141      deps.openRelaySession,142    );143    activeInitiatorSessions.add(session);144  } finally {145    masterKey?.fill(0);146    privateKey?.fill(0);147  }148149  try {150    if (isDecoyMode()) throw new Error("vault_locked");151    const outcome = await runSyncInitiator(session, adapter);152    if (outcome.status !== "completed") {153      console.info(154        `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${155          outcome.error ? `: ${outcome.error}` : ""156        }`,157      );158    }159    if (outcome.status === "completed") {160      return {161        ok: true,162        entriesReceived: outcome.entriesReceived,163        entriesSent: outcome.entriesSent,164      };165    }166    if (outcome.status === "vault_mismatch") {167      throw new Error("vault_id_mismatch");168    }169    if (outcome.status === "timeout") {170      throw new Error(outcome.error ?? "sync_timeout");171    }172    throw new Error(outcome.error ?? "sync_errored");173  } finally {174    activeInitiatorSessions.delete(session);175    // The coordinator does NOT close the session (it returns and leaves the176    // session open). This runner is the sole owner of session lifetime.177    await session.close().catch(() => undefined);178  }179}180181async function connectRelay(182  relayUrl: string,183  expectedRemoteHex: string,184  ourIdentity: SyncPeerIdentity,185  rendezvousIds: string[],186  openRelaySession: SyncRunnerDeps["openRelaySession"],187): Promise<SyncSession> {188  let lastError: unknown;189  for (const rendezvousId of rendezvousIds) {190    try {191      return await openRelaySession(192        relayUrl,193        ourIdentity,194        expectedRemoteHex,195        rendezvousId,196      );197    } catch (error) {198      lastError = error;199      // Each clock-skew window fails separately and only the last one survives200      // into the thrown message; on a real device the earlier ones are often201      // the informative ones. The URL and rendezvous capability are202      // deliberately omitted because either may reveal private routing state.203      console.info(204        `[Peach sync] relay dial failed — reason=${205          error instanceof Error ? error.message : String(error)206        }`,207      );208    }209  }210  const detail = lastError instanceof Error ? lastError.message : "unreachable";211  throw new Error(`relay_sync_failed:${detail}`);212}213214function hexToBytesLocal(hex: string): Uint8Array {215  const out = new Uint8Array(hex.length / 2);216  for (let i = 0; i < out.length; i++) {217    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);218  }219  return out;220}221222function bytesToHex(bytes: Uint8Array): string {223  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");224}