123456789101112131415161718192021import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";22import { SYNC_LIMITS } from "./sync-limits";23import { hexToPubkey, pubkeyToHex } from "./sync-identity";2425const DEFAULT_PROBE_TIMEOUT_MS = 500;26const DEFAULT_PROBE_BATCH = 16;27const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;2829export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3031export interface ChirpSyncSession {32 send(plaintext: Uint8Array): void;33 receive(): Promise<Uint8Array>;34 close(): Promise<void>;35 remoteStaticPublicKey: Uint8Array;36}3738export interface SyncEndpoint {39 ip: string;40 port: number;41}42434445464748495051export async function openChirpSyncSession(52 endpoint: SyncEndpoint,53 ourLongTermPrivateKey: Uint8Array,54 ourLongTermPublicKey: Uint8Array,55 expectedRemotePubkeyHex: string,56 options: { connectTimeoutMs?: number } = {},57): Promise<ChirpSyncSession> {58 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);59 const inbox: Uint8Array[] = [];60 const readers: Array<(b: Uint8Array) => void> = [];61 const errors: Error[] = [];62 let closed = false;6364 ws.binaryType = "arraybuffer";65 ws.onmessage = (ev: MessageEvent) => {66 const data = ev.data;67 if (data instanceof ArrayBuffer) {68 69 70 71 if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {72 errors.push(new Error(73 `sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`,74 ));75 try { ws.close(); } catch { }76 return;77 }78 const bytes = new Uint8Array(data);79 const r = readers.shift();80 if (r) r(bytes);81 else inbox.push(bytes);82 }83 };84 ws.onerror = () => {85 errors.push(new Error("WebSocket error"));86 };87 ws.onclose = () => {88 closed = true;89 while (readers.length > 0) {90 const r = readers.shift()!;91 r(new Uint8Array(0));92 }93 };9495 function send(bytes: Uint8Array): Promise<void> {96 return new Promise((resolve, reject) => {97 try {98 ws.send(bytes as Uint8Array<ArrayBuffer>);99 resolve();100 } catch (err) {101 reject(err as Error);102 }103 });104 }105 function receive(): Promise<Uint8Array> {106 if (closed) return Promise.reject(new Error("WebSocket closed before frame"));107 const v = inbox.shift();108 if (v) return Promise.resolve(v);109 return new Promise<Uint8Array>((resolve, reject) => {110 readers.push((b) => {111 if (b.length === 0 && closed) reject(new Error("WebSocket closed"));112 else resolve(b);113 });114 });115 }116117 let session: NoiseXXSession;118 try {119 session = await noiseXXInitiator(120 ourLongTermPrivateKey,121 ourLongTermPublicKey,122 send,123 receive,124 );125 } catch (err) {126 try { ws.close(); } catch { }127 throw err;128 }129130 const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);131 if (remoteHex !== expectedRemotePubkeyHex) {132 try { ws.close(); } catch { }133 throw new Error(134 `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +135 `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,136 );137 }138 139 void hexToPubkey(expectedRemotePubkeyHex);140141 return {142 send(plaintext: Uint8Array) {143 const ciphertext = session.encrypt(plaintext);144 ws.send(ciphertext as Uint8Array<ArrayBuffer>);145 },146 async receive() {147 const ct = await receive();148 return session.decrypt(ct);149 },150 async close() {151 try { ws.close(); } catch { }152 },153 remoteStaticPublicKey: session.remoteStaticPublicKey,154 };155}156157function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {158 return new Promise((resolve, reject) => {159 let ws: WebSocket;160 try {161 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);162 } catch (err) {163 reject(err as Error);164 return;165 }166 const t = setTimeout(() => {167 try { ws.close(); } catch { }168 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));169 }, timeoutMs);170 ws.onopen = () => {171 clearTimeout(t);172 resolve(ws);173 };174 ws.onerror = () => {175 clearTimeout(t);176 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));177 };178 });179}180181182183184185186187188189190191const lastProbeAt = new Map<string, number>();192193export function subnetProbeOnCooldown(194 expectedRemotePubkeyHex: string,195 now: number = Date.now(),196 cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,197): boolean {198 const last = lastProbeAt.get(expectedRemotePubkeyHex);199 return last !== undefined && now - last < cooldownMs;200}201202203export function _resetSubnetProbeCooldown(): void {204 lastProbeAt.clear();205}206207208209210211212213214215216217218219220221export async function probeSubnetForPeer(222 subnet: string, 223 port: number,224 ourLongTermPrivateKey: Uint8Array,225 ourLongTermPublicKey: Uint8Array,226 expectedRemotePubkeyHex: string,227 options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},228): Promise<SyncEndpoint | null> {229 const now = options.now ?? Date.now();230 const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;231 if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {232 return null;233 }234 lastProbeAt.set(expectedRemotePubkeyHex, now);235 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;236 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;237 for (let start = 1; start <= 254; start += batchSize) {238 const end = Math.min(start + batchSize - 1, 254);239 const ips: string[] = [];240 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);241242 const results = await Promise.all(ips.map((ip) => probeOne(243 { ip, port },244 ourLongTermPrivateKey,245 ourLongTermPublicKey,246 expectedRemotePubkeyHex,247 timeoutMs,248 )));249 const hit = results.find((r): r is SyncEndpoint => r !== null);250 if (hit) return hit;251 }252 return null;253}254255async function probeOne(256 endpoint: SyncEndpoint,257 ourLongTermPrivateKey: Uint8Array,258 ourLongTermPublicKey: Uint8Array,259 expectedRemotePubkeyHex: string,260 timeoutMs: number,261): Promise<SyncEndpoint | null> {262 try {263 const session = await openChirpSyncSession(264 endpoint,265 ourLongTermPrivateKey,266 ourLongTermPublicKey,267 expectedRemotePubkeyHex,268 { connectTimeoutMs: timeoutMs },269 );270 await session.close();271 return endpoint;272 } catch {273 return null;274 }275}