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 try { ws.close(); } catch { }273 throw err;274 }275276 return {277 send(plaintext: Uint8Array) {278 const ciphertext = session.encrypt(plaintext);279 if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {280 throw new SyncFrameTooLargeError(281 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,282 );283 }284 ws.send(ciphertext as Uint8Array<ArrayBuffer>);285 },286 async receive() {287 const ct = await receive();288 return session.decrypt(ct);289 },290 async close() {291 try { ws.close(); } catch { }292 },293 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),294 };295}296297function connectWebSocketUrl(298 url: string,299 targetLabel: string,300 timeoutMs: number,301): Promise<WebSocket> {302 return new Promise((resolve, reject) => {303 let ws: WebSocket;304 try {305 ws = new WebSocket(url);306 } catch (err) {307 reject(err as Error);308 return;309 }310 const t = setTimeout(() => {311 try { ws.close(); } catch { }312 reject(new Error(`${targetLabel} WebSocket connect timed out`));313 }, timeoutMs);314 ws.onopen = () => {315 clearTimeout(t);316 resolve(ws);317 };318 ws.onerror = () => {319 clearTimeout(t);320 reject(new Error(`${targetLabel} WebSocket connect errored`));321 };322 });323}324325326327328329330331332333334335const lastProbeAt = new Map<string, number>();336337export function subnetProbeOnCooldown(338 expectedRemotePubkeyHex: string,339 now: number = Date.now(),340 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,341): boolean {342 const last = lastProbeAt.get(expectedRemotePubkeyHex);343 return last !== undefined && now - last < cooldownMs;344}345346347export function _resetSubnetProbeCooldown(): void {348 lastProbeAt.clear();349}350351352353354355356357358359360361362363364365export async function probeSubnetForPeer(366 subnet: string, 367 port: number,368 ourIdentity: SyncPeerIdentity,369 expectedRemotePubkeyHex: string,370 rendezvousIds: readonly string[],371 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},372): Promise<ProbedSyncEndpoint | null> {373 if (rendezvousIds.length === 0) {374 throw new Error("subnet probe requires at least one rendezvous id");375 }376 const now = options.now ?? Date.now();377 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;378 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {379 return null;380 }381 lastProbeAt.set(expectedRemotePubkeyHex, now);382 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;383 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;384 for (let start = 1; start <= 254; start += batchSize) {385 const end = Math.min(start + batchSize - 1, 254);386 const ips: string[] = [];387 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);388389 const results = await Promise.all(ips.map((ip) => probeOne(390 { ip, port },391 ourIdentity,392 expectedRemotePubkeyHex,393 rendezvousIds,394 timeoutMs,395 )));396 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);397 if (hit) return hit;398 }399 return null;400}401402async function probeOne(403 endpoint: SyncEndpoint,404 ourIdentity: SyncPeerIdentity,405 expectedRemotePubkeyHex: string,406 rendezvousIds: readonly string[],407 timeoutMs: number,408): Promise<ProbedSyncEndpoint | null> {409 for (const rendezvousId of rendezvousIds) {410 try {411 const session = await openSyncSession(412 endpoint,413 ourIdentity,414 expectedRemotePubkeyHex,415 rendezvousId,416 { connectTimeoutMs: timeoutMs },417 );418 await session.close();419 return { ...endpoint, rendezvousId };420 } catch {421 422 }423 }424 return null;425}