05-subnet-probe.md

6.6 KB · raw

rendered · source

Change 5 — Subnet probe for the pairing port

DRAFT. Conforms to _GROUND-TRUTH.md. Proposed new module: /Users/june/projects/peach-browser/src/background/desktop-pairing-probe.ts. Consumed by Change 3 (findHost).

Verdict: the EXISTING probe cannot be reused as-is

There IS an existing /24 probe, and its port is already parameterized:

// src/shared/chirp-sync-transport.ts:238
export async function probeSubnetForPeer(
  subnet: string,           // e.g. "192.168.1"
  port: number,             // ← already a parameter (used at lines 256, 261-263)
  ourLongTermPrivateKey: Uint8Array,
  ourLongTermPublicKey: Uint8Array,
  expectedRemotePubkeyHex: string,
  options?: { batch?; timeoutMs?; cooldownMs?; now? },
): Promise<SyncEndpoint | null>

So "just call it with 49142" is tempting — but it is wrong for pairing:

  1. probeSubnetForPeer runs a full Noise XX handshake against an expected long-term static key per host (probeOne → openChirpSyncSession, chirp-sync-transport.ts:272-292, 51-156). It only returns a host that completes Noise XX with expectedRemotePubkeyHex.
  2. During code pairing we have no long-term pubkey yet — we only obtain the helper's ephemeral key, and only AFTER the plaintext PEACH_HELLO / PEACH_HELLO_ACK exchange (ground truth §2-3). The helper on port 49142 speaks the HELLO protocol, not Noise XX. openChirpSyncSession would send a Noise XX msg1 the pairing server never expects and fail every host.
  3. probeSubnetForPeer is also rate-limited per-pubkey (subnetProbeOnCooldown, chirp-sync-transport.ts:210-217); there is no pubkey to key the cooldown on during pairing.

Therefore Change 5 supplies a lighter, handshake-free port probe: "is there a WebSocket server answering on this port?" — nothing more. The HELLO handshake then happens against the single found host in Change 3.

Design

A plain /24 scan that returns the first IP whose ws://<ip>:<port>/ WebSocket reaches onopen within a tight timeout, then immediately closes it. No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of probeSubnetForPeer (batches of 16, ~500ms timeout) so worst-case scan time stays ~comparable.

NOTE: a bare port probe is intentionally undiscriminating — it returns the FIRST host with an open WebSocket on 49142, which during an active peach-sync pair session is the helper. The security boundary is NOT the probe: it is (a) the 6-digit code in PEACH_HELLO (a non-matching host closes the socket) and (b) the user-confirmed SAS. The probe only needs to find a candidate; it does not need to authenticate it.


Apply-ready module — src/background/desktop-pairing-probe.ts

// Handshake-free /24 port probe for desktop pairing (Change 5).
//
// Unlike `probeSubnetForPeer` (chirp-sync-transport.ts), this does NOT run
// Noise — during code pairing we have no long-term pubkey, and the pairing
// server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise
// XX. This probe answers only "is a WebSocket server reachable here?"; the
// 6-digit code + user-confirmed SAS are the actual trust boundary.

const DEFAULT_PROBE_TIMEOUT_MS = 500;
const DEFAULT_PROBE_BATCH = 16;

export interface PortProbeOptions {
  batch?: number;
  timeoutMs?: number;
  /** Injection seam for tests — defaults to the real WebSocket open check. */
  probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;
}

/**
 * Scan `subnet` (e.g. "192.168.1") for the first host accepting a WebSocket
 * connection on `port`. Returns the IP string, or null if none answered.
 */
export async function probeSubnetForPort(
  subnet: string,
  port: number,
  options: PortProbeOptions = {},
): Promise<string | null> {
  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;
  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
  const probe = options.probeOne ?? wsPortOpen;

  for (let start = 1; start <= 254; start += batchSize) {
    const end = Math.min(start + batchSize - 1, 254);
    const ips: string[] = [];
    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);

    const results = await Promise.all(
      ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),
    );
    const hit = results.find((r): r is string => r !== null);
    if (hit) return hit;
  }
  return null;
}

/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */
function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {
  return new Promise((resolve) => {
    let ws: WebSocket;
    let settled = false;
    const done = (ok: boolean) => {
      if (settled) return;
      settled = true;
      try { ws.close(); } catch { /* ignore */ }
      resolve(ok);
    };
    try {
      ws = new WebSocket(`ws://${ip}:${port}/`);
    } catch {
      resolve(false);
      return;
    }
    const t = setTimeout(() => done(false), timeoutMs);
    ws.onopen = () => { clearTimeout(t); done(true); };
    ws.onerror = () => { clearTimeout(t); done(false); };
    ws.onclose = () => { clearTimeout(t); done(false); };
  });
}

Why a new module rather than extracting from chirp-sync-transport.ts

Extracting a shared scan core from probeSubnetForPeer would force the Noise flow and the pairing flow to share a helper whose only common part is the nested batch loop (~10 lines). The per-host predicate differs fundamentally (full Noise XX vs bare WS open), as does the cooldown handling. A standalone module keeps the pairing path free of the sync transport's pubkey/cooldown machinery and avoids touching the load-bearing, already-tested sync probe. No edit to chirp-sync-transport.ts is required.

Call sites

Integration TODO

  1. Consider a hard ceiling on total scan time (the audio-chirp flow uses a 90s chirp window; a pairing probe should resolve much faster — a full /24 at 16-wide batches of 500ms is ~8s worst case, acceptable).
  2. The probe finds the FIRST open WS on the port. If multiple hosts answer (unlikely on a home LAN during an active pairing), only the first is tried; the wrong-code silent-close in Change 3 then surfaces as invalid_code. A future enhancement could try each candidate in turn until one ACKs — out of scope for this draft.