123456789101112131415161718192021222324import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";25import { SYNC_LIMITS } from "./sync-limits";26import {27 padSyncFrame,28 unpadSyncFrame,29 MAX_NOISE_PLAINTEXT_BYTES,30 FRAME_CONTINUATION_MARKER,31 chunkPaddedSyncFrame,32} from "./sync-padding";33import { hexToPubkey } from "./sync-identity";34import { buildRendezvousUrl } from "./rendezvous";35import type { DeviceType } from "./sync-types";3637383940414243444546474849505152535455const DEFAULT_PROBE_TIMEOUT_MS = 500;56const DEFAULT_PROBE_BATCH = 16;57const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;58const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;5960export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6162export interface SyncSession {63 send(plaintext: Uint8Array): void;64 receive(): Promise<Uint8Array>;65 close(): Promise<void>;66 remoteStaticPublicKey: Uint8Array;67}6869export interface SyncEndpoint {70 ip: string;71 port: number;72}7374export interface ProbedSyncEndpoint extends SyncEndpoint {75 76 rendezvousId: string;77}78798081export interface SyncPeerIdentity {82 deviceId: string;83 deviceName: string;84 deviceType: DeviceType;85 longTermPublicKeyHex: string;86 87 longTermPrivateKey: Uint8Array;88 signingPublicKeyHex?: string;89}9091export class SyncFrameTooLargeError extends Error {92 constructor(message = "sync frame too large (WebSocket close 1009)") {93 super(message);94 this.name = "SyncFrameTooLargeError";95 }96}9798export interface SyncSessionOptions {99 connectTimeoutMs?: number;100 handshakeTimeoutMs?: number;101}102103function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {104 return new TextEncoder().encode(JSON.stringify({105 type: "PAIR_IDENTITY",106 device_id: id.deviceId,107 device_name: id.deviceName,108 device_type: id.deviceType,109 long_term_public_key_hex: id.longTermPublicKeyHex,110 signing_public_key_hex: id.signingPublicKeyHex,111 }));112}113114115116117118119120121122export async function openSyncSession(123 endpoint: SyncEndpoint,124 ourIdentity: SyncPeerIdentity,125 expectedRemotePubkeyHex: string,126 rendezvousId: string,127 options: SyncSessionOptions = {},128): Promise<SyncSession> {129 return openSyncSessionAtUrl(130 `ws://${endpoint.ip}:${endpoint.port}/`,131 "LAN peer",132 ourIdentity,133 expectedRemotePubkeyHex,134 rendezvousId,135 options,136 );137}138139140141142143export async function openRelaySyncSession(144 relayUrl: string,145 ourIdentity: SyncPeerIdentity,146 expectedRemotePubkeyHex: string,147 rendezvousId: string,148 options: SyncSessionOptions = {},149): Promise<SyncSession> {150 return openSyncSessionAtUrl(151 buildRendezvousUrl(relayUrl, rendezvousId),152 "relay",153 ourIdentity,154 expectedRemotePubkeyHex,155 rendezvousId,156 options,157 );158}159160async function openSyncSessionAtUrl(161 url: string,162 targetLabel: string,163 ourIdentity: SyncPeerIdentity,164 expectedRemotePubkeyHex: string,165 rendezvousId: string,166 options: SyncSessionOptions,167): Promise<SyncSession> {168 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {169 throw new Error("reconnect requires a valid rotating rendezvous id");170 }171 const ws = await connectWebSocketUrl(172 url,173 targetLabel,174 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,175 );176 const inbox: Uint8Array[] = [];177 const readers: Array<(b: Uint8Array) => void> = [];178 let closed = false;179 let closeError: Error = new Error("WebSocket closed");180181 ws.binaryType = "arraybuffer";182 ws.onmessage = (ev: MessageEvent) => {183 const data = ev.data;184 if (data instanceof ArrayBuffer) {185 186 187 188 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {189 190 191 192 console.warn(193 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,194 );195 closeError = new SyncFrameTooLargeError(196 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,197 );198 try { ws.close(1009, "sync frame too large"); } catch { }199 return;200 }201 const bytes = new Uint8Array(data);202 const r = readers.shift();203 if (r) r(bytes);204 else inbox.push(bytes);205 }206 };207 ws.onerror = () => {208 209 210 211 console.warn("[Peach] sync WebSocket error; closing transport");212 try { ws.close(); } catch { }213 };214 ws.onclose = (event: CloseEvent) => {215 closed = true;216 if (event.code === 1009) {217 closeError = new SyncFrameTooLargeError();218 }219 while (readers.length > 0) {220 const r = readers.shift()!;221 r(new Uint8Array(0));222 }223 };224225 function send(bytes: Uint8Array): Promise<void> {226 return new Promise((resolve, reject) => {227 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {228 reject(229 new SyncFrameTooLargeError(230 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,231 ),232 );233 return;234 }235 try {236 ws.send(bytes as Uint8Array<ArrayBuffer>);237 resolve();238 } catch (err) {239 reject(err as Error);240 }241 });242 }243 function receive(): Promise<Uint8Array> {244 if (closed) return Promise.reject(closeError);245 const v = inbox.shift();246 if (v) return Promise.resolve(v);247 return new Promise<Uint8Array>((resolve, reject) => {248 readers.push((b) => {249 if (b.length === 0 && closed) reject(closeError);250 else resolve(b);251 });252 });253 }254255 const handshakeTimer = setTimeout(() => {256 closeError = new Error("sync_handshake_timeout");257 try { ws.close(1000, "sync handshake timeout"); } catch { }258 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);259 let session: NoiseNKSession;260 try {261 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);262 if (ourIdentity.longTermPrivateKey.length !== 32) {263 throw new Error("local reconnect private key must be exactly 32 bytes");264 }265 266 267 268 await send(new TextEncoder().encode(JSON.stringify({269 type: "PEACH_RECONNECT",270 rendezvous_id: rendezvousId,271 })));272 session = await noiseKKInitiator(273 ourIdentity.longTermPrivateKey,274 ourPublicKey,275 hexToPubkey(expectedRemotePubkeyHex),276 send,277 receive,278 );279 } catch (err) {280 clearTimeout(handshakeTimer);281 try { ws.close(); } catch { }282 throw err;283 }284285 try {286 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));287 const theirFrame = JSON.parse(288 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),289 );290 if (theirFrame?.type !== "PAIR_IDENTITY") {291 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);292 }293 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {294 throw new Error("peer identity does not match authenticated KK key");295 }296 clearTimeout(handshakeTimer);297 } catch (err) {298 clearTimeout(handshakeTimer);299 session.close();300 try { ws.close(); } catch { }301 throw err;302 }303304 return {305 async send(plaintext: Uint8Array) {306 307 308 309 310 311 312 313 314 315 const padded = padSyncFrame(plaintext);316 if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {317 throw new SyncFrameTooLargeError(318 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,319 );320 }321 322 323 324 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {325 const ct = session.encrypt(padded);326 ws.send(ct as Uint8Array<ArrayBuffer>);327 return;328 }329 for (const plaintext of chunkPaddedSyncFrame(padded)) {330 if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {331 throw new Error(332 `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,333 );334 }335 const ct = session.encrypt(plaintext);336 if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {337 throw new SyncFrameTooLargeError(338 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,339 );340 }341 ws.send(ct as Uint8Array<ArrayBuffer>);342 }343 },344 async receive() {345 346 347 348 349 let buffered: Uint8Array | null = null;350 while (true) {351 const ct = await receive();352 const pt = session.decrypt(ct);353 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {354 355 buffered = pt.subarray(1);356 continue;357 }358 if (buffered !== null) {359 if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {360 const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);361 merged.set(buffered, 0);362 merged.set(pt.subarray(1), buffered.length);363 buffered = merged;364 continue;365 }366 367 368 const reassembled = new Uint8Array(buffered.length + pt.byteLength);369 reassembled.set(buffered, 0);370 reassembled.set(pt, buffered.length);371 return unpadSyncFrame(reassembled);372 }373 374 return unpadSyncFrame(pt);375 }376 },377 async close() {378 try { ws.close(); } catch { }379 session.close();380 },381 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),382 };383}384385function connectWebSocketUrl(386 url: string,387 targetLabel: string,388 timeoutMs: number,389): Promise<WebSocket> {390 return new Promise((resolve, reject) => {391 let ws: WebSocket;392 try {393 ws = new WebSocket(url);394 } catch (err) {395 reject(err as Error);396 return;397 }398 const t = setTimeout(() => {399 try { ws.close(); } catch { }400 reject(new Error(`${targetLabel} WebSocket connect timed out`));401 }, timeoutMs);402 ws.onopen = () => {403 clearTimeout(t);404 resolve(ws);405 };406 ws.onerror = () => {407 clearTimeout(t);408 reject(new Error(`${targetLabel} WebSocket connect errored`));409 };410 });411}412413414415416417418419420421422423const lastProbeAt = new Map<string, number>();424425export function subnetProbeOnCooldown(426 expectedRemotePubkeyHex: string,427 now: number = Date.now(),428 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,429): boolean {430 const last = lastProbeAt.get(expectedRemotePubkeyHex);431 return last !== undefined && now - last < cooldownMs;432}433434435export function _resetSubnetProbeCooldown(): void {436 lastProbeAt.clear();437}438439440441442443444445446447448449450451452453export async function probeSubnetForPeer(454 subnet: string, 455 port: number,456 ourIdentity: SyncPeerIdentity,457 expectedRemotePubkeyHex: string,458 rendezvousIds: readonly string[],459 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},460): Promise<ProbedSyncEndpoint | null> {461 if (rendezvousIds.length === 0) {462 throw new Error("subnet probe requires at least one rendezvous id");463 }464 const now = options.now ?? Date.now();465 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;466 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {467 return null;468 }469 lastProbeAt.set(expectedRemotePubkeyHex, now);470 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;471 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;472 for (let start = 1; start <= 254; start += batchSize) {473 const end = Math.min(start + batchSize - 1, 254);474 const ips: string[] = [];475 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);476477 const results = await Promise.all(ips.map((ip) => probeOne(478 { ip, port },479 ourIdentity,480 expectedRemotePubkeyHex,481 rendezvousIds,482 timeoutMs,483 )));484 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);485 if (hit) return hit;486 }487 return null;488}489490async function probeOne(491 endpoint: SyncEndpoint,492 ourIdentity: SyncPeerIdentity,493 expectedRemotePubkeyHex: string,494 rendezvousIds: readonly string[],495 timeoutMs: number,496): Promise<ProbedSyncEndpoint | null> {497 for (const rendezvousId of rendezvousIds) {498 try {499 const session = await openSyncSession(500 endpoint,501 ourIdentity,502 expectedRemotePubkeyHex,503 rendezvousId,504 { connectTimeoutMs: timeoutMs },505 );506 await session.close();507 return { ...endpoint, rendezvousId };508 } catch {509 510 }511 }512 return null;513}