123456789101112131415161718import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";19import { createSyncVaultAdapter } from "./sync-vault-adapter";20import {21 openRelaySyncSession,22 type SyncSession,23 type SyncPeerIdentity,24} from "../shared/sync-transport";25import { hexToPubkey } from "../shared/sync-identity";26import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";27import { constantTimeEqualHex } from "../shared/constant-time";28import { getRootKeyHex, isUnlocked } from "./vault";29import type { PairedDevice } from "../shared/sync-types";30import type { SyncRunResult } from "./sync-trigger";31import {32 dialingRendezvousIds,33 rendezvousSecretFromHex,34} from "../shared/rendezvous";35import { getSyncSettings, type SyncSettings } from "./sync-settings";363738394041export interface SyncRunnerDeps {42 openRelaySession: (43 relayUrl: string,44 ourIdentity: SyncPeerIdentity,45 expectedRemoteHex: string,46 rendezvousId: string,47 ) => Promise<SyncSession>;48 getSyncSettings: () => Promise<SyncSettings>;49 createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;50}5152const productionDeps: SyncRunnerDeps = {53 openRelaySession: (relayUrl, identity, expected, rendezvousId) =>54 openRelaySyncSession(relayUrl, identity, expected, rendezvousId),55 getSyncSettings,56 createAdapter: (context) => createSyncVaultAdapter(context),57};5859export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {60 return runWithDeps(device, productionDeps);61}6263646566export async function runWithDeps(67 device: PairedDevice,68 deps: SyncRunnerDeps,69): Promise<SyncRunResult> {70 if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };71 if (!device.longTermPublicKeyHex) {72 throw new Error("paired_device_missing_pubkey");73 }74 if (!device.rendezvousSecretHex) {75 throw new Error("paired_device_missing_rendezvous_secret");76 }77 const expectedRemoteHex = device.longTermPublicKeyHex;78 const reconnectIds = dialingRendezvousIds(79 rendezvousSecretFromHex(device.rendezvousSecretHex),80 Date.now() / 1000,81 );82 83 84 const masterKeyHex = getRootKeyHex();85 if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };86 const masterKey = hexToBytesLocal(masterKeyHex);87 const publicIdentity = await buildLocalIdentity(masterKey);88 const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);89 if (!constantTimeEqualHex(90 publicIdentity.longTermPublicKeyHex,91 bytesToHex(localKeys.publicKey),92 )) {93 masterKey.fill(0);94 localKeys.secretKey.fill(0);95 throw new Error("derived reconnect identity mismatch");96 }97 const ourIdentity: SyncPeerIdentity = {98 ...publicIdentity,99 longTermPrivateKey: localKeys.secretKey,100 };101102 103 void hexToPubkey(expectedRemoteHex);104105 106 const adapter = deps.createAdapter({ deviceName: device.deviceName });107 let session: SyncSession;108 try {109 const settings = await deps.getSyncSettings();110 if (settings.mode === "off") throw new Error("sync_disabled");111 session = await connectRelay(112 device.relayUrl ?? settings.relayUrl,113 expectedRemoteHex,114 ourIdentity,115 reconnectIds,116 deps.openRelaySession,117 );118 } finally {119 masterKey.fill(0);120 localKeys.secretKey.fill(0);121 }122123 try {124 const outcome = await runSyncInitiator(session, adapter);125 if (outcome.status === "completed") {126 return {127 ok: true,128 entriesReceived: outcome.entriesReceived,129 entriesSent: outcome.entriesSent,130 };131 }132 if (outcome.status === "vault_mismatch") {133 throw new Error("vault_id_mismatch");134 }135 if (outcome.status === "timeout") {136 throw new Error("sync_timeout");137 }138 throw new Error(outcome.error ?? "sync_errored");139 } finally {140 141 142 await session.close().catch(() => undefined);143 }144}145146async function connectRelay(147 relayUrl: string,148 expectedRemoteHex: string,149 ourIdentity: SyncPeerIdentity,150 rendezvousIds: string[],151 openRelaySession: SyncRunnerDeps["openRelaySession"],152): Promise<SyncSession> {153 let lastError: unknown;154 for (const rendezvousId of rendezvousIds) {155 try {156 return await openRelaySession(157 relayUrl,158 ourIdentity,159 expectedRemoteHex,160 rendezvousId,161 );162 } catch (error) {163 lastError = error;164 }165 }166 const detail = lastError instanceof Error ? lastError.message : "unreachable";167 throw new Error(`relay_sync_failed:${detail}`);168}169170function hexToBytesLocal(hex: string): Uint8Array {171 const out = new Uint8Array(hex.length / 2);172 for (let i = 0; i < out.length; i++) {173 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);174 }175 return out;176}177178function bytesToHex(bytes: Uint8Array): string {179 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");180}