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";2930const DEFAULT_PROBE_TIMEOUT_MS = 500;31const DEFAULT_PROBE_BATCH = 16;32const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;33const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;3435export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3637export interface SyncSession {38 send(plaintext: Uint8Array): void;39 receive(): Promise<Uint8Array>;40 close(): Promise<void>;41 remoteStaticPublicKey: Uint8Array;42}4344export interface SyncEndpoint {45 ip: string;46 port: number;47}4849export interface ProbedSyncEndpoint extends SyncEndpoint {50 51 rendezvousId: string;52}53545556export interface SyncPeerIdentity {57 deviceId: string;58 deviceName: string;59 deviceType: DeviceType;60 longTermPublicKeyHex: string;61 62 longTermPrivateKey: Uint8Array;63 signingPublicKeyHex?: string;64}6566export class SyncFrameTooLargeError extends Error {67 constructor(message = "sync frame too large (WebSocket close 1009)") {68 super(message);69 this.name = "SyncFrameTooLargeError";70 }71}7273export interface SyncSessionOptions {74 connectTimeoutMs?: number;75 handshakeTimeoutMs?: number;76}7778function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {79 return new TextEncoder().encode(JSON.stringify({80 type: "PAIR_IDENTITY",81 device_id: id.deviceId,82 device_name: id.deviceName,83 device_type: id.deviceType,84 long_term_public_key_hex: id.longTermPublicKeyHex,85 signing_public_key_hex: id.signingPublicKeyHex,86 }));87}88899091929394959697export async function openSyncSession(98 endpoint: SyncEndpoint,99 ourIdentity: SyncPeerIdentity,100 expectedRemotePubkeyHex: string,101 rendezvousId: string,102 options: SyncSessionOptions = {},103): Promise<SyncSession> {104 return openSyncSessionAtUrl(105 `ws://${endpoint.ip}:${endpoint.port}/`,106 "LAN peer",107 ourIdentity,108 expectedRemotePubkeyHex,109 rendezvousId,110 options,111 );112}113114115116117118export async function openRelaySyncSession(119 relayUrl: string,120 ourIdentity: SyncPeerIdentity,121 expectedRemotePubkeyHex: string,122 rendezvousId: string,123 options: SyncSessionOptions = {},124): Promise<SyncSession> {125 return openSyncSessionAtUrl(126 buildRendezvousUrl(relayUrl, rendezvousId),127 "relay",128 ourIdentity,129 expectedRemotePubkeyHex,130 rendezvousId,131 options,132 );133}134135async function openSyncSessionAtUrl(136 url: string,137 targetLabel: string,138 ourIdentity: SyncPeerIdentity,139 expectedRemotePubkeyHex: string,140 rendezvousId: string,141 options: SyncSessionOptions,142): Promise<SyncSession> {143 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {144 throw new Error("reconnect requires a valid rotating rendezvous id");145 }146 const ws = await connectWebSocketUrl(147 url,148 targetLabel,149 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,150 );151 const inbox: Uint8Array[] = [];152 const readers: Array<(b: Uint8Array) => void> = [];153 let closed = false;154 let closeError: Error = new Error("WebSocket closed");155156 ws.binaryType = "arraybuffer";157 ws.onmessage = (ev: MessageEvent) => {158 const data = ev.data;159 if (data instanceof ArrayBuffer) {160 161 162 163 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {164 165 166 167 console.warn(168 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,169 );170 closeError = new SyncFrameTooLargeError(171 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,172 );173 try { ws.close(1009, "sync frame too large"); } catch { }174 return;175 }176 const bytes = new Uint8Array(data);177 const r = readers.shift();178 if (r) r(bytes);179 else inbox.push(bytes);180 }181 };182 ws.onerror = () => {183 184 185 186 console.warn("[Peach] sync WebSocket error; closing transport");187 try { ws.close(); } catch { }188 };189 ws.onclose = (event: CloseEvent) => {190 closed = true;191 if (event.code === 1009) {192 closeError = new SyncFrameTooLargeError();193 }194 while (readers.length > 0) {195 const r = readers.shift()!;196 r(new Uint8Array(0));197 }198 };199200 function send(bytes: Uint8Array): Promise<void> {201 return new Promise((resolve, reject) => {202 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {203 reject(204 new SyncFrameTooLargeError(205 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,206 ),207 );208 return;209 }210 try {211 ws.send(bytes as Uint8Array<ArrayBuffer>);212 resolve();213 } catch (err) {214 reject(err as Error);215 }216 });217 }218 function receive(): Promise<Uint8Array> {219 if (closed) return Promise.reject(closeError);220 const v = inbox.shift();221 if (v) return Promise.resolve(v);222 return new Promise<Uint8Array>((resolve, reject) => {223 readers.push((b) => {224 if (b.length === 0 && closed) reject(closeError);225 else resolve(b);226 });227 });228 }229230 const handshakeTimer = setTimeout(() => {231 closeError = new Error("sync_handshake_timeout");232 try { ws.close(1000, "sync handshake timeout"); } catch { }233 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);234 let session: NoiseNKSession;235 try {236 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);237 if (ourIdentity.longTermPrivateKey.length !== 32) {238 throw new Error("local reconnect private key must be exactly 32 bytes");239 }240 241 242 243 await send(new TextEncoder().encode(JSON.stringify({244 type: "PEACH_RECONNECT",245 rendezvous_id: rendezvousId,246 })));247 session = await noiseKKInitiator(248 ourIdentity.longTermPrivateKey,249 ourPublicKey,250 hexToPubkey(expectedRemotePubkeyHex),251 send,252 receive,253 );254 } catch (err) {255 clearTimeout(handshakeTimer);256 try { ws.close(); } catch { }257 throw err;258 }259260 try {261 await send(session.encrypt(encodePairIdentity(ourIdentity)));262 const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));263 if (theirFrame?.type !== "PAIR_IDENTITY") {264 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);265 }266 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {267 throw new Error("peer identity does not match authenticated KK key");268 }269 clearTimeout(handshakeTimer);270 } catch (err) {271 clearTimeout(handshakeTimer);272 session.close();273 try { ws.close(); } catch { }274 throw err;275 }276277 return {278 send(plaintext: Uint8Array) {279 const ciphertext = session.encrypt(plaintext);280 if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {281 throw new SyncFrameTooLargeError(282 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,283 );284 }285 ws.send(ciphertext as Uint8Array<ArrayBuffer>);286 },287 async receive() {288 const ct = await receive();289 return session.decrypt(ct);290 },291 async close() {292 try { ws.close(); } catch { }293 session.close();294 },295 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),296 };297}298299function connectWebSocketUrl(300 url: string,301 targetLabel: string,302 timeoutMs: number,303): Promise<WebSocket> {304 return new Promise((resolve, reject) => {305 let ws: WebSocket;306 try {307 ws = new WebSocket(url);308 } catch (err) {309 reject(err as Error);310 return;311 }312 const t = setTimeout(() => {313 try { ws.close(); } catch { }314 reject(new Error(`${targetLabel} WebSocket connect timed out`));315 }, timeoutMs);316 ws.onopen = () => {317 clearTimeout(t);318 resolve(ws);319 };320 ws.onerror = () => {321 clearTimeout(t);322 reject(new Error(`${targetLabel} WebSocket connect errored`));323 };324 });325}326327328329330331332333334335336337const lastProbeAt = new Map<string, number>();338339export function subnetProbeOnCooldown(340 expectedRemotePubkeyHex: string,341 now: number = Date.now(),342 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,343): boolean {344 const last = lastProbeAt.get(expectedRemotePubkeyHex);345 return last !== undefined && now - last < cooldownMs;346}347348349export function _resetSubnetProbeCooldown(): void {350 lastProbeAt.clear();351}352353354355356357358359360361362363364365366367export async function probeSubnetForPeer(368 subnet: string, 369 port: number,370 ourIdentity: SyncPeerIdentity,371 expectedRemotePubkeyHex: string,372 rendezvousIds: readonly string[],373 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},374): Promise<ProbedSyncEndpoint | null> {375 if (rendezvousIds.length === 0) {376 throw new Error("subnet probe requires at least one rendezvous id");377 }378 const now = options.now ?? Date.now();379 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;380 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {381 return null;382 }383 lastProbeAt.set(expectedRemotePubkeyHex, now);384 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;385 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;386 for (let start = 1; start <= 254; start += batchSize) {387 const end = Math.min(start + batchSize - 1, 254);388 const ips: string[] = [];389 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);390391 const results = await Promise.all(ips.map((ip) => probeOne(392 { ip, port },393 ourIdentity,394 expectedRemotePubkeyHex,395 rendezvousIds,396 timeoutMs,397 )));398 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);399 if (hit) return hit;400 }401 return null;402}403404async function probeOne(405 endpoint: SyncEndpoint,406 ourIdentity: SyncPeerIdentity,407 expectedRemotePubkeyHex: string,408 rendezvousIds: readonly string[],409 timeoutMs: number,410): Promise<ProbedSyncEndpoint | null> {411 for (const rendezvousId of rendezvousIds) {412 try {413 const session = await openSyncSession(414 endpoint,415 ourIdentity,416 expectedRemotePubkeyHex,417 rendezvousId,418 { connectTimeoutMs: timeoutMs },419 );420 await session.close();421 return { ...endpoint, rendezvousId };422 } catch {423 424 }425 }426 return null;427}