123456789101112131415161718192021import { 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 ProbedSyncEndpoint,31 type SyncSession,32 type SyncEndpoint,33 type SyncPeerIdentity,34} from "../shared/sync-transport";35import { hexToPubkey } from "../shared/sync-identity";36import { getLocalSubnet } from "../shared/local-network";37import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";38import { PAIRING_PORT } from "../shared/pairing-dial";39import { getRootKeyHex, isUnlocked } from "./vault";40import type { PairedDevice } from "../shared/sync-types";41import type { SyncRunResult } from "./sync-trigger";42import {43 dialingRendezvousIds,44 rendezvousSecretFromHex,45} from "../shared/rendezvous";464748const DEFAULT_PORT = PAIRING_PORT;495051525354export interface SyncRunnerDeps {55 openSession: (56 endpoint: SyncEndpoint,57 ourIdentity: SyncPeerIdentity,58 expectedRemoteHex: string,59 rendezvousId: string,60 ) => Promise<SyncSession>;61 probeSubnet: (62 subnet: string,63 port: number,64 ourIdentity: SyncPeerIdentity,65 expectedRemoteHex: string,66 rendezvousIds: readonly string[],67 ) => Promise<ProbedSyncEndpoint | null>;68 getSubnet: () => Promise<string | null>;69 createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;70}7172const productionDeps: SyncRunnerDeps = {73 openSession: (endpoint, identity, expected, rendezvousId) =>74 openSyncSession(endpoint, identity, expected, rendezvousId),75 probeSubnet: (subnet, port, identity, expected, rendezvousIds) =>76 probeSubnetForPeer(subnet, port, identity, expected, rendezvousIds),77 getSubnet: getLocalSubnet,78 createAdapter: (context) => createSyncVaultAdapter(context),79};8081export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {82 return runWithDeps(device, productionDeps);83}8485868788export async function runWithDeps(89 device: PairedDevice,90 deps: SyncRunnerDeps,91): Promise<SyncRunResult> {92 if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };93 if (!device.longTermPublicKeyHex) {94 throw new Error("paired_device_missing_pubkey");95 }96 if (!device.rendezvousSecretHex) {97 throw new Error("paired_device_missing_rendezvous_secret");98 }99 const expectedRemoteHex = device.longTermPublicKeyHex;100 const reconnectIds = dialingRendezvousIds(101 rendezvousSecretFromHex(device.rendezvousSecretHex),102 Date.now() / 1000,103 );104 const port = device.wsPort ?? DEFAULT_PORT;105106 107 108 const masterKeyHex = getRootKeyHex();109 if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };110 const masterKey = hexToBytesLocal(masterKeyHex);111 const publicIdentity = await buildLocalIdentity(masterKey);112 const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);113 if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localKeys.publicKey)) {114 masterKey.fill(0);115 localKeys.secretKey.fill(0);116 throw new Error("derived reconnect identity mismatch");117 }118 const ourIdentity: SyncPeerIdentity = {119 ...publicIdentity,120 longTermPrivateKey: localKeys.secretKey,121 };122123 124 void hexToPubkey(expectedRemoteHex);125126 127 const adapter = deps.createAdapter({ deviceName: device.deviceName });128 let session: SyncSession;129 try {130 session = await connect(expectedRemoteHex, port, ourIdentity, reconnectIds, deps);131 } finally {132 masterKey.fill(0);133 localKeys.secretKey.fill(0);134 }135136 try {137 const outcome = await runSyncInitiator(session, adapter);138 if (outcome.status === "completed") {139 return {140 ok: true,141 entriesReceived: outcome.entriesReceived,142 entriesSent: outcome.entriesSent,143 };144 }145 if (outcome.status === "vault_mismatch") {146 throw new Error("vault_id_mismatch");147 }148 if (outcome.status === "timeout") {149 throw new Error("sync_timeout");150 }151 throw new Error(outcome.error ?? "sync_errored");152 } finally {153 154 155 await session.close().catch(() => undefined);156 }157}158159async function connect(160 expectedRemoteHex: string,161 port: number,162 ourIdentity: SyncPeerIdentity,163 rendezvousIds: string[],164 deps: SyncRunnerDeps,165): Promise<SyncSession> {166 const cached = await getLastKnownIp(expectedRemoteHex);167 if (cached) {168 for (const rendezvousId of rendezvousIds) {169 try {170 const session = await deps.openSession(171 { ip: cached, port },172 ourIdentity,173 expectedRemoteHex,174 rendezvousId,175 );176 return session;177 } catch {178 179 }180 }181 }182183 const subnet = await deps.getSubnet();184 if (!subnet) {185 throw new Error(cached ? "host_unreachable" : "subnet_unknown");186 }187188 const found = await deps.probeSubnet(189 subnet,190 port,191 ourIdentity,192 expectedRemoteHex,193 rendezvousIds,194 );195 if (!found) throw new Error("host_unreachable");196 const session = await deps.openSession(197 { ip: found.ip, port: found.port },198 ourIdentity,199 expectedRemoteHex,200 found.rendezvousId,201 );202 await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);203 return session;204}205206function hexToBytesLocal(hex: string): Uint8Array {207 const out = new Uint8Array(hex.length / 2);208 for (let i = 0; i < out.length; i++) {209 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);210 }211 return out;212}213214function bytesToHex(bytes: Uint8Array): string {215 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");216}