chirp-sync-transport.ts

8.9 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    );125  } catch (err) {126    try { ws.close(); } catch { /* ignore */ }127    throw err;128  }129130  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);131  if (remoteHex !== expectedRemotePubkeyHex) {132    try { ws.close(); } catch { /* ignore */ }133    throw new Error(134      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +135      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,136    );137  }138  // Bind so the unused expectation-source check helper stays honest.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 { /* ignore */ }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 { /* ignore */ }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}180181/**182 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps183 * full /24 scans at once per minute so an extension whose cached IP has184 * gone stale doesn't hammer the LAN with handshake attempts every time185 * the popup opens.186 *187 * Keyed by the expected remote pubkey because each paired peer is probed188 * independently; the rate limit applies to "scan looking for THIS peer",189 * not "scan the subnet at all".190 */191const 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}201202/** Test-only — clear the per-pubkey probe cooldown ledger. */203export function _resetSubnetProbeCooldown(): void {204  lastProbeAt.clear();205}206207/**208 * Subnet probe — scan a /24 looking for a host that completes Noise XX209 * with the expected long-term static key. Returns the first match, or210 * null if the cooldown is still active or no host responded.211 *212 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey213 * (v2 brief §5.2). Callers should still attempt the cached214 * `last_known_ip`/`last_known_port` directly first — the subnet probe is215 * fallback only.216 *217 * For each IP we attempt the full Noise XX handshake, but with a tight218 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser219 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.220 */221export async function probeSubnetForPeer(222  subnet: string, // e.g. "192.168.1"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}