1234567891011121314151617181920212223242526import {27 noiseKKInitiator,28 noiseKKResponder,29 type NoiseNKSession,30} from "./noise-js";31import { SYNC_LIMITS } from "./sync-limits";32import {33 padSyncFrame,34 unpadSyncFrame,35 MAX_NOISE_PLAINTEXT_BYTES,36 FRAME_CONTINUATION_MARKER,37 chunkPaddedSyncFrame,38} from "./sync-padding";39import { hexToPubkey } from "./sync-identity";40import { buildRendezvousUrl } from "./rendezvous";41import type { DeviceType } from "./sync-types";4243444546474849505152535455565758596061const DEFAULT_PROBE_TIMEOUT_MS = 500;62const DEFAULT_PROBE_BATCH = 16;63const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;64const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;65export const RELAY_WAITER_KEEPALIVE_MS = 20_000;6667export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6869export interface SyncSession {70 send(plaintext: Uint8Array): void;71 receive(): Promise<Uint8Array>;72 close(): Promise<void>;73 remoteStaticPublicKey: Uint8Array;74}7576export interface SyncEndpoint {77 ip: string;78 port: number;79}8081export interface ProbedSyncEndpoint extends SyncEndpoint {82 83 rendezvousId: string;84}85868788export interface SyncPeerIdentity {89 deviceId: string;90 deviceName: string;91 deviceType: DeviceType;92 longTermPublicKeyHex: string;93 94 longTermPrivateKey: Uint8Array;95 signingPublicKeyHex?: string;96}9798export class SyncFrameTooLargeError extends Error {99 constructor(message = "sync frame too large (WebSocket close 1009)") {100 super(message);101 this.name = "SyncFrameTooLargeError";102 }103}104105export interface SyncSessionOptions {106 connectTimeoutMs?: number;107 handshakeTimeoutMs?: number;108 signal?: AbortSignal;109}110111function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {112 return new TextEncoder().encode(JSON.stringify({113 type: "PAIR_IDENTITY",114 device_id: id.deviceId,115 device_name: id.deviceName,116 device_type: id.deviceType,117 long_term_public_key_hex: id.longTermPublicKeyHex,118 signing_public_key_hex: id.signingPublicKeyHex,119 }));120}121122123124125126127128129130export async function openSyncSession(131 endpoint: SyncEndpoint,132 ourIdentity: SyncPeerIdentity,133 expectedRemotePubkeyHex: string,134 rendezvousId: string,135 options: SyncSessionOptions = {},136): Promise<SyncSession> {137 return openSyncSessionAtUrl(138 `ws://${endpoint.ip}:${endpoint.port}/`,139 "LAN peer",140 ourIdentity,141 expectedRemotePubkeyHex,142 rendezvousId,143 options,144 );145}146147148149150151export async function openRelaySyncSession(152 relayUrl: string,153 ourIdentity: SyncPeerIdentity,154 expectedRemotePubkeyHex: string,155 rendezvousId: string,156 options: SyncSessionOptions = {},157): Promise<SyncSession> {158 return openSyncSessionAtUrl(159 buildRendezvousUrl(relayUrl, rendezvousId),160 "relay",161 ourIdentity,162 expectedRemotePubkeyHex,163 rendezvousId,164 options,165 );166}167168169170171172173174export async function openRelaySyncResponderSession(175 relayUrl: string,176 ourIdentity: SyncPeerIdentity,177 expectedRemotePubkeyHex: string,178 rendezvousId: string,179 options: SyncSessionOptions & { keepaliveMs?: number } = {},180): Promise<SyncSession> {181 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {182 throw new Error("reconnect requires a valid rotating rendezvous id");183 }184 if (ourIdentity.longTermPrivateKey.length !== 32) {185 throw new Error("local reconnect private key must be exactly 32 bytes");186 }187 const ws = await connectWebSocketUrl(188 buildRendezvousUrl(relayUrl, rendezvousId),189 "relay",190 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,191 );192 const inbox: Uint8Array[] = [];193 const readers: Array<(b: Uint8Array) => void> = [];194 let closed = false;195 let closeError: Error = new Error("WebSocket closed");196 const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;197 const abortWaiter = () => {198 try { ws.close(1000, "relay waiter replaced"); } catch { }199 };200 const keepalive = setInterval(() => {201 if (ws.readyState !== WebSocket.OPEN) return;202 try { ws.send(new Uint8Array(0)); } catch { }203 }, keepaliveMs);204205 ws.binaryType = "arraybuffer";206 ws.onmessage = (event: MessageEvent) => {207 if (!(event.data instanceof ArrayBuffer)) return;208 if (event.data.byteLength === 0) return;209 if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {210 closeError = new SyncFrameTooLargeError(211 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,212 );213 try { ws.close(1009, "sync frame too large"); } catch { }214 return;215 }216 const bytes = new Uint8Array(event.data);217 const reader = readers.shift();218 if (reader) reader(bytes);219 else inbox.push(bytes);220 };221 ws.onerror = () => {222 try { ws.close(); } catch { }223 };224 ws.onclose = (event: CloseEvent) => {225 clearInterval(keepalive);226 options.signal?.removeEventListener("abort", abortWaiter);227 closed = true;228 if (event.code === 1009) closeError = new SyncFrameTooLargeError();229 while (readers.length > 0) readers.shift()!(new Uint8Array(0));230 };231 232 233 options.signal?.addEventListener("abort", abortWaiter, { once: true });234 if (options.signal?.aborted) abortWaiter();235236 const send = async (bytes: Uint8Array): Promise<void> => {237 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {238 throw new SyncFrameTooLargeError(239 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,240 );241 }242 ws.send(bytes as Uint8Array<ArrayBuffer>);243 };244 const receive = (): Promise<Uint8Array> => {245 if (closed) return Promise.reject(closeError);246 const buffered = inbox.shift();247 if (buffered) return Promise.resolve(buffered);248 return new Promise<Uint8Array>((resolve, reject) => {249 readers.push((bytes) => {250 if (bytes.length === 0 && closed) reject(closeError);251 else resolve(bytes);252 });253 });254 };255256 let noiseSession: NoiseNKSession | undefined;257 try {258 const selectorBytes = await receive();259 const selector = JSON.parse(new TextDecoder().decode(selectorBytes));260 if (261 selector?.type !== "PEACH_RECONNECT" ||262 typeof selector.rendezvous_id !== "string" ||263 !equalAscii(selector.rendezvous_id, rendezvousId)264 ) {265 throw new Error("reconnect capability does not match relay room");266 }267 const handshakeTimer = setTimeout(() => {268 closeError = new Error("sync_handshake_timeout");269 try { ws.close(1000, "sync handshake timeout"); } catch { }270 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);271 try {272 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);273 noiseSession = await noiseKKResponder(274 ourIdentity.longTermPrivateKey,275 ourPublicKey,276 hexToPubkey(expectedRemotePubkeyHex),277 send,278 receive,279 );280 const theirFrame = JSON.parse(281 new TextDecoder().decode(282 unpadSyncFrame(noiseSession.decrypt(await receive())),283 ),284 );285 if (theirFrame?.type !== "PAIR_IDENTITY") {286 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);287 }288 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {289 throw new Error("peer identity does not match authenticated KK key");290 }291 await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));292 } finally {293 clearTimeout(handshakeTimer);294 }295 } catch (error) {296 clearInterval(keepalive);297 options.signal?.removeEventListener("abort", abortWaiter);298 noiseSession?.close();299 try { ws.close(); } catch { }300 throw error;301 }302303 return buildEncryptedSyncSession(304 ws,305 noiseSession,306 expectedRemotePubkeyHex,307 receive,308 () => {309 clearInterval(keepalive);310 options.signal?.removeEventListener("abort", abortWaiter);311 },312 );313}314315async function openSyncSessionAtUrl(316 url: string,317 targetLabel: string,318 ourIdentity: SyncPeerIdentity,319 expectedRemotePubkeyHex: string,320 rendezvousId: string,321 options: SyncSessionOptions,322): Promise<SyncSession> {323 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {324 throw new Error("reconnect requires a valid rotating rendezvous id");325 }326 const ws = await connectWebSocketUrl(327 url,328 targetLabel,329 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,330 );331 const inbox: Uint8Array[] = [];332 const readers: Array<(b: Uint8Array) => void> = [];333 let closed = false;334 let closeError: Error = new Error("WebSocket closed");335336 ws.binaryType = "arraybuffer";337 ws.onmessage = (ev: MessageEvent) => {338 const data = ev.data;339 if (data instanceof ArrayBuffer) {340 341 342 343 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {344 345 346 347 console.warn(348 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,349 );350 closeError = new SyncFrameTooLargeError(351 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,352 );353 try { ws.close(1009, "sync frame too large"); } catch { }354 return;355 }356 const bytes = new Uint8Array(data);357 const r = readers.shift();358 if (r) r(bytes);359 else inbox.push(bytes);360 }361 };362 ws.onerror = () => {363 364 365 366 console.warn("[Peach] sync WebSocket error; closing transport");367 try { ws.close(); } catch { }368 };369 ws.onclose = (event: CloseEvent) => {370 closed = true;371 if (event.code === 1009) {372 closeError = new SyncFrameTooLargeError();373 }374 while (readers.length > 0) {375 const r = readers.shift()!;376 r(new Uint8Array(0));377 }378 };379380 function send(bytes: Uint8Array): Promise<void> {381 return new Promise((resolve, reject) => {382 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {383 reject(384 new SyncFrameTooLargeError(385 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,386 ),387 );388 return;389 }390 try {391 ws.send(bytes as Uint8Array<ArrayBuffer>);392 resolve();393 } catch (err) {394 reject(err as Error);395 }396 });397 }398 function receive(): Promise<Uint8Array> {399 if (closed) return Promise.reject(closeError);400 const v = inbox.shift();401 if (v) return Promise.resolve(v);402 return new Promise<Uint8Array>((resolve, reject) => {403 readers.push((b) => {404 if (b.length === 0 && closed) reject(closeError);405 else resolve(b);406 });407 });408 }409410 const handshakeTimer = setTimeout(() => {411 closeError = new Error("sync_handshake_timeout");412 try { ws.close(1000, "sync handshake timeout"); } catch { }413 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);414 let session: NoiseNKSession;415 try {416 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);417 if (ourIdentity.longTermPrivateKey.length !== 32) {418 throw new Error("local reconnect private key must be exactly 32 bytes");419 }420 421 422 423 await send(new TextEncoder().encode(JSON.stringify({424 type: "PEACH_RECONNECT",425 rendezvous_id: rendezvousId,426 })));427 session = await noiseKKInitiator(428 ourIdentity.longTermPrivateKey,429 ourPublicKey,430 hexToPubkey(expectedRemotePubkeyHex),431 send,432 receive,433 );434 } catch (err) {435 clearTimeout(handshakeTimer);436 try { ws.close(); } catch { }437 throw err;438 }439440 try {441 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));442 const theirFrame = JSON.parse(443 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),444 );445 if (theirFrame?.type !== "PAIR_IDENTITY") {446 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);447 }448 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {449 throw new Error("peer identity does not match authenticated KK key");450 }451 clearTimeout(handshakeTimer);452 } catch (err) {453 clearTimeout(handshakeTimer);454 session.close();455 try { ws.close(); } catch { }456 throw err;457 }458459 return buildEncryptedSyncSession(460 ws,461 session,462 expectedRemotePubkeyHex,463 receive,464 );465}466467function buildEncryptedSyncSession(468 ws: WebSocket,469 session: NoiseNKSession,470 expectedRemotePubkeyHex: string,471 receiveCiphertext: () => Promise<Uint8Array>,472 beforeClose: () => void = () => undefined,473): SyncSession {474 return {475 async send(plaintext: Uint8Array) {476 477 478 479 480 481 482 483 484 485 const padded = padSyncFrame(plaintext);486 if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {487 throw new SyncFrameTooLargeError(488 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,489 );490 }491 492 493 494 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {495 const ct = session.encrypt(padded);496 ws.send(ct as Uint8Array<ArrayBuffer>);497 return;498 }499 for (const plaintext of chunkPaddedSyncFrame(padded)) {500 if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {501 throw new Error(502 `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,503 );504 }505 const ct = session.encrypt(plaintext);506 if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {507 throw new SyncFrameTooLargeError(508 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,509 );510 }511 ws.send(ct as Uint8Array<ArrayBuffer>);512 }513 },514 async receive() {515 516 517 518 519 let buffered: Uint8Array | null = null;520 while (true) {521 const ct = await receiveCiphertext();522 const pt = session.decrypt(ct);523 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {524 525 buffered = pt.subarray(1);526 continue;527 }528 if (buffered !== null) {529 if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {530 const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);531 merged.set(buffered, 0);532 merged.set(pt.subarray(1), buffered.length);533 buffered = merged;534 continue;535 }536 537 538 const reassembled = new Uint8Array(buffered.length + pt.byteLength);539 reassembled.set(buffered, 0);540 reassembled.set(pt, buffered.length);541 return unpadSyncFrame(reassembled);542 }543 544 return unpadSyncFrame(pt);545 }546 },547 async close() {548 beforeClose();549 try { ws.close(); } catch { }550 session.close();551 },552 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),553 };554}555556function equalAscii(left: string, right: string): boolean {557 const a = new TextEncoder().encode(left);558 const b = new TextEncoder().encode(right);559 if (a.length !== b.length) return false;560 let diff = 0;561 for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];562 return diff === 0;563}564565function connectWebSocketUrl(566 url: string,567 targetLabel: string,568 timeoutMs: number,569): Promise<WebSocket> {570 return new Promise((resolve, reject) => {571 let ws: WebSocket;572 try {573 ws = new WebSocket(url);574 } catch (err) {575 reject(err as Error);576 return;577 }578 const t = setTimeout(() => {579 try { ws.close(); } catch { }580 reject(new Error(`${targetLabel} WebSocket connect timed out`));581 }, timeoutMs);582 ws.onopen = () => {583 clearTimeout(t);584 resolve(ws);585 };586 ws.onerror = () => {587 clearTimeout(t);588 reject(new Error(`${targetLabel} WebSocket connect errored`));589 };590 });591}592593594595596597598599600601602603const lastProbeAt = new Map<string, number>();604605export function subnetProbeOnCooldown(606 expectedRemotePubkeyHex: string,607 now: number = Date.now(),608 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,609): boolean {610 const last = lastProbeAt.get(expectedRemotePubkeyHex);611 return last !== undefined && now - last < cooldownMs;612}613614615export function _resetSubnetProbeCooldown(): void {616 lastProbeAt.clear();617}618619620621622623624625626627628629630631632633export async function probeSubnetForPeer(634 subnet: string, 635 port: number,636 ourIdentity: SyncPeerIdentity,637 expectedRemotePubkeyHex: string,638 rendezvousIds: readonly string[],639 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},640): Promise<ProbedSyncEndpoint | null> {641 if (rendezvousIds.length === 0) {642 throw new Error("subnet probe requires at least one rendezvous id");643 }644 const now = options.now ?? Date.now();645 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;646 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {647 return null;648 }649 lastProbeAt.set(expectedRemotePubkeyHex, now);650 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;651 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;652 for (let start = 1; start <= 254; start += batchSize) {653 const end = Math.min(start + batchSize - 1, 254);654 const ips: string[] = [];655 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);656657 const results = await Promise.all(ips.map((ip) => probeOne(658 { ip, port },659 ourIdentity,660 expectedRemotePubkeyHex,661 rendezvousIds,662 timeoutMs,663 )));664 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);665 if (hit) return hit;666 }667 return null;668}669670async function probeOne(671 endpoint: SyncEndpoint,672 ourIdentity: SyncPeerIdentity,673 expectedRemotePubkeyHex: string,674 rendezvousIds: readonly string[],675 timeoutMs: number,676): Promise<ProbedSyncEndpoint | null> {677 for (const rendezvousId of rendezvousIds) {678 try {679 const session = await openSyncSession(680 endpoint,681 ourIdentity,682 expectedRemotePubkeyHex,683 rendezvousId,684 { connectTimeoutMs: timeoutMs },685 );686 await session.close();687 return { ...endpoint, rendezvousId };688 } catch {689 690 }691 }692 return null;693}