123456789101112131415161718192021222324import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";25import { SYNC_LIMITS } from "./sync-limits";26import { padSyncFrame, unpadSyncFrame } from "./sync-padding";27import { hexToPubkey } from "./sync-identity";28import { buildRendezvousUrl } from "./rendezvous";29import type { DeviceType } from "./sync-types";3031323334353637383940414243444546474849const DEFAULT_PROBE_TIMEOUT_MS = 500;50const DEFAULT_PROBE_BATCH = 16;51const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;52const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;5354export const SUBNET_PROBE_COOLDOWN_MS = 60_000;5556export interface SyncSession {57 send(plaintext: Uint8Array): void;58 receive(): Promise<Uint8Array>;59 close(): Promise<void>;60 remoteStaticPublicKey: Uint8Array;61}6263export interface SyncEndpoint {64 ip: string;65 port: number;66}6768export interface ProbedSyncEndpoint extends SyncEndpoint {69 70 rendezvousId: string;71}72737475export interface SyncPeerIdentity {76 deviceId: string;77 deviceName: string;78 deviceType: DeviceType;79 longTermPublicKeyHex: string;80 81 longTermPrivateKey: Uint8Array;82 signingPublicKeyHex?: string;83}8485export class SyncFrameTooLargeError extends Error {86 constructor(message = "sync frame too large (WebSocket close 1009)") {87 super(message);88 this.name = "SyncFrameTooLargeError";89 }90}9192export interface SyncSessionOptions {93 connectTimeoutMs?: number;94 handshakeTimeoutMs?: number;95}9697function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {98 return new TextEncoder().encode(JSON.stringify({99 type: "PAIR_IDENTITY",100 device_id: id.deviceId,101 device_name: id.deviceName,102 device_type: id.deviceType,103 long_term_public_key_hex: id.longTermPublicKeyHex,104 signing_public_key_hex: id.signingPublicKeyHex,105 }));106}107108109110111112113114115116export async function openSyncSession(117 endpoint: SyncEndpoint,118 ourIdentity: SyncPeerIdentity,119 expectedRemotePubkeyHex: string,120 rendezvousId: string,121 options: SyncSessionOptions = {},122): Promise<SyncSession> {123 return openSyncSessionAtUrl(124 `ws://${endpoint.ip}:${endpoint.port}/`,125 "LAN peer",126 ourIdentity,127 expectedRemotePubkeyHex,128 rendezvousId,129 options,130 );131}132133134135136137export async function openRelaySyncSession(138 relayUrl: string,139 ourIdentity: SyncPeerIdentity,140 expectedRemotePubkeyHex: string,141 rendezvousId: string,142 options: SyncSessionOptions = {},143): Promise<SyncSession> {144 return openSyncSessionAtUrl(145 buildRendezvousUrl(relayUrl, rendezvousId),146 "relay",147 ourIdentity,148 expectedRemotePubkeyHex,149 rendezvousId,150 options,151 );152}153154async function openSyncSessionAtUrl(155 url: string,156 targetLabel: string,157 ourIdentity: SyncPeerIdentity,158 expectedRemotePubkeyHex: string,159 rendezvousId: string,160 options: SyncSessionOptions,161): Promise<SyncSession> {162 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {163 throw new Error("reconnect requires a valid rotating rendezvous id");164 }165 const ws = await connectWebSocketUrl(166 url,167 targetLabel,168 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,169 );170 const inbox: Uint8Array[] = [];171 const readers: Array<(b: Uint8Array) => void> = [];172 let closed = false;173 let closeError: Error = new Error("WebSocket closed");174175 ws.binaryType = "arraybuffer";176 ws.onmessage = (ev: MessageEvent) => {177 const data = ev.data;178 if (data instanceof ArrayBuffer) {179 180 181 182 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {183 184 185 186 console.warn(187 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,188 );189 closeError = new SyncFrameTooLargeError(190 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,191 );192 try { ws.close(1009, "sync frame too large"); } catch { }193 return;194 }195 const bytes = new Uint8Array(data);196 const r = readers.shift();197 if (r) r(bytes);198 else inbox.push(bytes);199 }200 };201 ws.onerror = () => {202 203 204 205 console.warn("[Peach] sync WebSocket error; closing transport");206 try { ws.close(); } catch { }207 };208 ws.onclose = (event: CloseEvent) => {209 closed = true;210 if (event.code === 1009) {211 closeError = new SyncFrameTooLargeError();212 }213 while (readers.length > 0) {214 const r = readers.shift()!;215 r(new Uint8Array(0));216 }217 };218219 function send(bytes: Uint8Array): Promise<void> {220 return new Promise((resolve, reject) => {221 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {222 reject(223 new SyncFrameTooLargeError(224 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,225 ),226 );227 return;228 }229 try {230 ws.send(bytes as Uint8Array<ArrayBuffer>);231 resolve();232 } catch (err) {233 reject(err as Error);234 }235 });236 }237 function receive(): Promise<Uint8Array> {238 if (closed) return Promise.reject(closeError);239 const v = inbox.shift();240 if (v) return Promise.resolve(v);241 return new Promise<Uint8Array>((resolve, reject) => {242 readers.push((b) => {243 if (b.length === 0 && closed) reject(closeError);244 else resolve(b);245 });246 });247 }248249 const handshakeTimer = setTimeout(() => {250 closeError = new Error("sync_handshake_timeout");251 try { ws.close(1000, "sync handshake timeout"); } catch { }252 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);253 let session: NoiseNKSession;254 try {255 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);256 if (ourIdentity.longTermPrivateKey.length !== 32) {257 throw new Error("local reconnect private key must be exactly 32 bytes");258 }259 260 261 262 await send(new TextEncoder().encode(JSON.stringify({263 type: "PEACH_RECONNECT",264 rendezvous_id: rendezvousId,265 })));266 session = await noiseKKInitiator(267 ourIdentity.longTermPrivateKey,268 ourPublicKey,269 hexToPubkey(expectedRemotePubkeyHex),270 send,271 receive,272 );273 } catch (err) {274 clearTimeout(handshakeTimer);275 try { ws.close(); } catch { }276 throw err;277 }278279 try {280 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));281 const theirFrame = JSON.parse(282 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),283 );284 if (theirFrame?.type !== "PAIR_IDENTITY") {285 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);286 }287 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {288 throw new Error("peer identity does not match authenticated KK key");289 }290 clearTimeout(handshakeTimer);291 } catch (err) {292 clearTimeout(handshakeTimer);293 session.close();294 try { ws.close(); } catch { }295 throw err;296 }297298 return {299 send(plaintext: Uint8Array) {300 301 302 const ciphertext = session.encrypt(padSyncFrame(plaintext));303 if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {304 throw new SyncFrameTooLargeError(305 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,306 );307 }308 ws.send(ciphertext as Uint8Array<ArrayBuffer>);309 },310 async receive() {311 const ct = await receive();312 return unpadSyncFrame(session.decrypt(ct));313 },314 async close() {315 try { ws.close(); } catch { }316 session.close();317 },318 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),319 };320}321322function connectWebSocketUrl(323 url: string,324 targetLabel: string,325 timeoutMs: number,326): Promise<WebSocket> {327 return new Promise((resolve, reject) => {328 let ws: WebSocket;329 try {330 ws = new WebSocket(url);331 } catch (err) {332 reject(err as Error);333 return;334 }335 const t = setTimeout(() => {336 try { ws.close(); } catch { }337 reject(new Error(`${targetLabel} WebSocket connect timed out`));338 }, timeoutMs);339 ws.onopen = () => {340 clearTimeout(t);341 resolve(ws);342 };343 ws.onerror = () => {344 clearTimeout(t);345 reject(new Error(`${targetLabel} WebSocket connect errored`));346 };347 });348}349350351352353354355356357358359360const lastProbeAt = new Map<string, number>();361362export function subnetProbeOnCooldown(363 expectedRemotePubkeyHex: string,364 now: number = Date.now(),365 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,366): boolean {367 const last = lastProbeAt.get(expectedRemotePubkeyHex);368 return last !== undefined && now - last < cooldownMs;369}370371372export function _resetSubnetProbeCooldown(): void {373 lastProbeAt.clear();374}375376377378379380381382383384385386387388389390export async function probeSubnetForPeer(391 subnet: string, 392 port: number,393 ourIdentity: SyncPeerIdentity,394 expectedRemotePubkeyHex: string,395 rendezvousIds: readonly string[],396 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},397): Promise<ProbedSyncEndpoint | null> {398 if (rendezvousIds.length === 0) {399 throw new Error("subnet probe requires at least one rendezvous id");400 }401 const now = options.now ?? Date.now();402 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;403 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {404 return null;405 }406 lastProbeAt.set(expectedRemotePubkeyHex, now);407 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;408 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;409 for (let start = 1; start <= 254; start += batchSize) {410 const end = Math.min(start + batchSize - 1, 254);411 const ips: string[] = [];412 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);413414 const results = await Promise.all(ips.map((ip) => probeOne(415 { ip, port },416 ourIdentity,417 expectedRemotePubkeyHex,418 rendezvousIds,419 timeoutMs,420 )));421 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);422 if (hit) return hit;423 }424 return null;425}426427async function probeOne(428 endpoint: SyncEndpoint,429 ourIdentity: SyncPeerIdentity,430 expectedRemotePubkeyHex: string,431 rendezvousIds: readonly string[],432 timeoutMs: number,433): Promise<ProbedSyncEndpoint | null> {434 for (const rendezvousId of rendezvousIds) {435 try {436 const session = await openSyncSession(437 endpoint,438 ourIdentity,439 expectedRemotePubkeyHex,440 rendezvousId,441 { connectTimeoutMs: timeoutMs },442 );443 await session.close();444 return { ...endpoint, rendezvousId };445 } catch {446 447 }448 }449 return null;450}