// Production sync runner — composes the live vault adapter, WebSocket // transport, and coordinator into the function the dispatcher injects via // `setSyncRunner`. The runner is responsible for opening one Noise KK // session against a single paired device, driving the sync protocol, and // surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in // sync-trigger.ts iterates over hosts and aggregates results. // // Connection strategy: dial the mesh's relay, trying each clock-skew // rendezvous window in turn. There is one transport. LAN peer-to-peer sync is // retired (docs/sync-lan-retirement.md), so there is no address cache, no // subnet probe and no fallback ladder to reason about. // // We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange // does that (returns `status: "vault_mismatch"` if the peer holds a // different vault). The runner just translates the coordinator's outcome // into the throw-or-return contract the dispatcher expects. import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator"; import { createSyncVaultAdapter } from "./sync-vault-adapter"; import { createLockedSyncVaultAdapter, getLockedSyncIdentity } from "./locked-sync-spool"; import { openRelaySyncSession, type SyncSession, type SyncPeerIdentity, } from "../shared/sync-transport"; import { hexToPubkey } from "../shared/sync-identity"; import { getLocalPairingIdentity } from "./device-identity"; import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault"; import { getPairSecret } from "./pair-secrets"; import type { PairedDevice } from "../shared/sync-types"; import type { SyncRunResult } from "./sync-trigger"; import { dialingRendezvousIds, rendezvousSecretFromHex, } from "../shared/rendezvous"; import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings"; /** * Test/injection seam. Production wires in the real `openRelaySyncSession`; * unit tests inject a fake to avoid real WebSocket + Noise KK. */ export interface SyncRunnerDeps { openRelaySession: ( relayUrl: string, ourIdentity: SyncPeerIdentity, expectedRemoteHex: string, rendezvousId: string, ) => Promise; getSyncSettings: () => Promise; createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter; } const productionDeps: SyncRunnerDeps = { openRelaySession: (relayUrl, identity, expected, rendezvousId) => openRelaySyncSession(relayUrl, identity, expected, rendezvousId), getSyncSettings, createAdapter: (context) => createSyncVaultAdapter(context), }; const activeInitiatorSessions = new Set(); /** Close initiator sessions that survived into a decoy-vault transition. */ export function abortSyncRunnerSessions(): void { for (const session of activeInitiatorSessions) void session.close().catch(() => undefined); activeInitiatorSessions.clear(); } export async function productionSyncRunner(device: PairedDevice): Promise { return runWithDeps(device, productionDeps); } /** * Inner runner with explicit deps — exported so tests can inject fakes. */ export async function runWithDeps( device: PairedDevice, deps: SyncRunnerDeps, ): Promise { if (!device.longTermPublicKeyHex) { throw new Error("paired_device_missing_pubkey"); } // PB-26-213: the secret resolves from the encrypted pair-secret record // (unlocked) or the trusted-session copy (locked), never from the device // row — the public registry carries no reusable secrets. const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null); if (!secretHex) { // Locked without a session copy: dormant until the next unlock, same as // any other locked-vault sync attempt. Unlocked without a secret is a // corrupt pairing that must surface loudly. if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" }; throw new Error("paired_device_missing_rendezvous_secret"); } const expectedRemoteHex = device.longTermPublicKeyHex; const reconnectIds = dialingRendezvousIds( rendezvousSecretFromHex(secretHex), Date.now() / 1000, ); let privateKey: Uint8Array | null = null; let ourIdentity: SyncPeerIdentity; let adapter: SyncVaultAdapter; if (isUnlocked()) { const localPairing = await getLocalPairingIdentity(); const publicIdentity = localPairing.identity; privateKey = localPairing.privateKey; if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localPairing.publicKey)) { privateKey.fill(0); localPairing.publicKey.fill(0); return { ok: false, error: "IDENTITY_MISMATCH" }; } ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey }; adapter = deps.createAdapter({ deviceName: device.deviceName }); } else { const cachedIdentity = await getLockedSyncIdentity(); const cachedAdapter = await createLockedSyncVaultAdapter(); if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" }; ourIdentity = cachedIdentity; privateKey = cachedIdentity.longTermPrivateKey; adapter = cachedAdapter; } // Sanity — the remote pubkey must be 32-byte hex so noise can compare it. void hexToPubkey(expectedRemoteHex); // HV-1b: pass the peer being synced so remote-mutation alerts can name it. let session: SyncSession; try { const settings = await deps.getSyncSettings(); const configuredRelay = relayInForce(settings); session = await connectRelay( device.relayUrl ?? configuredRelay, expectedRemoteHex, ourIdentity, reconnectIds, deps.openRelaySession, ); activeInitiatorSessions.add(session); } finally { privateKey?.fill(0); } try { if (isDecoyMode()) throw new Error("vault_locked"); const outcome = await runSyncInitiator(session, adapter); if (outcome.status !== "completed") { console.info( `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${ outcome.error ? `: ${outcome.error}` : "" }`, ); } if (outcome.status === "completed") { return { ok: true, entriesReceived: outcome.entriesReceived, entriesSent: outcome.entriesSent, }; } if (outcome.status === "vault_mismatch") { throw new Error("vault_id_mismatch"); } if (outcome.status === "timeout") { throw new Error(outcome.error ?? "sync_timeout"); } throw new Error(outcome.error ?? "sync_errored"); } finally { activeInitiatorSessions.delete(session); // The coordinator does NOT close the session (it returns and leaves the // session open). This runner is the sole owner of session lifetime. await session.close().catch(() => undefined); } } async function connectRelay( relayUrl: string, expectedRemoteHex: string, ourIdentity: SyncPeerIdentity, rendezvousIds: string[], openRelaySession: SyncRunnerDeps["openRelaySession"], ): Promise { let lastError: unknown; for (const rendezvousId of rendezvousIds) { try { return await openRelaySession( relayUrl, ourIdentity, expectedRemoteHex, rendezvousId, ); } catch (error) { lastError = error; // Each clock-skew window fails separately and only the last one survives // into the thrown message; on a real device the earlier ones are often // the informative ones. The URL and rendezvous capability are // deliberately omitted because either may reveal private routing state. console.info( `[Peach sync] relay dial failed — reason=${ error instanceof Error ? error.message : String(error) }`, ); } } const detail = lastError instanceof Error ? lastError.message : "unreachable"; throw new Error(`relay_sync_failed:${detail}`); } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }