chirp-sync-transport.ts

9.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 { 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;28/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */29export 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}4243/**44 * Open a WebSocket to (ip, port), run Noise XX, verify the responder's45 * long-term static key matches `expectedRemotePubkeyHex`, and return a46 * ready-to-use session.47 *48 * On a successful return, `setLastKnownIp` in the caller can persist49 * (ip, port) for the next session's fast path.50 */51export 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      // MV-3: refuse oversized frames before they are buffered or decrypted.69      // A peer that sends one is misbehaving — tear the session down rather70      // than letting the inbox grow toward OOM.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 { /* ignore */ }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      // PCH-01-003: abort BEFORE msg3 if the responder is not the peer we125      // expect, so a probed-but-unexpected LAN host never receives our static.126      (remoteStatic) => {127        const seenHex = pubkeyToHex(remoteStatic);128        if (seenHex !== expectedRemotePubkeyHex) {129          throw new Error(130            `paired-device static key mismatch (got ${seenHex.slice(0, 16)}…, ` +131            `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,132          );133        }134      },135    );136  } catch (err) {137    try { ws.close(); } catch { /* ignore */ }138    throw err;139  }140141  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);142  if (remoteHex !== expectedRemotePubkeyHex) {143    try { ws.close(); } catch { /* ignore */ }144    throw new Error(145      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +146      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,147    );148  }149  // Bind so the unused expectation-source check helper stays honest.150  void hexToPubkey(expectedRemotePubkeyHex);151152  return {153    send(plaintext: Uint8Array) {154      const ciphertext = session.encrypt(plaintext);155      ws.send(ciphertext as Uint8Array<ArrayBuffer>);156    },157    async receive() {158      const ct = await receive();159      return session.decrypt(ct);160    },161    async close() {162      try { ws.close(); } catch { /* ignore */ }163    },164    remoteStaticPublicKey: session.remoteStaticPublicKey,165  };166}167168function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {169  return new Promise((resolve, reject) => {170    let ws: WebSocket;171    try {172      ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);173    } catch (err) {174      reject(err as Error);175      return;176    }177    const t = setTimeout(() => {178      try { ws.close(); } catch { /* ignore */ }179      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));180    }, timeoutMs);181    ws.onopen = () => {182      clearTimeout(t);183      resolve(ws);184    };185    ws.onerror = () => {186      clearTimeout(t);187      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));188    };189  });190}191192/**193 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps194 * full /24 scans at once per minute so an extension whose cached IP has195 * gone stale doesn't hammer the LAN with handshake attempts every time196 * the popup opens.197 *198 * Keyed by the expected remote pubkey because each paired peer is probed199 * independently; the rate limit applies to "scan looking for THIS peer",200 * not "scan the subnet at all".201 */202const lastProbeAt = new Map<string, number>();203204export function subnetProbeOnCooldown(205  expectedRemotePubkeyHex: string,206  now: number = Date.now(),207  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,208): boolean {209  const last = lastProbeAt.get(expectedRemotePubkeyHex);210  return last !== undefined && now - last < cooldownMs;211}212213/** Test-only — clear the per-pubkey probe cooldown ledger. */214export function _resetSubnetProbeCooldown(): void {215  lastProbeAt.clear();216}217218/**219 * Subnet probe — scan a /24 looking for a host that completes Noise XX220 * with the expected long-term static key. Returns the first match, or221 * null if the cooldown is still active or no host responded.222 *223 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey224 * (v2 brief §5.2). Callers should still attempt the cached225 * `last_known_ip`/`last_known_port` directly first — the subnet probe is226 * fallback only.227 *228 * For each IP we attempt the full Noise XX handshake, but with a tight229 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser230 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.231 */232export async function probeSubnetForPeer(233  subnet: string, // e.g. "192.168.1"234  port: number,235  ourLongTermPrivateKey: Uint8Array,236  ourLongTermPublicKey: Uint8Array,237  expectedRemotePubkeyHex: string,238  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},239): Promise<SyncEndpoint | null> {240  const now = options.now ?? Date.now();241  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;242  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {243    return null;244  }245  lastProbeAt.set(expectedRemotePubkeyHex, now);246  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;247  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;248  for (let start = 1; start <= 254; start += batchSize) {249    const end = Math.min(start + batchSize - 1, 254);250    const ips: string[] = [];251    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);252253    const results = await Promise.all(ips.map((ip) => probeOne(254      { ip, port },255      ourLongTermPrivateKey,256      ourLongTermPublicKey,257      expectedRemotePubkeyHex,258      timeoutMs,259    )));260    const hit = results.find((r): r is SyncEndpoint => r !== null);261    if (hit) return hit;262  }263  return null;264}265266async function probeOne(267  endpoint: SyncEndpoint,268  ourLongTermPrivateKey: Uint8Array,269  ourLongTermPublicKey: Uint8Array,270  expectedRemotePubkeyHex: string,271  timeoutMs: number,272): Promise<SyncEndpoint | null> {273  try {274    const session = await openChirpSyncSession(275      endpoint,276      ourLongTermPrivateKey,277      ourLongTermPublicKey,278      expectedRemotePubkeyHex,279      { connectTimeoutMs: timeoutMs },280    );281    await session.close();282    return endpoint;283  } catch {284    return null;285  }286}