05-subnet-probe.md

6.6 KB · raw

rendered · source

1# Change 5 — Subnet probe for the pairing port23> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module:4> `/Users/june/projects/peach-browser/src/background/desktop-pairing-probe.ts`.5> Consumed by Change 3 (`findHost`).67## Verdict: the EXISTING probe cannot be reused as-is89There IS an existing /24 probe, and its **port is already parameterized**:1011```ts12// src/shared/chirp-sync-transport.ts:23813export async function probeSubnetForPeer(14  subnet: string,           // e.g. "192.168.1"15  port: number,             // ← already a parameter (used at lines 256, 261-263)16  ourLongTermPrivateKey: Uint8Array,17  ourLongTermPublicKey: Uint8Array,18  expectedRemotePubkeyHex: string,19  options?: { batch?; timeoutMs?; cooldownMs?; now? },20): Promise<SyncEndpoint | null>21```2223So "just call it with 49142" is tempting — but it is **wrong for pairing**:24251. `probeSubnetForPeer` runs a **full Noise XX handshake** against an26   *expected long-term static key* per host (`probeOne` →27   `openChirpSyncSession`, `chirp-sync-transport.ts:272-292, 51-156`). It only28   returns a host that completes Noise XX with `expectedRemotePubkeyHex`.292. During **code pairing we have no long-term pubkey yet** — we only obtain30   the helper's *ephemeral* key, and only AFTER the plaintext `PEACH_HELLO` /31   `PEACH_HELLO_ACK` exchange (ground truth §2-3). The helper on port 4914232   speaks the HELLO protocol, **not** Noise XX. `openChirpSyncSession` would33   send a Noise XX msg1 the pairing server never expects and fail every host.343. `probeSubnetForPeer` is also rate-limited per-pubkey35   (`subnetProbeOnCooldown`, `chirp-sync-transport.ts:210-217`); there is no36   pubkey to key the cooldown on during pairing.3738Therefore Change 5 supplies a **lighter, handshake-free port probe**: "is39there a WebSocket server answering on this port?" — nothing more. The HELLO40handshake then happens against the single found host in Change 3.4142## Design4344A plain `/24` scan that returns the first IP whose `ws://<ip>:<port>/`45WebSocket reaches `onopen` within a tight timeout, then immediately closes it.46No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of47`probeSubnetForPeer` (batches of 16, ~500ms timeout) so worst-case scan time48stays ~comparable.4950> NOTE: a bare port probe is intentionally undiscriminating — it returns the51> FIRST host with an open WebSocket on 49142, which during an active52> `peach-sync pair` session is the helper. The security boundary is NOT the53> probe: it is (a) the 6-digit code in `PEACH_HELLO` (a non-matching host54> closes the socket) and (b) the user-confirmed SAS. The probe only needs to55> find a candidate; it does not need to authenticate it.5657---5859## Apply-ready module — `src/background/desktop-pairing-probe.ts`6061```ts62// Handshake-free /24 port probe for desktop pairing (Change 5).63//64// Unlike `probeSubnetForPeer` (chirp-sync-transport.ts), this does NOT run65// Noise — during code pairing we have no long-term pubkey, and the pairing66// server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise67// XX. This probe answers only "is a WebSocket server reachable here?"; the68// 6-digit code + user-confirmed SAS are the actual trust boundary.6970const DEFAULT_PROBE_TIMEOUT_MS = 500;71const DEFAULT_PROBE_BATCH = 16;7273export interface PortProbeOptions {74  batch?: number;75  timeoutMs?: number;76  /** Injection seam for tests — defaults to the real WebSocket open check. */77  probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;78}7980/**81 * Scan `subnet` (e.g. "192.168.1") for the first host accepting a WebSocket82 * connection on `port`. Returns the IP string, or null if none answered.83 */84export async function probeSubnetForPort(85  subnet: string,86  port: number,87  options: PortProbeOptions = {},88): Promise<string | null> {89  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;90  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;91  const probe = options.probeOne ?? wsPortOpen;9293  for (let start = 1; start <= 254; start += batchSize) {94    const end = Math.min(start + batchSize - 1, 254);95    const ips: string[] = [];96    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);9798    const results = await Promise.all(99      ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),100    );101    const hit = results.find((r): r is string => r !== null);102    if (hit) return hit;103  }104  return null;105}106107/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */108function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {109  return new Promise((resolve) => {110    let ws: WebSocket;111    let settled = false;112    const done = (ok: boolean) => {113      if (settled) return;114      settled = true;115      try { ws.close(); } catch { /* ignore */ }116      resolve(ok);117    };118    try {119      ws = new WebSocket(`ws://${ip}:${port}/`);120    } catch {121      resolve(false);122      return;123    }124    const t = setTimeout(() => done(false), timeoutMs);125    ws.onopen = () => { clearTimeout(t); done(true); };126    ws.onerror = () => { clearTimeout(t); done(false); };127    ws.onclose = () => { clearTimeout(t); done(false); };128  });129}130```131132## Why a new module rather than extracting from `chirp-sync-transport.ts`133134Extracting a shared scan core from `probeSubnetForPeer` would force the Noise135flow and the pairing flow to share a helper whose only common part is the136nested batch loop (~10 lines). The per-host predicate differs fundamentally137(full Noise XX vs bare WS open), as does the cooldown handling. A standalone138module keeps the pairing path free of the sync transport's pubkey/cooldown139machinery and avoids touching the load-bearing, already-tested sync probe.140**No edit to `chirp-sync-transport.ts` is required.**141142## Call sites143144- **Only** new caller: Change 3's `productionDesktopPairingDeps().findHost`145  (`03-desktop-pairing-handler.md`), which calls146  `probeSubnetForPort(subnet, 49142)` with `subnet` from `getLocalSubnet()`147  (`src/shared/local-network.ts`, already used at `chirp-sync-runner.ts:69`).148- No existing call site changes.149150## Integration TODO1511521. Consider a hard ceiling on total scan time (the audio-chirp flow uses a153   90s chirp window; a pairing probe should resolve much faster — a full /24154   at 16-wide batches of 500ms is ~8s worst case, acceptable).1552. The probe finds the FIRST open WS on the port. If multiple hosts answer156   (unlikely on a home LAN during an active pairing), only the first is tried;157   the wrong-code silent-close in Change 3 then surfaces as `invalid_code`.158   A future enhancement could try each candidate in turn until one ACKs —159   out of scope for this draft.