123456789101112131415161718import { 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 { getLocalPairingIdentity } from "./device-identity";28import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault";29import { getPairSecret } from "./pair-secrets";30import type { PairedDevice } from "../shared/sync-types";31import type { SyncRunResult } from "./sync-trigger";32import {33 dialingRendezvousIds,34 rendezvousSecretFromHex,35} from "../shared/rendezvous";36import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";373839404142export interface SyncRunnerDeps {43 openRelaySession: (44 relayUrl: string,45 ourIdentity: SyncPeerIdentity,46 expectedRemoteHex: string,47 rendezvousId: string,48 ) => Promise<SyncSession>;49 getSyncSettings: () => Promise<SyncSettings>;50 createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;51}5253const productionDeps: SyncRunnerDeps = {54 openRelaySession: (relayUrl, identity, expected, rendezvousId) =>55 openRelaySyncSession(relayUrl, identity, expected, rendezvousId),56 getSyncSettings,57 createAdapter: (context) => createSyncVaultAdapter(context),58};5960const activeInitiatorSessions = new Set<SyncSession>();616263export function abortSyncRunnerSessions(): void {64 for (const session of activeInitiatorSessions) void session.close().catch(() => undefined);65 activeInitiatorSessions.clear();66}6768export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {69 return runWithDeps(device, productionDeps);70}7172737475export async function runWithDeps(76 device: PairedDevice,77 deps: SyncRunnerDeps,78): Promise<SyncRunResult> {79 if (!device.longTermPublicKeyHex) {80 throw new Error("paired_device_missing_pubkey");81 }82 83 84 85 const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);86 if (!secretHex) {87 88 89 90 if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };91 throw new Error("paired_device_missing_rendezvous_secret");92 }93 const expectedRemoteHex = device.longTermPublicKeyHex;94 const reconnectIds = dialingRendezvousIds(95 rendezvousSecretFromHex(secretHex),96 Date.now() / 1000,97 );98 let privateKey: Uint8Array | null = null;99 let ourIdentity: SyncPeerIdentity;100 let adapter: SyncVaultAdapter;101 if (isUnlocked()) {102 const localPairing = await getLocalPairingIdentity();103 const publicIdentity = localPairing.identity;104 privateKey = localPairing.privateKey;105 if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localPairing.publicKey)) {106 privateKey.fill(0);107 localPairing.publicKey.fill(0);108 return { ok: false, error: "IDENTITY_MISMATCH" };109 }110 ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };111 adapter = deps.createAdapter({ deviceName: device.deviceName });112 } else {113 const cachedIdentity = await getLockedSyncIdentity();114 const cachedAdapter = await createLockedSyncVaultAdapter();115 if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };116 ourIdentity = cachedIdentity;117 privateKey = cachedIdentity.longTermPrivateKey;118 adapter = cachedAdapter;119 }120121 122 void hexToPubkey(expectedRemoteHex);123124 125 let session: SyncSession;126 try {127 const settings = await deps.getSyncSettings();128 const configuredRelay = relayInForce(settings);129 session = await connectRelay(130 device.relayUrl ?? configuredRelay,131 expectedRemoteHex,132 ourIdentity,133 reconnectIds,134 deps.openRelaySession,135 );136 activeInitiatorSessions.add(session);137 } finally {138 privateKey?.fill(0);139 }140141 try {142 if (isDecoyMode()) throw new Error("vault_locked");143 const outcome = await runSyncInitiator(session, adapter);144 if (outcome.status !== "completed") {145 console.info(146 `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${147 outcome.error ? `: ${outcome.error}` : ""148 }`,149 );150 }151 if (outcome.status === "completed") {152 return {153 ok: true,154 entriesReceived: outcome.entriesReceived,155 entriesSent: outcome.entriesSent,156 };157 }158 if (outcome.status === "vault_mismatch") {159 throw new Error("vault_id_mismatch");160 }161 if (outcome.status === "timeout") {162 throw new Error(outcome.error ?? "sync_timeout");163 }164 throw new Error(outcome.error ?? "sync_errored");165 } finally {166 activeInitiatorSessions.delete(session);167 168 169 await session.close().catch(() => undefined);170 }171}172173async function connectRelay(174 relayUrl: string,175 expectedRemoteHex: string,176 ourIdentity: SyncPeerIdentity,177 rendezvousIds: string[],178 openRelaySession: SyncRunnerDeps["openRelaySession"],179): Promise<SyncSession> {180 let lastError: unknown;181 for (const rendezvousId of rendezvousIds) {182 try {183 return await openRelaySession(184 relayUrl,185 ourIdentity,186 expectedRemoteHex,187 rendezvousId,188 );189 } catch (error) {190 lastError = error;191 192 193 194 195 console.info(196 `[Peach sync] relay dial failed — reason=${197 error instanceof Error ? error.message : String(error)198 }`,199 );200 }201 }202 const detail = lastError instanceof Error ? lastError.message : "unreachable";203 throw new Error(`relay_sync_failed:${detail}`);204}205206207function bytesToHex(bytes: Uint8Array): string {208 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");209}