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 { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";28import { constantTimeEqualHex } from "../shared/constant-time";29import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault";30import { getPairSecret } from "./pair-secrets";31import type { PairedDevice } from "../shared/sync-types";32import type { SyncRunResult } from "./sync-trigger";33import {34 dialingRendezvousIds,35 rendezvousSecretFromHex,36} from "../shared/rendezvous";37import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";383940414243export interface SyncRunnerDeps {44 openRelaySession: (45 relayUrl: string,46 ourIdentity: SyncPeerIdentity,47 expectedRemoteHex: string,48 rendezvousId: string,49 ) => Promise<SyncSession>;50 getSyncSettings: () => Promise<SyncSettings>;51 createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;52}5354const productionDeps: SyncRunnerDeps = {55 openRelaySession: (relayUrl, identity, expected, rendezvousId) =>56 openRelaySyncSession(relayUrl, identity, expected, rendezvousId),57 getSyncSettings,58 createAdapter: (context) => createSyncVaultAdapter(context),59};6061const activeInitiatorSessions = new Set<SyncSession>();626364export function abortSyncRunnerSessions(): void {65 for (const session of activeInitiatorSessions) void session.close().catch(() => undefined);66 activeInitiatorSessions.clear();67}6869export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {70 return runWithDeps(device, productionDeps);71}7273747576export async function runWithDeps(77 device: PairedDevice,78 deps: SyncRunnerDeps,79): Promise<SyncRunResult> {80 if (!device.longTermPublicKeyHex) {81 throw new Error("paired_device_missing_pubkey");82 }83 84 85 86 const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);87 if (!secretHex) {88 89 90 91 if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };92 throw new Error("paired_device_missing_rendezvous_secret");93 }94 const expectedRemoteHex = device.longTermPublicKeyHex;95 const reconnectIds = dialingRendezvousIds(96 rendezvousSecretFromHex(secretHex),97 Date.now() / 1000,98 );99 100 101 let masterKey: Uint8Array | null = null;102 let privateKey: Uint8Array | null = null;103 let ourIdentity: SyncPeerIdentity;104 let adapter: SyncVaultAdapter;105 if (isUnlocked()) {106 const masterKeyHex = getRootKeyHex();107 if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };108 masterKey = hexToBytesLocal(masterKeyHex);109 const publicIdentity = await buildLocalIdentity(masterKey);110 const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);111 privateKey = localKeys.secretKey;112 if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {113 masterKey.fill(0);114 privateKey.fill(0);115 throw new Error("derived reconnect identity mismatch");116 }117 ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };118 adapter = deps.createAdapter({ deviceName: device.deviceName });119 } else {120 const cachedIdentity = await getLockedSyncIdentity();121 const cachedAdapter = await createLockedSyncVaultAdapter();122 if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };123 ourIdentity = cachedIdentity;124 privateKey = cachedIdentity.longTermPrivateKey;125 adapter = cachedAdapter;126 }127128 129 void hexToPubkey(expectedRemoteHex);130131 132 let session: SyncSession;133 try {134 const settings = await deps.getSyncSettings();135 const configuredRelay = relayInForce(settings);136 session = await connectRelay(137 device.relayUrl ?? configuredRelay,138 expectedRemoteHex,139 ourIdentity,140 reconnectIds,141 deps.openRelaySession,142 );143 activeInitiatorSessions.add(session);144 } finally {145 masterKey?.fill(0);146 privateKey?.fill(0);147 }148149 try {150 if (isDecoyMode()) throw new Error("vault_locked");151 const outcome = await runSyncInitiator(session, adapter);152 if (outcome.status !== "completed") {153 console.info(154 `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${155 outcome.error ? `: ${outcome.error}` : ""156 }`,157 );158 }159 if (outcome.status === "completed") {160 return {161 ok: true,162 entriesReceived: outcome.entriesReceived,163 entriesSent: outcome.entriesSent,164 };165 }166 if (outcome.status === "vault_mismatch") {167 throw new Error("vault_id_mismatch");168 }169 if (outcome.status === "timeout") {170 throw new Error(outcome.error ?? "sync_timeout");171 }172 throw new Error(outcome.error ?? "sync_errored");173 } finally {174 activeInitiatorSessions.delete(session);175 176 177 await session.close().catch(() => undefined);178 }179}180181async function connectRelay(182 relayUrl: string,183 expectedRemoteHex: string,184 ourIdentity: SyncPeerIdentity,185 rendezvousIds: string[],186 openRelaySession: SyncRunnerDeps["openRelaySession"],187): Promise<SyncSession> {188 let lastError: unknown;189 for (const rendezvousId of rendezvousIds) {190 try {191 return await openRelaySession(192 relayUrl,193 ourIdentity,194 expectedRemoteHex,195 rendezvousId,196 );197 } catch (error) {198 lastError = error;199 200 201 202 203 console.info(204 `[Peach sync] relay dial failed — reason=${205 error instanceof Error ? error.message : String(error)206 }`,207 );208 }209 }210 const detail = lastError instanceof Error ? lastError.message : "unreachable";211 throw new Error(`relay_sync_failed:${detail}`);212}213214function hexToBytesLocal(hex: string): Uint8Array {215 const out = new Uint8Array(hex.length / 2);216 for (let i = 0; i < out.length; i++) {217 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);218 }219 return out;220}221222function bytesToHex(bytes: Uint8Array): string {223 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");224}