// 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 XX // 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: // 1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path. // 2. Fall back to `probeSubnetForPeer` over the local /24. The subnet // probe is itself rate-limited (60s per-pubkey cooldown) inside the // transport, so we don't add another layer here. // 3. On a successful session, persist the IP we connected on so the next // sync round skips the probe. // // 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 { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator"; import { createChirpSyncVaultAdapter } from "./chirp-sync-vault-adapter"; import { getLastKnownIp, setLastKnownIp, } from "./paired-devices"; import { openChirpSyncSession, probeSubnetForPeer, type ChirpSyncSession, type SyncEndpoint, } from "../shared/chirp-sync-transport"; import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity"; import { getLocalSubnet } from "../shared/local-network"; import { getOrCreateDeviceId } from "./chirp-identity"; import { getRootKeyHex, isUnlocked } from "./vault"; import type { PairedDevice } from "../shared/sync-types"; import type { SyncRunResult } from "./sync-trigger"; const DEFAULT_PORT = 8765; /** * Test/injection seam. Production wires in the real `openChirpSyncSession`; * unit tests inject a fake to avoid real WebSocket + Noise XX. */ export interface SyncRunnerDeps { openSession: ( endpoint: SyncEndpoint, ourPriv: Uint8Array, ourPub: Uint8Array, expectedRemoteHex: string, ) => Promise; probeSubnet: ( subnet: string, port: number, ourPriv: Uint8Array, ourPub: Uint8Array, expectedRemoteHex: string, ) => Promise; getSubnet: () => Promise; createAdapter: () => ChirpSyncVaultAdapter; } const productionDeps: SyncRunnerDeps = { openSession: (endpoint, priv, pub, expected) => openChirpSyncSession(endpoint, priv, pub, expected), probeSubnet: (subnet, port, priv, pub, expected) => probeSubnetForPeer(subnet, port, priv, pub, expected), getSubnet: getLocalSubnet, createAdapter: createChirpSyncVaultAdapter, }; 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 (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" }; if (!device.longTermPublicKeyHex) { throw new Error("paired_device_missing_pubkey"); } const expectedRemoteHex = device.longTermPublicKeyHex; const port = device.wsPort ?? DEFAULT_PORT; // Sync identity derives from the stable root key, not the password-derived // key — keeps the Noise XX static key constant across password changes (SEV-2-B). const masterKeyHex = getRootKeyHex(); if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" }; const masterKey = hexToBytesLocal(masterKeyHex); const ourDeviceId = await getOrCreateDeviceId(); const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId); // Sanity — the remote pubkey must be 32-byte hex so noise can compare it. void hexToPubkey(expectedRemoteHex); const adapter = deps.createAdapter(); const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps); try { const outcome = await runChirpSyncInitiator(session, adapter); 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("sync_timeout"); } throw new Error(outcome.error ?? "sync_errored"); } finally { // 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 connect( _device: PairedDevice, expectedRemoteHex: string, port: number, ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array }, deps: SyncRunnerDeps, ): Promise { const cached = await getLastKnownIp(expectedRemoteHex); if (cached) { try { const session = await deps.openSession( { ip: cached, port }, ourKeypair.secretKey, ourKeypair.publicKey, expectedRemoteHex, ); return session; } catch { // Cached IP stale — fall through to the subnet probe. } } const subnet = await deps.getSubnet(); if (!subnet) { throw new Error(cached ? "host_unreachable" : "subnet_unknown"); } const found = await deps.probeSubnet( subnet, port, ourKeypair.secretKey, ourKeypair.publicKey, expectedRemoteHex, ); if (!found) throw new Error("host_unreachable"); const session = await deps.openSession( found, ourKeypair.secretKey, ourKeypair.publicKey, expectedRemoteHex, ); await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined); return session; } function hexToBytesLocal(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; }