# 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**: ```ts // 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 ``` 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://:/` 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` ```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; } /** * 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 { 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 { 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 - **Only** new caller: Change 3's `productionDesktopPairingDeps().findHost` (`03-desktop-pairing-handler.md`), which calls `probeSubnetForPort(subnet, 49142)` with `subnet` from `getLocalSubnet()` (`src/shared/local-network.ts`, already used at `chirp-sync-runner.ts:69`). - No existing call site changes. ## 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.