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} from "./sync-padding";32import { hexToPubkey } from "./sync-identity";33import { buildRendezvousUrl } from "./rendezvous";34import type { DeviceType } from "./sync-types";3536373839404142434445464748495051525354const DEFAULT_PROBE_TIMEOUT_MS = 500;55const DEFAULT_PROBE_BATCH = 16;56const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;57const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;5859export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6061export interface SyncSession {62 send(plaintext: Uint8Array): void;63 receive(): Promise<Uint8Array>;64 close(): Promise<void>;65 remoteStaticPublicKey: Uint8Array;66}6768export interface SyncEndpoint {69 ip: string;70 port: number;71}7273export interface ProbedSyncEndpoint extends SyncEndpoint {74 75 rendezvousId: string;76}77787980export interface SyncPeerIdentity {81 deviceId: string;82 deviceName: string;83 deviceType: DeviceType;84 longTermPublicKeyHex: string;85 86 longTermPrivateKey: Uint8Array;87 signingPublicKeyHex?: string;88}8990export class SyncFrameTooLargeError extends Error {91 constructor(message = "sync frame too large (WebSocket close 1009)") {92 super(message);93 this.name = "SyncFrameTooLargeError";94 }95}9697export interface SyncSessionOptions {98 connectTimeoutMs?: number;99 handshakeTimeoutMs?: number;100}101102function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {103 return new TextEncoder().encode(JSON.stringify({104 type: "PAIR_IDENTITY",105 device_id: id.deviceId,106 device_name: id.deviceName,107 device_type: id.deviceType,108 long_term_public_key_hex: id.longTermPublicKeyHex,109 signing_public_key_hex: id.signingPublicKeyHex,110 }));111}112113114115116117118119120121export async function openSyncSession(122 endpoint: SyncEndpoint,123 ourIdentity: SyncPeerIdentity,124 expectedRemotePubkeyHex: string,125 rendezvousId: string,126 options: SyncSessionOptions = {},127): Promise<SyncSession> {128 return openSyncSessionAtUrl(129 `ws://${endpoint.ip}:${endpoint.port}/`,130 "LAN peer",131 ourIdentity,132 expectedRemotePubkeyHex,133 rendezvousId,134 options,135 );136}137138139140141142export async function openRelaySyncSession(143 relayUrl: string,144 ourIdentity: SyncPeerIdentity,145 expectedRemotePubkeyHex: string,146 rendezvousId: string,147 options: SyncSessionOptions = {},148): Promise<SyncSession> {149 return openSyncSessionAtUrl(150 buildRendezvousUrl(relayUrl, rendezvousId),151 "relay",152 ourIdentity,153 expectedRemotePubkeyHex,154 rendezvousId,155 options,156 );157}158159async function openSyncSessionAtUrl(160 url: string,161 targetLabel: string,162 ourIdentity: SyncPeerIdentity,163 expectedRemotePubkeyHex: string,164 rendezvousId: string,165 options: SyncSessionOptions,166): Promise<SyncSession> {167 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {168 throw new Error("reconnect requires a valid rotating rendezvous id");169 }170 const ws = await connectWebSocketUrl(171 url,172 targetLabel,173 options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,174 );175 const inbox: Uint8Array[] = [];176 const readers: Array<(b: Uint8Array) => void> = [];177 let closed = false;178 let closeError: Error = new Error("WebSocket closed");179180 ws.binaryType = "arraybuffer";181 ws.onmessage = (ev: MessageEvent) => {182 const data = ev.data;183 if (data instanceof ArrayBuffer) {184 185 186 187 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {188 189 190 191 console.warn(192 `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,193 );194 closeError = new SyncFrameTooLargeError(195 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,196 );197 try { ws.close(1009, "sync frame too large"); } catch { }198 return;199 }200 const bytes = new Uint8Array(data);201 const r = readers.shift();202 if (r) r(bytes);203 else inbox.push(bytes);204 }205 };206 ws.onerror = () => {207 208 209 210 console.warn("[Peach] sync WebSocket error; closing transport");211 try { ws.close(); } catch { }212 };213 ws.onclose = (event: CloseEvent) => {214 closed = true;215 if (event.code === 1009) {216 closeError = new SyncFrameTooLargeError();217 }218 while (readers.length > 0) {219 const r = readers.shift()!;220 r(new Uint8Array(0));221 }222 };223224 function send(bytes: Uint8Array): Promise<void> {225 return new Promise((resolve, reject) => {226 if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {227 reject(228 new SyncFrameTooLargeError(229 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,230 ),231 );232 return;233 }234 try {235 ws.send(bytes as Uint8Array<ArrayBuffer>);236 resolve();237 } catch (err) {238 reject(err as Error);239 }240 });241 }242 function receive(): Promise<Uint8Array> {243 if (closed) return Promise.reject(closeError);244 const v = inbox.shift();245 if (v) return Promise.resolve(v);246 return new Promise<Uint8Array>((resolve, reject) => {247 readers.push((b) => {248 if (b.length === 0 && closed) reject(closeError);249 else resolve(b);250 });251 });252 }253254 const handshakeTimer = setTimeout(() => {255 closeError = new Error("sync_handshake_timeout");256 try { ws.close(1000, "sync handshake timeout"); } catch { }257 }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);258 let session: NoiseNKSession;259 try {260 const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);261 if (ourIdentity.longTermPrivateKey.length !== 32) {262 throw new Error("local reconnect private key must be exactly 32 bytes");263 }264 265 266 267 await send(new TextEncoder().encode(JSON.stringify({268 type: "PEACH_RECONNECT",269 rendezvous_id: rendezvousId,270 })));271 session = await noiseKKInitiator(272 ourIdentity.longTermPrivateKey,273 ourPublicKey,274 hexToPubkey(expectedRemotePubkeyHex),275 send,276 receive,277 );278 } catch (err) {279 clearTimeout(handshakeTimer);280 try { ws.close(); } catch { }281 throw err;282 }283284 try {285 await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));286 const theirFrame = JSON.parse(287 new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),288 );289 if (theirFrame?.type !== "PAIR_IDENTITY") {290 throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);291 }292 if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {293 throw new Error("peer identity does not match authenticated KK key");294 }295 clearTimeout(handshakeTimer);296 } catch (err) {297 clearTimeout(handshakeTimer);298 session.close();299 try { ws.close(); } catch { }300 throw err;301 }302303 return {304 async send(plaintext: Uint8Array) {305 306 307 308 309 310 311 312 313 314 const padded = padSyncFrame(plaintext);315 if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {316 throw new SyncFrameTooLargeError(317 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,318 );319 }320 321 322 323 if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {324 const ct = session.encrypt(padded);325 ws.send(ct as Uint8Array<ArrayBuffer>);326 return;327 }328 const total = padded.byteLength;329 const payload = padded.subarray(0);330 for (let offset = 0; offset < total; ) {331 const remaining = total - offset;332 333 334 335 336 337 338 const isLast = remaining <= MAX_NOISE_PLAINTEXT_BYTES;339 const chunkPlaintext = isLast340 ? payload.subarray(offset, total)341 : payload.subarray(offset, offset + MAX_NOISE_PLAINTEXT_BYTES);342 343 344 345 346 347 const plaintext = isLast348 ? chunkPlaintext349 : concatBytes(FRAME_CONTINUATION_MARKER, chunkPlaintext);350 if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {351 throw new Error(352 `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,353 );354 }355 const ct = session.encrypt(plaintext);356 if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {357 throw new SyncFrameTooLargeError(358 `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,359 );360 }361 ws.send(ct as Uint8Array<ArrayBuffer>);362 offset += chunkPlaintext.byteLength;363 }364 },365 async receive() {366 367 368 369 370 let buffered: Uint8Array | null = null;371 while (true) {372 const ct = await receive();373 const pt = session.decrypt(ct);374 if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {375 376 buffered = pt.subarray(1);377 continue;378 }379 if (buffered !== null) {380 if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {381 const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);382 merged.set(buffered, 0);383 merged.set(pt.subarray(1), buffered.length);384 buffered = merged;385 continue;386 }387 388 389 const reassembled = new Uint8Array(buffered.length + pt.byteLength);390 reassembled.set(buffered, 0);391 reassembled.set(pt, buffered.length);392 return unpadSyncFrame(reassembled);393 }394 395 return unpadSyncFrame(pt);396 }397 },398 async close() {399 try { ws.close(); } catch { }400 session.close();401 },402 remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),403 };404}405406function concatBytes(prefix: number, payload: Uint8Array): Uint8Array {407 const out = new Uint8Array(1 + payload.byteLength);408 out[0] = prefix;409 out.set(payload, 1);410 return out;411}412413function connectWebSocketUrl(414 url: string,415 targetLabel: string,416 timeoutMs: number,417): Promise<WebSocket> {418 return new Promise((resolve, reject) => {419 let ws: WebSocket;420 try {421 ws = new WebSocket(url);422 } catch (err) {423 reject(err as Error);424 return;425 }426 const t = setTimeout(() => {427 try { ws.close(); } catch { }428 reject(new Error(`${targetLabel} WebSocket connect timed out`));429 }, timeoutMs);430 ws.onopen = () => {431 clearTimeout(t);432 resolve(ws);433 };434 ws.onerror = () => {435 clearTimeout(t);436 reject(new Error(`${targetLabel} WebSocket connect errored`));437 };438 });439}440441442443444445446447448449450451const lastProbeAt = new Map<string, number>();452453export function subnetProbeOnCooldown(454 expectedRemotePubkeyHex: string,455 now: number = Date.now(),456 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,457): boolean {458 const last = lastProbeAt.get(expectedRemotePubkeyHex);459 return last !== undefined && now - last < cooldownMs;460}461462463export function _resetSubnetProbeCooldown(): void {464 lastProbeAt.clear();465}466467468469470471472473474475476477478479480481export async function probeSubnetForPeer(482 subnet: string, 483 port: number,484 ourIdentity: SyncPeerIdentity,485 expectedRemotePubkeyHex: string,486 rendezvousIds: readonly string[],487 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},488): Promise<ProbedSyncEndpoint | null> {489 if (rendezvousIds.length === 0) {490 throw new Error("subnet probe requires at least one rendezvous id");491 }492 const now = options.now ?? Date.now();493 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;494 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {495 return null;496 }497 lastProbeAt.set(expectedRemotePubkeyHex, now);498 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;499 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;500 for (let start = 1; start <= 254; start += batchSize) {501 const end = Math.min(start + batchSize - 1, 254);502 const ips: string[] = [];503 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);504505 const results = await Promise.all(ips.map((ip) => probeOne(506 { ip, port },507 ourIdentity,508 expectedRemotePubkeyHex,509 rendezvousIds,510 timeoutMs,511 )));512 const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);513 if (hit) return hit;514 }515 return null;516}517518async function probeOne(519 endpoint: SyncEndpoint,520 ourIdentity: SyncPeerIdentity,521 expectedRemotePubkeyHex: string,522 rendezvousIds: readonly string[],523 timeoutMs: number,524): Promise<ProbedSyncEndpoint | null> {525 for (const rendezvousId of rendezvousIds) {526 try {527 const session = await openSyncSession(528 endpoint,529 ourIdentity,530 expectedRemotePubkeyHex,531 rendezvousId,532 { connectTimeoutMs: timeoutMs },533 );534 await session.close();535 return { ...endpoint, rendezvousId };536 } catch {537 538 }539 }540 return null;541}