chirp-sync-transport.ts

6.8 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;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}3940/**41 * Open a WebSocket to (ip, port), run Noise XX, verify the responder's42 * long-term static key matches `expectedRemotePubkeyHex`, and return a43 * ready-to-use session.44 *45 * On a successful return, `setLastKnownIp` in the caller can persist46 * (ip, port) for the next session's fast path.47 */48export 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 { /* ignore */ }114    throw err;115  }116117  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);118  if (remoteHex !== expectedRemotePubkeyHex) {119    try { ws.close(); } catch { /* ignore */ }120    throw new Error(121      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +122      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,123    );124  }125  // Bind so the unused expectation-source check helper stays honest.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 { /* ignore */ }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 { /* ignore */ }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}167168/**169 * Subnet probe — scan a /24 looking for a host that completes Noise XX170 * with the expected long-term static key. Returns the first match.171 *172 * For each IP we attempt the full Noise XX handshake, but with a tight173 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser174 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.175 */176export async function probeSubnetForPeer(177  subnet: string, // e.g. "192.168.1"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}