123456789101112131415161718192021222324import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";25import { SYNC_LIMITS } from "./sync-limits";26import { hexToPubkey } from "./sync-identity";27import { buildRendezvousUrl } from "./rendezvous";28import type { DeviceType } from "./sync-types";2930313233343536373839404142434445const DEFAULT_PROBE_TIMEOUT_MS = 500;46const DEFAULT_PROBE_BATCH = 16;47const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;48const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;4950export const SUBNET_PROBE_COOLDOWN_MS = 60_000;5152export interface SyncSession {53 send(plaintext: Uint8Array): void;54 receive(): Promise<Uint8Array>;55 close(): Promise<void>;56 remoteStaticPublicKey: Uint8Array;57}5859export interface SyncEndpoint {60 ip: string;61 port: number;62}6364export interface ProbedSyncEndpoint extends SyncEndpoint {65 66 rendezvousId: string;67}68697071export interface SyncPeerIdentity {72 deviceId: string;73 deviceName: string;74 deviceType: DeviceType;75 longTermPublicKeyHex: string;76 77 longTermPrivateKey: Uint8Array;78 signingPublicKeyHex?: string;79}8081export class SyncFrameTooLargeError extends Error {82 constructor(message = "sync frame too large (WebSocket close 1009)") {83 super(message);84 this.name = "SyncFrameTooLargeError";85 }86}8788export interface SyncSessionOptions {89 connectTimeoutMs?: number;90 handshakeTimeoutMs?: number;91}9293function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {94 return new TextEncoder().encode(JSON.stringify({95 type: "PAIR_IDENTITY",96 device_id: id.deviceId,97 device_name: id.deviceName,98 device_type: id.deviceType,99 long_term_public_key_hex: id.longTermPublicKeyHex,100 signing_public_key_hex: id.signingPublicKeyHex,101 }));102}103104105106107108109110111112export async function openSyncSession(113 endpoint: SyncEndpoint,114 ourIdentity: SyncPeerIdentity,115 expectedRemotePubkeyHex: string,116 rendezvousId: string,117 options: SyncSessionOptions = {},118): Promise<SyncSession> {119 return openSyncSessionAtUrl(120 `ws://${endpoint.ip}:${endpoint.port}/`,121 "LAN peer",122 ourIdentity,123 expectedRemotePubkeyHex,124 rendezvousId,125 options,126 );127}128129130131132133export async function openRelaySyncSession(134 relayUrl: string,135 ourIdentity: SyncPeerIdentity,136 expectedRemotePubkeyHex: string,137 rendezvousId: string,138 options: SyncSessionOptions = {},139): Promise<SyncSession> {140 return openSyncSessionAtUrl(141 buildRendezvousUrl(relayUrl, rendezvousId),142 "relay",143 ourIdentity,144 expectedRemotePubkeyHex,145 rendezvousId,146 options,147 );148}149150async function openSyncSessionAtUrl(151 url: string,152 targetLabel: string,153 ourIdentity: SyncPeerIdentity,154 expectedRemotePubkeyHex: string,155 rendezvousId: string,156 options: SyncSessionOptions,157): Promise<SyncSession> {158 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {159 throw new Error("reconnect requires a valid rotating rendezvous id");160 }161 const ws = await connectWebSocketUrl(162 url,163 targetLabel,164 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,165 );166 const inbox: Uint8Array[] = [];167 const readers: Array<(b: Uint8Array) => void> = [];168 let closed = false;169 let closeError: Error = new Error("WebSocket closed");170171 ws.binaryType = "arraybuffer";172 ws.onmessage = (ev: MessageEvent) => {173 const data = ev.data;174 if (data instanceof ArrayBuffer) {175 176 177 178 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {179 180 181 182 console.warn(183 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,184 );185 closeError = new SyncFrameTooLargeError(186 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,187 );188 try { ws.close(1009, "sync frame too large"); } catch { }189 return;190 }191 const bytes = new Uint8Array(data);192 const r = readers.shift();193 if (r) r(bytes);194 else inbox.push(bytes);195 }196 };197 ws.onerror = () => {198 199 200 201 console.warn("[Peach] sync WebSocket error; closing transport");202 try { ws.close(); } catch { }203 };204 ws.onclose = (event: CloseEvent) => {205 closed = true;206 if (event.code === 1009) {207 closeError = new SyncFrameTooLargeError();208 }209 while (readers.length > 0) {210 const r = readers.shift()!;211 r(new Uint8Array(0));212 }213 };214215 function send(bytes: Uint8Array): Promise<void> {216 return new Promise((resolve, reject) => {217 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {218 reject(219 new SyncFrameTooLargeError(220 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,221 ),222 );223 return;224 }225 try {226 ws.send(bytes as Uint8Array<ArrayBuffer>);227 resolve();228 } catch (err) {229 reject(err as Error);230 }231 });232 }233 function receive(): Promise<Uint8Array> {234 if (closed) return Promise.reject(closeError);235 const v = inbox.shift();236 if (v) return Promise.resolve(v);237 return new Promise<Uint8Array>((resolve, reject) => {238 readers.push((b) => {239 if (b.length === 0 && closed) reject(closeError);240 else resolve(b);241 });242 });243 }244245 const handshakeTimer = setTimeout(() => {246 closeError = new Error("sync_handshake_timeout");247 try { ws.close(1000, "sync handshake timeout"); } catch { }248 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);249 let session: NoiseNKSession;250 try {251 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);252 if (ourIdentity.longTermPrivateKey.length !== 32) {253 throw new Error("local reconnect private key must be exactly 32 bytes");254 }255 256 257 258 await send(new TextEncoder().encode(JSON.stringify({259 type: "PEACH_RECONNECT",260 rendezvous_id: rendezvousId,261 })));262 session = await noiseKKInitiator(263 ourIdentity.longTermPrivateKey,264 ourPublicKey,265 hexToPubkey(expectedRemotePubkeyHex),266 send,267 receive,268 );269 } catch (err) {270 clearTimeout(handshakeTimer);271 try { ws.close(); } catch { }272 throw err;273 }274275 try {276 await send(session.encrypt(encodePairIdentity(ourIdentity)));277 const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));278 if (theirFrame?.type !== "PAIR_IDENTITY") {279 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);280 }281 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {282 throw new Error("peer identity does not match authenticated KK key");283 }284 clearTimeout(handshakeTimer);285 } catch (err) {286 clearTimeout(handshakeTimer);287 session.close();288 try { ws.close(); } catch { }289 throw err;290 }291292 return {293 send(plaintext: Uint8Array) {294 const ciphertext = session.encrypt(plaintext);295 if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {296 throw new SyncFrameTooLargeError(297 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,298 );299 }300 ws.send(ciphertext as Uint8Array<ArrayBuffer>);301 },302 async receive() {303 const ct = await receive();304 return session.decrypt(ct);305 },306 async close() {307 try { ws.close(); } catch { }308 session.close();309 },310 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),311 };312}313314function connectWebSocketUrl(315 url: string,316 targetLabel: string,317 timeoutMs: number,318): Promise<WebSocket> {319 return new Promise((resolve, reject) => {320 let ws: WebSocket;321 try {322 ws = new WebSocket(url);323 } catch (err) {324 reject(err as Error);325 return;326 }327 const t = setTimeout(() => {328 try { ws.close(); } catch { }329 reject(new Error(`${targetLabel} WebSocket connect timed out`));330 }, timeoutMs);331 ws.onopen = () => {332 clearTimeout(t);333 resolve(ws);334 };335 ws.onerror = () => {336 clearTimeout(t);337 reject(new Error(`${targetLabel} WebSocket connect errored`));338 };339 });340}341342343344345346347348349350351352const lastProbeAt = new Map<string, number>();353354export function subnetProbeOnCooldown(355 expectedRemotePubkeyHex: string,356 now: number = Date.now(),357 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,358): boolean {359 const last = lastProbeAt.get(expectedRemotePubkeyHex);360 return last !== undefined && now - last < cooldownMs;361}362363364export function _resetSubnetProbeCooldown(): void {365 lastProbeAt.clear();366}367368369370371372373374375376377378379380381382export async function probeSubnetForPeer(383 subnet: string, 384 port: number,385 ourIdentity: SyncPeerIdentity,386 expectedRemotePubkeyHex: string,387 rendezvousIds: readonly string[],388 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},389): Promise<ProbedSyncEndpoint | null> {390 if (rendezvousIds.length === 0) {391 throw new Error("subnet probe requires at least one rendezvous id");392 }393 const now = options.now ?? Date.now();394 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;395 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {396 return null;397 }398 lastProbeAt.set(expectedRemotePubkeyHex, now);399 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;400 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;401 for (let start = 1; start <= 254; start += batchSize) {402 const end = Math.min(start + batchSize - 1, 254);403 const ips: string[] = [];404 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);405406 const results = await Promise.all(ips.map((ip) => probeOne(407 { ip, port },408 ourIdentity,409 expectedRemotePubkeyHex,410 rendezvousIds,411 timeoutMs,412 )));413 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);414 if (hit) return hit;415 }416 return null;417}418419async function probeOne(420 endpoint: SyncEndpoint,421 ourIdentity: SyncPeerIdentity,422 expectedRemotePubkeyHex: string,423 rendezvousIds: readonly string[],424 timeoutMs: number,425): Promise<ProbedSyncEndpoint | null> {426 for (const rendezvousId of rendezvousIds) {427 try {428 const session = await openSyncSession(429 endpoint,430 ourIdentity,431 expectedRemotePubkeyHex,432 rendezvousId,433 { connectTimeoutMs: timeoutMs },434 );435 await session.close();436 return { ...endpoint, rendezvousId };437 } catch {438 439 }440 }441 return null;442}