123456789101112131415161718192021import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";22import { hexToPubkey, pubkeyToHex } from "./sync-identity";2324const DEFAULT_PROBE_TIMEOUT_MS = 500;25const DEFAULT_PROBE_BATCH = 16;26const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;2728export interface ChirpSyncSession {29 send(plaintext: Uint8Array): void;30 receive(): Promise<Uint8Array>;31 close(): Promise<void>;32 remoteStaticPublicKey: Uint8Array;33}3435export interface SyncEndpoint {36 ip: string;37 port: number;38}39404142434445464748export async function openChirpSyncSession(49 endpoint: SyncEndpoint,50 ourLongTermPrivateKey: Uint8Array,51 ourLongTermPublicKey: Uint8Array,52 expectedRemotePubkeyHex: string,53 options: { connectTimeoutMs?: number } = {},54): Promise<ChirpSyncSession> {55 const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);56 const inbox: Uint8Array[] = [];57 const readers: Array<(b: Uint8Array) => void> = [];58 const errors: Error[] = [];59 let closed = false;6061 ws.binaryType = "arraybuffer";62 ws.onmessage = (ev: MessageEvent) => {63 const data = ev.data;64 if (data instanceof ArrayBuffer) {65 const bytes = new Uint8Array(data);66 const r = readers.shift();67 if (r) r(bytes);68 else inbox.push(bytes);69 }70 };71 ws.onerror = () => {72 errors.push(new Error("WebSocket error"));73 };74 ws.onclose = () => {75 closed = true;76 while (readers.length > 0) {77 const r = readers.shift()!;78 r(new Uint8Array(0));79 }80 };8182 function send(bytes: Uint8Array): Promise<void> {83 return new Promise((resolve, reject) => {84 try {85 ws.send(bytes as Uint8Array<ArrayBuffer>);86 resolve();87 } catch (err) {88 reject(err as Error);89 }90 });91 }92 function receive(): Promise<Uint8Array> {93 if (closed) return Promise.reject(new Error("WebSocket closed before frame"));94 const v = inbox.shift();95 if (v) return Promise.resolve(v);96 return new Promise<Uint8Array>((resolve, reject) => {97 readers.push((b) => {98 if (b.length === 0 && closed) reject(new Error("WebSocket closed"));99 else resolve(b);100 });101 });102 }103104 let session: NoiseXXSession;105 try {106 session = await noiseXXInitiator(107 ourLongTermPrivateKey,108 ourLongTermPublicKey,109 send,110 receive,111 );112 } catch (err) {113 try { ws.close(); } catch { }114 throw err;115 }116117 const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);118 if (remoteHex !== expectedRemotePubkeyHex) {119 try { ws.close(); } catch { }120 throw new Error(121 `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +122 `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,123 );124 }125 126 void hexToPubkey(expectedRemotePubkeyHex);127128 return {129 send(plaintext: Uint8Array) {130 const ciphertext = session.encrypt(plaintext);131 ws.send(ciphertext as Uint8Array<ArrayBuffer>);132 },133 async receive() {134 const ct = await receive();135 return session.decrypt(ct);136 },137 async close() {138 try { ws.close(); } catch { }139 },140 remoteStaticPublicKey: session.remoteStaticPublicKey,141 };142}143144function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {145 return new Promise((resolve, reject) => {146 let ws: WebSocket;147 try {148 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);149 } catch (err) {150 reject(err as Error);151 return;152 }153 const t = setTimeout(() => {154 try { ws.close(); } catch { }155 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));156 }, timeoutMs);157 ws.onopen = () => {158 clearTimeout(t);159 resolve(ws);160 };161 ws.onerror = () => {162 clearTimeout(t);163 reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));164 };165 });166}167168169170171172173174175176export async function probeSubnetForPeer(177 subnet: string, 178 port: number,179 ourLongTermPrivateKey: Uint8Array,180 ourLongTermPublicKey: Uint8Array,181 expectedRemotePubkeyHex: string,182 options: { batch?: number; timeoutMs?: number } = {},183): Promise<SyncEndpoint | null> {184 const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;185 const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;186 for (let start = 1; start <= 254; start += batchSize) {187 const end = Math.min(start + batchSize - 1, 254);188 const ips: string[] = [];189 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);190191 const results = await Promise.all(ips.map((ip) => probeOne(192 { ip, port },193 ourLongTermPrivateKey,194 ourLongTermPublicKey,195 expectedRemotePubkeyHex,196 timeoutMs,197 )));198 const hit = results.find((r): r is SyncEndpoint => r !== null);199 if (hit) return hit;200 }201 return null;202}203204async function probeOne(205 endpoint: SyncEndpoint,206 ourLongTermPrivateKey: Uint8Array,207 ourLongTermPublicKey: Uint8Array,208 expectedRemotePubkeyHex: string,209 timeoutMs: number,210): Promise<SyncEndpoint | null> {211 try {212 const session = await openChirpSyncSession(213 endpoint,214 ourLongTermPrivateKey,215 ourLongTermPublicKey,216 expectedRemotePubkeyHex,217 { connectTimeoutMs: timeoutMs },218 );219 await session.close();220 return endpoint;221 } catch {222 return null;223 }224}