chirp-sync-transport.ts

8.4 KB · raw

1// WebSocket sync transport — v3 chirp-sync Phase 2.2//3// The extension is always the WebSocket client; Android (or future iOS /4// desktop sync hosts) is always the server. Each binary WebSocket message5// carries exactly one Noise frame — no length prefix, no fragmentation.6//7// On connect:8//   1. Open ws://<ip>:<port>/9//   2. Run Noise XX as the initiator, using our long-term sync identity10//      static keypair.11//   3. Verify the responder's static public key matches the paired-device12//      registry entry we expect. Mismatch is a hard fail — no implicit13//      pubkey trust on the LAN.14//   4. Hand the caller a `ChirpSyncSession` for the SyncMessage protocol.15//16// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`17// when the cached last-known IP fails. We use a short connection timeout18// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan19// under ~10 seconds.2021import { 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;27/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */28export const SUBNET_PROBE_COOLDOWN_MS = 60_000;2930export interface ChirpSyncSession {31  send(plaintext: Uint8Array): void;32  receive(): Promise<Uint8Array>;33  close(): Promise<void>;34  remoteStaticPublicKey: Uint8Array;35}3637export interface SyncEndpoint {38  ip: string;39  port: number;40}4142/**43 * Open a WebSocket to (ip, port), run Noise XX, verify the responder's44 * long-term static key matches `expectedRemotePubkeyHex`, and return a45 * ready-to-use session.46 *47 * On a successful return, `setLastKnownIp` in the caller can persist48 * (ip, port) for the next session's fast path.49 */50export async function openChirpSyncSession(51  endpoint: SyncEndpoint,52  ourLongTermPrivateKey: Uint8Array,53  ourLongTermPublicKey: Uint8Array,54  expectedRemotePubkeyHex: string,55  options: { connectTimeoutMs?: number } = {},56): Promise<ChirpSyncSession> {57  const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);58  const inbox: Uint8Array[] = [];59  const readers: Array<(b: Uint8Array) => void> = [];60  const errors: Error[] = [];61  let closed = false;6263  ws.binaryType = "arraybuffer";64  ws.onmessage = (ev: MessageEvent) => {65    const data = ev.data;66    if (data instanceof ArrayBuffer) {67      const bytes = new Uint8Array(data);68      const r = readers.shift();69      if (r) r(bytes);70      else inbox.push(bytes);71    }72  };73  ws.onerror = () => {74    errors.push(new Error("WebSocket error"));75  };76  ws.onclose = () => {77    closed = true;78    while (readers.length > 0) {79      const r = readers.shift()!;80      r(new Uint8Array(0));81    }82  };8384  function send(bytes: Uint8Array): Promise<void> {85    return new Promise((resolve, reject) => {86      try {87        ws.send(bytes as Uint8Array<ArrayBuffer>);88        resolve();89      } catch (err) {90        reject(err as Error);91      }92    });93  }94  function receive(): Promise<Uint8Array> {95    if (closed) return Promise.reject(new Error("WebSocket closed before frame"));96    const v = inbox.shift();97    if (v) return Promise.resolve(v);98    return new Promise<Uint8Array>((resolve, reject) => {99      readers.push((b) => {100        if (b.length === 0 && closed) reject(new Error("WebSocket closed"));101        else resolve(b);102      });103    });104  }105106  let session: NoiseXXSession;107  try {108    session = await noiseXXInitiator(109      ourLongTermPrivateKey,110      ourLongTermPublicKey,111      send,112      receive,113    );114  } catch (err) {115    try { ws.close(); } catch { /* ignore */ }116    throw err;117  }118119  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);120  if (remoteHex !== expectedRemotePubkeyHex) {121    try { ws.close(); } catch { /* ignore */ }122    throw new Error(123      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +124      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,125    );126  }127  // Bind so the unused expectation-source check helper stays honest.128  void hexToPubkey(expectedRemotePubkeyHex);129130  return {131    send(plaintext: Uint8Array) {132      const ciphertext = session.encrypt(plaintext);133      ws.send(ciphertext as Uint8Array<ArrayBuffer>);134    },135    async receive() {136      const ct = await receive();137      return session.decrypt(ct);138    },139    async close() {140      try { ws.close(); } catch { /* ignore */ }141    },142    remoteStaticPublicKey: session.remoteStaticPublicKey,143  };144}145146function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {147  return new Promise((resolve, reject) => {148    let ws: WebSocket;149    try {150      ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);151    } catch (err) {152      reject(err as Error);153      return;154    }155    const t = setTimeout(() => {156      try { ws.close(); } catch { /* ignore */ }157      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));158    }, timeoutMs);159    ws.onopen = () => {160      clearTimeout(t);161      resolve(ws);162    };163    ws.onerror = () => {164      clearTimeout(t);165      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));166    };167  });168}169170/**171 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps172 * full /24 scans at once per minute so an extension whose cached IP has173 * gone stale doesn't hammer the LAN with handshake attempts every time174 * the popup opens.175 *176 * Keyed by the expected remote pubkey because each paired peer is probed177 * independently; the rate limit applies to "scan looking for THIS peer",178 * not "scan the subnet at all".179 */180const lastProbeAt = new Map<string, number>();181182export function subnetProbeOnCooldown(183  expectedRemotePubkeyHex: string,184  now: number = Date.now(),185  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,186): boolean {187  const last = lastProbeAt.get(expectedRemotePubkeyHex);188  return last !== undefined && now - last < cooldownMs;189}190191/** Test-only — clear the per-pubkey probe cooldown ledger. */192export function _resetSubnetProbeCooldown(): void {193  lastProbeAt.clear();194}195196/**197 * Subnet probe — scan a /24 looking for a host that completes Noise XX198 * with the expected long-term static key. Returns the first match, or199 * null if the cooldown is still active or no host responded.200 *201 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey202 * (v2 brief §5.2). Callers should still attempt the cached203 * `last_known_ip`/`last_known_port` directly first — the subnet probe is204 * fallback only.205 *206 * For each IP we attempt the full Noise XX handshake, but with a tight207 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser208 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.209 */210export async function probeSubnetForPeer(211  subnet: string, // e.g. "192.168.1"212  port: number,213  ourLongTermPrivateKey: Uint8Array,214  ourLongTermPublicKey: Uint8Array,215  expectedRemotePubkeyHex: string,216  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},217): Promise<SyncEndpoint | null> {218  const now = options.now ?? Date.now();219  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;220  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {221    return null;222  }223  lastProbeAt.set(expectedRemotePubkeyHex, now);224  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;225  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;226  for (let start = 1; start <= 254; start += batchSize) {227    const end = Math.min(start + batchSize - 1, 254);228    const ips: string[] = [];229    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);230231    const results = await Promise.all(ips.map((ip) => probeOne(232      { ip, port },233      ourLongTermPrivateKey,234      ourLongTermPublicKey,235      expectedRemotePubkeyHex,236      timeoutMs,237    )));238    const hit = results.find((r): r is SyncEndpoint => r !== null);239    if (hit) return hit;240  }241  return null;242}243244async function probeOne(245  endpoint: SyncEndpoint,246  ourLongTermPrivateKey: Uint8Array,247  ourLongTermPublicKey: Uint8Array,248  expectedRemotePubkeyHex: string,249  timeoutMs: number,250): Promise<SyncEndpoint | null> {251  try {252    const session = await openChirpSyncSession(253      endpoint,254      ourLongTermPrivateKey,255      ourLongTermPublicKey,256      expectedRemotePubkeyHex,257      { connectTimeoutMs: timeoutMs },258    );259    await session.close();260    return endpoint;261  } catch {262    return null;263  }264}