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 { 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};6061export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {62 return runWithDeps(device, productionDeps);63}6465666768export async function runWithDeps(69 device: PairedDevice,70 deps: SyncRunnerDeps,71): Promise<SyncRunResult> {72 if (!device.longTermPublicKeyHex) {73 throw new Error("paired_device_missing_pubkey");74 }75 76 77 78 const secretHex = await getPairSecret(device.deviceId, isUnlocked() ? getRootKeyHex() : null);79 if (!secretHex) {80 81 82 83 if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };84 throw new Error("paired_device_missing_rendezvous_secret");85 }86 const expectedRemoteHex = device.longTermPublicKeyHex;87 const reconnectIds = dialingRendezvousIds(88 rendezvousSecretFromHex(secretHex),89 Date.now() / 1000,90 );91 92 93 let masterKey: Uint8Array | null = null;94 let privateKey: Uint8Array | null = null;95 let ourIdentity: SyncPeerIdentity;96 let adapter: SyncVaultAdapter;97 if (isUnlocked()) {98 const masterKeyHex = getRootKeyHex();99 if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };100 masterKey = hexToBytesLocal(masterKeyHex);101 const publicIdentity = await buildLocalIdentity(masterKey);102 const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);103 privateKey = localKeys.secretKey;104 if (!constantTimeEqualHex(publicIdentity.longTermPublicKeyHex, bytesToHex(localKeys.publicKey))) {105 masterKey.fill(0);106 privateKey.fill(0);107 throw new Error("derived reconnect identity mismatch");108 }109 ourIdentity = { ...publicIdentity, longTermPrivateKey: privateKey };110 adapter = deps.createAdapter({ deviceName: device.deviceName });111 } else {112 const cachedIdentity = await getLockedSyncIdentity();113 const cachedAdapter = await createLockedSyncVaultAdapter();114 if (!cachedIdentity || !cachedAdapter) return { ok: false, error: "VAULT_LOCKED" };115 ourIdentity = cachedIdentity;116 privateKey = cachedIdentity.longTermPrivateKey;117 adapter = cachedAdapter;118 }119120 121 void hexToPubkey(expectedRemoteHex);122123 124 let session: SyncSession;125 try {126 const settings = await deps.getSyncSettings();127 const configuredRelay = relayInForce(settings);128 session = await connectRelay(129 device.relayUrl ?? configuredRelay,130 expectedRemoteHex,131 ourIdentity,132 reconnectIds,133 deps.openRelaySession,134 );135 } finally {136 masterKey?.fill(0);137 privateKey?.fill(0);138 }139140 try {141 const outcome = await runSyncInitiator(session, adapter);142 if (outcome.status !== "completed") {143 console.info(144 `[Peach sync] protocol ended ${outcome.status} with ${device.deviceName ?? device.deviceId}${145 outcome.error ? `: ${outcome.error}` : ""146 }`,147 );148 }149 if (outcome.status === "completed") {150 return {151 ok: true,152 entriesReceived: outcome.entriesReceived,153 entriesSent: outcome.entriesSent,154 };155 }156 if (outcome.status === "vault_mismatch") {157 throw new Error("vault_id_mismatch");158 }159 if (outcome.status === "timeout") {160 throw new Error(outcome.error ?? "sync_timeout");161 }162 throw new Error(outcome.error ?? "sync_errored");163 } finally {164 165 166 await session.close().catch(() => undefined);167 }168}169170async function connectRelay(171 relayUrl: string,172 expectedRemoteHex: string,173 ourIdentity: SyncPeerIdentity,174 rendezvousIds: string[],175 openRelaySession: SyncRunnerDeps["openRelaySession"],176): Promise<SyncSession> {177 let lastError: unknown;178 for (const rendezvousId of rendezvousIds) {179 try {180 return await openRelaySession(181 relayUrl,182 ourIdentity,183 expectedRemoteHex,184 rendezvousId,185 );186 } catch (error) {187 lastError = error;188 189 190 191 192 console.info(193 `[Peach sync] relay dial failed — reason=${194 error instanceof Error ? error.message : String(error)195 }`,196 );197 }198 }199 const detail = lastError instanceof Error ? lastError.message : "unreachable";200 throw new Error(`relay_sync_failed:${detail}`);201}202203function hexToBytesLocal(hex: string): Uint8Array {204 const out = new Uint8Array(hex.length / 2);205 for (let i = 0; i < out.length; i++) {206 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);207 }208 return out;209}210211function bytesToHex(bytes: Uint8Array): string {212 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");213}