123456789101112131415161718192021import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";22import { SYNC_LIMITS } from "./sync-limits";23import { hexToPubkey, pubkeyToHex } from "./sync-identity";2425const DEFAULT_PROBE_TIMEOUT_MS = 500;26const DEFAULT_PROBE_BATCH = 16;27const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;2829export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3031export interface SyncSession {32 send(plaintext: Uint8Array): void;33 receive(): Promise<Uint8Array>;34 close(): Promise<void>;35 remoteStaticPublicKey: Uint8Array;36}3738export interface SyncEndpoint {39 ip: string;40 port: number;41}42434445464748495051export async function openSyncSession(52 endpoint: SyncEndpoint,53 ourLongTermPrivateKey: Uint8Array,54 ourLongTermPublicKey: Uint8Array,55 expectedRemotePubkeyHex: string,56 options: { connectTimeoutMs?: number } = {},57): Promise<SyncSession> {58 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);59 const inbox: Uint8Array[] = [];60 const readers: Array<(b: Uint8Array) => void> = [];61 let closed = false;6263 ws.binaryType = "arraybuffer";64 ws.onmessage = (ev: MessageEvent) => {65 const data = ev.data;66 if (data instanceof ArrayBuffer) {67 68 69 70 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {71 72 73 74 console.warn(75 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,76 );77 try { ws.close(); } catch { }78 return;79 }80 const bytes = new Uint8Array(data);81 const r = readers.shift();82 if (r) r(bytes);83 else inbox.push(bytes);84 }85 };86 ws.onerror = () => {87 88 89 90 console.warn("[Peach] sync WebSocket error; closing transport");91 try { ws.close(); } catch { }92 };93 ws.onclose = () => {94 closed = true;95 while (readers.length > 0) {96 const r = readers.shift()!;97 r(new Uint8Array(0));98 }99 };100101 function send(bytes: Uint8Array): Promise<void> {102 return new Promise((resolve, reject) => {103 try {104 ws.send(bytes as Uint8Array<ArrayBuffer>);105 resolve();106 } catch (err) {107 reject(err as Error);108 }109 });110 }111 function receive(): Promise<Uint8Array> {112 if (closed) return Promise.reject(new Error("WebSocket closed before frame"));113 const v = inbox.shift();114 if (v) return Promise.resolve(v);115 return new Promise<Uint8Array>((resolve, reject) => {116 readers.push((b) => {117 if (b.length === 0 && closed) reject(new Error("WebSocket closed"));118 else resolve(b);119 });120 });121 }122123 let session: NoiseXXSession;124 try {125 session = await noiseXXInitiator(126 ourLongTermPrivateKey,127 ourLongTermPublicKey,128 send,129 receive,130 131 132 (remoteStatic) => {133 const seenHex = pubkeyToHex(remoteStatic);134 if (seenHex !== expectedRemotePubkeyHex) {135 throw new Error(136 `paired-device static key mismatch (got ${seenHex.slice(0, 16)}…, ` +137 `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,138 );139 }140 },141 );142 } catch (err) {143 try { ws.close(); } catch { }144 throw err;145 }146147 const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);148 if (remoteHex !== expectedRemotePubkeyHex) {149 try { ws.close(); } catch { }150 throw new Error(151 `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +152 `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,153 );154 }155 156 void hexToPubkey(expectedRemotePubkeyHex);157158 return {159 send(plaintext: Uint8Array) {160 const ciphertext = session.encrypt(plaintext);161 ws.send(ciphertext as Uint8Array<ArrayBuffer>);162 },163 async receive() {164 const ct = await receive();165 return session.decrypt(ct);166 },167 async close() {168 try { ws.close(); } catch { }169 },170 remoteStaticPublicKey: session.remoteStaticPublicKey,171 };172}173174function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {175 return new Promise((resolve, reject) => {176 let ws: WebSocket;177 try {178 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);179 } catch (err) {180 reject(err as Error);181 return;182 }183 const t = setTimeout(() => {184 try { ws.close(); } catch { }185 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));186 }, timeoutMs);187 ws.onopen = () => {188 clearTimeout(t);189 resolve(ws);190 };191 ws.onerror = () => {192 clearTimeout(t);193 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));194 };195 });196}197198199200201202203204205206207208const lastProbeAt = new Map<string, number>();209210export function subnetProbeOnCooldown(211 expectedRemotePubkeyHex: string,212 now: number = Date.now(),213 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,214): boolean {215 const last = lastProbeAt.get(expectedRemotePubkeyHex);216 return last !== undefined && now - last < cooldownMs;217}218219220export function _resetSubnetProbeCooldown(): void {221 lastProbeAt.clear();222}223224225226227228229230231232233234235236237238export async function probeSubnetForPeer(239 subnet: string, 240 port: number,241 ourLongTermPrivateKey: Uint8Array,242 ourLongTermPublicKey: Uint8Array,243 expectedRemotePubkeyHex: string,244 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},245): Promise<SyncEndpoint | null> {246 const now = options.now ?? Date.now();247 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;248 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {249 return null;250 }251 lastProbeAt.set(expectedRemotePubkeyHex, now);252 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;253 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;254 for (let start = 1; start <= 254; start += batchSize) {255 const end = Math.min(start + batchSize - 1, 254);256 const ips: string[] = [];257 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);258259 const results = await Promise.all(ips.map((ip) => probeOne(260 { ip, port },261 ourLongTermPrivateKey,262 ourLongTermPublicKey,263 expectedRemotePubkeyHex,264 timeoutMs,265 )));266 const hit = results.find((r): r is SyncEndpoint => r !== null);267 if (hit) return hit;268 }269 return null;270}271272async function probeOne(273 endpoint: SyncEndpoint,274 ourLongTermPrivateKey: Uint8Array,275 ourLongTermPublicKey: Uint8Array,276 expectedRemotePubkeyHex: string,277 timeoutMs: number,278): Promise<SyncEndpoint | null> {279 try {280 const session = await openSyncSession(281 endpoint,282 ourLongTermPrivateKey,283 ourLongTermPublicKey,284 expectedRemotePubkeyHex,285 { connectTimeoutMs: timeoutMs },286 );287 await session.close();288 return endpoint;289 } catch {290 return null;291 }292}