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 } from "./vault";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};5960export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {61 return runWithDeps(device, productionDeps);62}6364656667export async function runWithDeps(68 device: PairedDevice,69 deps: SyncRunnerDeps,70): Promise<SyncRunResult> {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 let masterKey: Uint8Array | null = null;85 let privateKey: Uint8Array | null = null;86 let ourIdentity: SyncPeerIdentity;87 let adapter: SyncVaultAdapter;88 if (isUnlocked()) {89 const masterKeyHex = getRootKeyHex();90 if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };91 masterKey = hexToBytesLocal(masterKeyHex);92 const publicIdentity = await buildLocalIdentity(masterKey);93 const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);94 privateKey = localKeys.secretKey;95 if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {96 masterKey.fill(0);97 privateKey.fill(0);98 throw new Error("derived reconnect identity mismatch");99 }100 ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };101 adapter = deps.createAdapter({ deviceName: device.deviceName });102 } else {103 const cachedIdentity = await getLockedSyncIdentity();104 const cachedAdapter = await createLockedSyncVaultAdapter();105 if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };106 ourIdentity = cachedIdentity;107 privateKey = cachedIdentity.longTermPrivateKey;108 adapter = cachedAdapter;109 }110111 112 void hexToPubkey(expectedRemoteHex);113114 115 let session: SyncSession;116 try {117 const settings = await deps.getSyncSettings();118 const configuredRelay = relayInForce(settings);119 session = await connectRelay(120 device.relayUrl ?? configuredRelay,121 expectedRemoteHex,122 ourIdentity,123 reconnectIds,124 deps.openRelaySession,125 );126 } finally {127 masterKey?.fill(0);128 privateKey?.fill(0);129 }130131 try {132 const outcome = await runSyncInitiator(session, adapter);133 if (outcome.status !== "completed") {134 console.info(135 `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${136 outcome.error ? `: ${outcome.error}` : ""137 }`,138 );139 }140 if (outcome.status === "completed") {141 return {142 ok: true,143 entriesReceived: outcome.entriesReceived,144 entriesSent: outcome.entriesSent,145 };146 }147 if (outcome.status === "vault_mismatch") {148 throw new Error("vault_id_mismatch");149 }150 if (outcome.status === "timeout") {151 throw new Error(outcome.error ?? "sync_timeout");152 }153 throw new Error(outcome.error ?? "sync_errored");154 } finally {155 156 157 await session.close().catch(() => undefined);158 }159}160161async function connectRelay(162 relayUrl: string,163 expectedRemoteHex: string,164 ourIdentity: SyncPeerIdentity,165 rendezvousIds: string[],166 openRelaySession: SyncRunnerDeps["openRelaySession"],167): Promise<SyncSession> {168 let lastError: unknown;169 for (const rendezvousId of rendezvousIds) {170 try {171 return await openRelaySession(172 relayUrl,173 ourIdentity,174 expectedRemoteHex,175 rendezvousId,176 );177 } catch (error) {178 lastError = error;179 180 181 182 183 console.info(184 `[Peach sync] relay dial failed — reason=${185 error instanceof Error ? error.message : String(error)186 }`,187 );188 }189 }190 const detail = lastError instanceof Error ? lastError.message : "unreachable";191 throw new Error(`relay_sync_failed:${detail}`);192}193194function hexToBytesLocal(hex: string): Uint8Array {195 const out = new Uint8Array(hex.length / 2);196 for (let i = 0; i < out.length; i++) {197 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);198 }199 return out;200}201202function bytesToHex(bytes: Uint8Array): string {203 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");204}