6158d49b58eb
add Change 3/5 drafts — pairWithDesktopHelper (PEACH_HELLO → noiseNKInitiator against ACK ephemeral → reuse derivePairingSAS → PAIR_IDENTITY w/ vault_id → upsertPairedDevice), socket-close=invalid_code; new handshake-free probeSubnetForPort (existing probeSubnetForPeer does Noise XX, not reusable); KAT + invalid-code + persist tests
- time
- 2026-06-17 01:40 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- desktop helper pairing drafts
- parent
- 4b3171f697b0
↓ peach-browser-6158d49b.tar.gzatlas at this save
files changed3 files
src/drafts/DESKTOP-PAIRING/03-desktop-pairing-handler.md+435 −0src/drafts/DESKTOP-PAIRING/05-subnet-probe.md+159 −0src/drafts/DESKTOP-PAIRING/tests/03-desktop-pairing.test.ts+268 −0src/drafts/DESKTOP-PAIRING/03-desktop-pairing-handler.md+435 −0
@@ -0,0 +1,435 @@1+# Change 3 — Desktop helper pairing handler2+3+> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module:4+> `/Users/june/projects/peach-browser/src/background/desktop-pairing.ts`.5+> Reuses Change 1 (`vault_id` in `PAIR_IDENTITY`) and Change 5 (plain6+> port probe). Nothing here is applied — it is an apply-ready draft.7+8+## What this change adds9+10+A net-new browser-side **code pairing** client. The existing11+`chirp-handshake.ts` only implements the *audio chirp* NK flow where the12+**extension is the NK responder**. Desktop pairing is the OPPOSITE: the13+**browser is the NK initiator** against the helper's ephemeral key learned14+from `PEACH_HELLO_ACK` (`_GROUND-TRUTH.md` §3).15+16+## Verified dependencies (real exported symbols)17+18+| Need | Symbol | Location |19+| --- | --- | --- |20+| NK initiator primitive | `noiseNKInitiator(responderStaticPublicKey, send, receive)` | `src/shared/noise-js.ts:71` |21+| SAS derivation (REUSE) | `derivePairingSAS(handshakeHash: Uint8Array): string` | `src/shared/chirp-handshake.ts:171` |22+| Persist paired device | `upsertPairedDevice(device: PairedDevice)` | `src/background/paired-devices.ts:56` |23+| Cache peer IP (optional) | `setLastKnownIp(pubkeyHex, ip)` | `src/background/paired-devices.ts:120` |24+| Build local identity | `buildLocalIdentity(masterKey): Promise<LocalChirpIdentity>` | `src/background/chirp-identity.ts:79` |25+| Vault id for `vault_id` | `computeVaultId(masterKey): Promise<string>` | `src/shared/sync-identity.ts:75` |26+| Root key (for masterKey) | `getRootKeyHex()` / `isUnlocked()` | `src/background/vault.ts` (used at `messages.ts:1324`) |27+| Plain port probe (Change 5) | `probeSubnetForPort(subnet, port, opts?)` | `src/background/desktop-pairing-probe.ts` (NEW — see `05-subnet-probe.md`) |28+| PairedDevice / DeviceType | `PairedDevice`, `DeviceType` | `src/shared/sync-types.ts:86,11` (`"desktop"` already a member) |29+30+> **Why not reuse `probeSubnetForPeer`?** It is already port-parameterized31+> (`chirp-sync-transport.ts:238`), BUT it runs a full **Noise XX** handshake32+> against an *expected long-term pubkey* per host. In code pairing we have no33+> long-term pubkey yet (we only get the helper's *ephemeral* key, after the34+> plaintext HELLO). So Change 5 supplies a lighter "is there a WebSocket35+> server on this port" probe. See `05-subnet-probe.md`.36+37+## The PEACH_HELLO / PEACH_HELLO_ACK frames (PINNED, see §6 of ground truth)38+39+```ts40+interface PeachHelloFrame { type: "PEACH_HELLO"; code: string; }41+interface PeachHelloAck { type: "PEACH_HELLO_ACK"; ephemeralPublicKeyHex: string; }42+```43+44+There is **no** error/OK frame. After sending `PEACH_HELLO`, the browser45+expects EITHER a `PEACH_HELLO_ACK` binary frame OR the socket to close.46+Socket close before an ACK ⇒ wrong/expired code ⇒ `onError("invalid_code")`.47+48+## Transport notes (from ground truth §1)49+50+- Port **49142** (pairing). The persistent sync port arrives inside the51+ encrypted `PAIR_IDENTITY` as `ws_port` (helper always announces `8765`).52+- WebSocket over TCP, **binary frames only**, one Noise (or one JSON) frame53+ per WS binary message. The browser `WebSocket` client masks frames54+ automatically — no manual masking needed on this side.55+- Pre-handshake HELLO/ACK frames are PLAINTEXT JSON, sent as binary WS56+ messages (`new TextEncoder().encode(JSON.stringify(...))`).57+58+---59+60+## Apply-ready module — `src/background/desktop-pairing.ts`61+62+```ts63+// Desktop helper code-pairing client (Change 3).64+//65+// Pairs the extension with the `peach-sync` desktop helper:66+// 1. probe the /24 for a host accepting WebSocket on the pairing port67+// 2. open ws://<host>:4914268+// 3. send PEACH_HELLO { code } in PLAINTEXT69+// 4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close70+// (socket close before ACK == wrong/expired code — peach-sync sends71+// NO error frame; it silently closes and rotates the code)72+// 5. run Noise NK as INITIATOR against the helper's ephemeral key73+// 6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)74+// 7. derive the SAS, surface it for user confirmation75+// 8. on confirm -> persist the PairedDevice (deviceType "desktop"); on76+// reject -> abort without persisting77+//78+// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio79+// chirp flow (chirp-handshake.ts), where the extension is the responder.80+// The helper's `ephemeralPublicKeyHex` is the NK responder static `s`.81+82+import { noiseNKInitiator } from "../shared/noise-js";83+import { derivePairingSAS } from "../shared/chirp-handshake";84+import type { LocalIdentity } from "../shared/chirp-handshake";85+import type { DeviceType, PairedDevice } from "../shared/sync-types";86+87+/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */88+export const DESKTOP_PAIRING_PORT = 49142;89+/** Default sync port the helper announces; used when ws_port is absent. */90+const DEFAULT_SYNC_PORT = 8765;91+92+export type DesktopPairingError =93+ | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)94+ | "no_host_found" // no WebSocket server answered on the pairing port95+ | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed96+ | "sas_mismatch" // user rejected the SAS97+ | "vault_locked"; // no master key available98+99+/**100+ * Minimal binary WebSocket transport seam. Production wires the real101+ * `WebSocket`; tests inject a fake. One binary message per frame.102+ */103+export interface DesktopPairingSocket {104+ send(bytes: Uint8Array): void;105+ /** Resolves with the next inbound binary frame, or rejects on close. */106+ receive(): Promise<Uint8Array>;107+ close(): void;108+ /** Resolves true if the socket closed before the next frame arrived. */109+ closedBeforeFrame(): boolean;110+}111+112+export interface DesktopPairingDeps {113+ /** Change 5 — find a host answering WebSocket on the pairing port. */114+ findHost(port: number): Promise<string | null>;115+ /** Open a binary WebSocket to ws://<ip>:<port>/ . */116+ openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;117+ /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */118+ getLocalIdentity(): Promise<LocalIdentity>;119+ /** Persist on user confirm. */120+ persistDevice(device: PairedDevice): Promise<void>;121+ /** Optional fast-path cache for the next sync round. */122+ cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;123+}124+125+export interface PairWithDesktopCallbacks {126+ /** Show the 6-digit SAS; the popup must compare against the helper's. */127+ onSasReady(sas: string): void;128+ /** Pairing succeeded and the device was persisted. */129+ onComplete(device: PairedDevice): void;130+ /** Pairing failed at some stage. */131+ onError(error: DesktopPairingError): void;132+}133+134+interface PairingFrame {135+ type: "PAIR_IDENTITY";136+ device_id: string;137+ device_name: string;138+ device_type: DeviceType;139+ long_term_public_key_hex: string;140+ signing_public_key_hex?: string;141+ ws_port?: number;142+ vault_id?: string; // Change 1 — additive, optional143+}144+145+function encodeJson(obj: unknown): Uint8Array {146+ return new TextEncoder().encode(JSON.stringify(obj));147+}148+149+function decodeJson<T>(bytes: Uint8Array): T {150+ return JSON.parse(new TextDecoder().decode(bytes)) as T;151+}152+153+function hexToBytes(hex: string): Uint8Array {154+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");155+ const out = new Uint8Array(32);156+ for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);157+ return out;158+}159+160+function decodePairIdentity(bytes: Uint8Array): PairingFrame {161+ const p = decodeJson<Record<string, unknown>>(bytes);162+ if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");163+ if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");164+ if (typeof p.device_name !== "string") throw new Error("device_name missing");165+ if (typeof p.device_type !== "string") throw new Error("device_type missing");166+ if (typeof p.long_term_public_key_hex !== "string" ||167+ !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {168+ throw new Error("long_term_public_key_hex is not 32-byte hex");169+ }170+ return p as unknown as PairingFrame;171+}172+173+/**174+ * Drive a complete desktop-helper code pairing.175+ *176+ * `confirm` is a function the caller resolves AFTER the user has compared177+ * the SAS shown here against the SAS the helper displays. Resolving false178+ * (or rejecting) aborts before any device is persisted — this is the179+ * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).180+ */181+export async function pairWithDesktopHelper(182+ code: string,183+ cb: PairWithDesktopCallbacks,184+ deps: DesktopPairingDeps,185+ confirm: (sas: string) => Promise<boolean>,186+): Promise<void> {187+ // The helper compares the code byte-for-byte against a [6]u8. Normalise to188+ // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2).189+ const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);190+191+ let identity: LocalIdentity;192+ try {193+ identity = await deps.getLocalIdentity();194+ } catch {195+ cb.onError("vault_locked");196+ return;197+ }198+199+ // 1) Find a host on the pairing port.200+ const host = await deps.findHost(DESKTOP_PAIRING_PORT);201+ if (!host) {202+ cb.onError("no_host_found");203+ return;204+ }205+206+ // 2) Open the WebSocket.207+ let sock: DesktopPairingSocket;208+ try {209+ sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);210+ } catch {211+ cb.onError("no_host_found");212+ return;213+ }214+215+ try {216+ // 3) Send PEACH_HELLO (PLAINTEXT binary frame).217+ sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));218+219+ // 4) Await PEACH_HELLO_ACK OR socket close.220+ // peach-sync sends NO error frame; a wrong/expired code closes the221+ // socket. So "receive() rejected / closed before a frame" == invalid_code.222+ let ackBytes: Uint8Array;223+ try {224+ ackBytes = await sock.receive();225+ } catch {226+ cb.onError("invalid_code");227+ return;228+ }229+ if (sock.closedBeforeFrame() || ackBytes.length === 0) {230+ cb.onError("invalid_code");231+ return;232+ }233+234+ let ephemeralPublicKeyHex: string;235+ try {236+ const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);237+ if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {238+ // A non-ACK frame on a matching code should not happen; treat the239+ // missing ACK exactly like a wrong code.240+ cb.onError("invalid_code");241+ return;242+ }243+ ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;244+ } catch {245+ cb.onError("invalid_code");246+ return;247+ }248+249+ // 5) Noise NK as INITIATOR against the helper's ephemeral static key.250+ const responderStatic = hexToBytes(ephemeralPublicKeyHex);251+ const session = await noiseNKInitiator(252+ responderStatic,253+ async (msg) => sock.send(msg),254+ () => sock.receive(),255+ );256+257+ // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.258+ const ourFrame: PairingFrame = {259+ type: "PAIR_IDENTITY",260+ device_id: identity.deviceId,261+ device_name: identity.deviceName,262+ device_type: identity.deviceType,263+ long_term_public_key_hex: identity.longTermPublicKeyHex,264+ signing_public_key_hex: identity.signingPublicKeyHex,265+ ws_port: identity.wsPort,266+ vault_id: identity.vaultId, // Change 1267+ };268+ sock.send(session.encrypt(encodeJson(ourFrame)));269+270+ const theirCipher = await sock.receive();271+ const theirFrame = decodePairIdentity(session.decrypt(theirCipher));272+273+ // 7) Derive the SAS (REUSED — same value the helper computes from the274+ // shared handshake hash) and surface it for user confirmation.275+ const sas = derivePairingSAS(session.handshakeHash);276+ cb.onSasReady(sas);277+278+ const confirmed = await confirm(sas);279+ if (!confirmed) {280+ cb.onError("sas_mismatch");281+ return;282+ }283+284+ // 8) Persist. deviceType "desktop"; ws_port from the helper frame,285+ // defaulting to 8765 (the helper always announces 8765, but guard it).286+ const device: PairedDevice = {287+ deviceId: theirFrame.device_id,288+ deviceName: theirFrame.device_name,289+ deviceType: (theirFrame.device_type as DeviceType) || "desktop",290+ longTermPublicKeyHex: theirFrame.long_term_public_key_hex,291+ signingPublicKeyHex: theirFrame.signing_public_key_hex,292+ trustSource: "direct",293+ wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,294+ pairedAt: Date.now(),295+ };296+ await deps.persistDevice(device);297+ if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {298+ await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);299+ }300+ cb.onComplete(device);301+ } catch {302+ cb.onError("handshake_failed");303+ } finally {304+ sock.close();305+ }306+}307+```308+309+### Production deps factory (sketch — lives alongside or in the wiring layer)310+311+```ts312+import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";313+import { buildLocalIdentity } from "./chirp-identity";314+import { computeVaultId } from "../shared/sync-identity";315+import { getRootKeyHex, isUnlocked } from "./vault";316+import { probeSubnetForPort } from "./desktop-pairing-probe"; // Change 5317+import { getLocalSubnet } from "../shared/local-network";318+319+export function productionDesktopPairingDeps(): DesktopPairingDeps {320+ return {321+ async findHost(port) {322+ const subnet = await getLocalSubnet();323+ if (!subnet) return null;324+ return probeSubnetForPort(subnet, port);325+ },326+ async openSocket(ip, port) {327+ return openBinaryWebSocket(ip, port); // small wrapper around WebSocket328+ },329+ async getLocalIdentity() {330+ if (!isUnlocked()) throw new Error("vault_locked");331+ const masterKeyHex = getRootKeyHex();332+ if (!masterKeyHex) throw new Error("vault_locked");333+ const masterKey = hexToBytesLocal(masterKeyHex);334+ try {335+ const base = await buildLocalIdentity(masterKey); // chirp-identity.ts:79336+ const vaultId = await computeVaultId(masterKey); // sync-identity.ts:75337+ return { ...base, vaultId }; // LocalIdentity + Change-1 field338+ } finally {339+ masterKey.fill(0);340+ }341+ },342+ persistDevice: (d) => upsertPairedDevice(d),343+ cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),344+ };345+}346+```347+348+---349+350+## Threading note — where new state comes from (mirrors Change 1)351+352+`LocalIdentity.vaultId` (added in Change 1) is **not** populated by353+`buildLocalIdentity` (`chirp-identity.ts:79` returns no `vaultId`). The354+production deps factory above threads it in:355+356+- `masterKey` is obtained the same way `CHIRP_GET_LOCAL_IDENTITY` does it357+ (`messages.ts:1324-1326`): `getRootKeyHex()` → `unhexBytes` → `masterKey.fill(0)`358+ in `finally`. **Use the stable ROOT key, not the password-derived key**359+ (SEV-2-B), so pairing survives a master-password change.360+- `vaultId = await computeVaultId(masterKey)` (`sync-identity.ts:75`).361+- Both are zeroed after use.362+363+**INTEGRATOR TODO:** `buildLocalIdentity` returns `LocalChirpIdentity`, whose364+shape matches `LocalIdentity` minus `vaultId`/`wsPort`. Confirm the two types365+are assignable, or add `vaultId?` to `LocalChirpIdentity` directly so the366+spread is type-clean. The extension has no `wsPort` (it is never a sync host),367+so leaving `wsPort` undefined is correct.368+369+---370+371+## Wiring — popup ⇄ background372+373+The handshake needs `getUserMedia`-free crypto only, so it CAN run entirely374+in the background service worker (unlike the audio chirp flow, which must run375+in the popup). Recommended split: **the background runs the whole pairing**,376+the popup drives the UI and the SAS confirmation round-trip.377+378+### New message types (propose)379+380+| Direction | `type` | Payload | Response |381+| --- | --- | --- | --- |382+| popup → bg | `PAIR_WITH_DESKTOP` | `{ code: string }` | `{ ok: true }` (started) or `{ ok:false, error }` |383+| bg → popup | `DESKTOP_PAIR_SAS` | `{ sas: string }` | — (push via `chrome.runtime.sendMessage`) |384+| popup → bg | `DESKTOP_PAIR_CONFIRM` | `{ confirmed: boolean }` | `{ ok: true }` |385+| bg → popup | `DESKTOP_PAIR_RESULT` | `{ status:"complete", device } \| { status:"error", error }` | — (push) |386+387+### SAS / confirm round-trip388+389+Because `pairWithDesktopHelper` takes a `confirm(sas) => Promise<boolean>`390+callback that must block on the user, the background handler bridges it to a391+message round-trip:392+393+1. `PAIR_WITH_DESKTOP` handler calls `pairWithDesktopHelper(code, cb, deps, confirm)`.394+2. `cb.onSasReady(sas)` and the `confirm` callback both fire `DESKTOP_PAIR_SAS`395+ to the popup and then **await a deferred Promise** that a later396+ `DESKTOP_PAIR_CONFIRM` message resolves.397+3. The popup shows the SAS, the user compares it to the helper's display,398+ taps confirm/reject, and the popup sends `DESKTOP_PAIR_CONFIRM { confirmed }`.399+4. The handler resolves the deferred Promise with `confirmed`, completing the400+ `confirm()` call inside `pairWithDesktopHelper`.401+5. `cb.onComplete`/`cb.onError` push `DESKTOP_PAIR_RESULT` to the popup.402+403+Keep one in-flight pairing per worker (store the pending `confirm` resolver404+in a module-level variable, cleared on completion/error). This mirrors how405+`chirp-pair.ts`'s `confirmSAS(sas): Promise<boolean>` dep gates persistence406+(`chirp-pair.ts` `confirmSAS` doc, lines ~62-71), just relocated to the407+background since no microphone is involved.408+409+**INTEGRATOR TODO (handler placement):** add the three `case` blocks to the410+`switch` in `messages.ts` (alongside `CHIRP_*` at lines 1318-1384). Gate on411+`vault.isUnlocked()` and `requirePro("sync")` exactly like412+`CHIRP_GET_LOCAL_IDENTITY` (`messages.ts:1319-1321`). Validate the persisted413+device with the same shape check `CHIRP_COMPLETE_PAIRING` uses414+(`messages.ts:1344-1360`) — except here the background builds the415+`PairedDevice` itself, so the validation can be skipped (the device comes from416+our own decode path, not from the popup).417+418+---419+420+## Open integration TODOs421+422+1. **`openBinaryWebSocket` helper** — a small adapter producing a423+ `DesktopPairingSocket` from `WebSocket` (set `binaryType="arraybuffer"`,424+ queue inbound frames, expose `closedBeforeFrame()`). Pattern already exists425+ in `chirp-sync-transport.ts:58-121` (inbox/readers + onclose); extract a426+ trimmed version. Critically, `closedBeforeFrame()` must distinguish "socket427+ closed with no ACK" from "ACK received" — this is the `invalid_code` signal.428+2. **`LocalChirpIdentity` vs `LocalIdentity`** type reconciliation (see429+ threading note).430+3. **Pro gating + lock checks** in the new `messages.ts` cases.431+4. **Single-flight guard** for the pending-confirm resolver in the background.432+5. **Timeout** — wrap `findHost`/`receive` in a ceiling (e.g. 10s probe, 5s433+ per receive) so a stalled helper surfaces `no_host_found`/`handshake_failed`434+ instead of hanging the popup. `connectWebSocket` in435+ `chirp-sync-transport.ts:174` already shows the timeout idiom.src/drafts/DESKTOP-PAIRING/05-subnet-probe.md+159 −0
@@ -0,0 +1,159 @@1+# Change 5 — Subnet probe for the pairing port2+3+> **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`).6+7+## Verdict: the EXISTING probe cannot be reused as-is8+9+There IS an existing /24 probe, and its **port is already parameterized**:10+11+```ts12+// src/shared/chirp-sync-transport.ts:23813+export 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+```22+23+So "just call it with 49142" is tempting — but it is **wrong for pairing**:24+25+1. `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`.29+2. 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.34+3. `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.37+38+Therefore Change 5 supplies a **lighter, handshake-free port probe**: "is39+there a WebSocket server answering on this port?" — nothing more. The HELLO40+handshake then happens against the single found host in Change 3.41+42+## Design43+44+A plain `/24` scan that returns the first IP whose `ws://<ip>:<port>/`45+WebSocket reaches `onopen` within a tight timeout, then immediately closes it.46+No Noise, no pubkey, no cooldown ledger. Mirrors the batching/timeout shape of47+`probeSubnetForPeer` (batches of 16, ~500ms timeout) so worst-case scan time48+stays ~comparable.49+50+> 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.56+57+---58+59+## Apply-ready module — `src/background/desktop-pairing-probe.ts`60+61+```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.69+70+const DEFAULT_PROBE_TIMEOUT_MS = 500;71+const DEFAULT_PROBE_BATCH = 16;72+73+export 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+}79+80+/**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+ */84+export 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;92+93+ 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}`);97+98+ 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+}106+107+/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */108+function 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+```131+132+## Why a new module rather than extracting from `chirp-sync-transport.ts`133+134+Extracting a shared scan core from `probeSubnetForPeer` would force the Noise135+flow and the pairing flow to share a helper whose only common part is the136+nested batch loop (~10 lines). The per-host predicate differs fundamentally137+(full Noise XX vs bare WS open), as does the cooldown handling. A standalone138+module keeps the pairing path free of the sync transport's pubkey/cooldown139+machinery and avoids touching the load-bearing, already-tested sync probe.140+**No edit to `chirp-sync-transport.ts` is required.**141+142+## Call sites143+144+- **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.149+150+## Integration TODO151+152+1. 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).155+2. 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.src/drafts/DESKTOP-PAIRING/tests/03-desktop-pairing.test.ts+268 −0
@@ -0,0 +1,268 @@1+// Tests for Change 3 — desktop helper code pairing.2+//3+// DRAFT / apply-ready. These specs import from the proposed module4+// `src/background/desktop-pairing.ts` (Change 3). Path assumes this file is5+// relocated to `src/background/desktop-pairing.test.ts` on apply; from the6+// draft location the imports below would resolve as written if vitest's7+// rootDir maps `src/` — adjust the two `../../` prefixes when you move it.8+//9+// They use the dependency-injection seam (DesktopPairingDeps / a fake10+// DesktopPairingSocket) so no real WebSocket or Noise crypto runs — except11+// the SAS KAT, which calls the REAL `derivePairingSAS` from the shipped12+// chirp-handshake module to pin cross-impl agreement with peach-sync.13+14+import { beforeEach, describe, expect, it, vi } from "vitest";15+16+// Change 3 module (proposed):17+import {18+ pairWithDesktopHelper,19+ DESKTOP_PAIRING_PORT,20+ type DesktopPairingDeps,21+ type DesktopPairingSocket,22+ type PairWithDesktopCallbacks,23+} from "../../background/desktop-pairing";24+25+// REAL SAS implementation — reused, not re-derived (ground truth §5).26+import { derivePairingSAS } from "../../shared/chirp-handshake";27+import type { LocalIdentity } from "../../shared/chirp-handshake";28+import type { PairedDevice } from "../../shared/sync-types";29+30+// ── helpers ────────────────────────────────────────────────────────────────31+32+const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));33+const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));34+35+const HELPER_EPHEMERAL_HEX = "a".repeat(64);36+const HELPER_LONGTERM_HEX = "b".repeat(64);37+38+const OUR_IDENTITY: LocalIdentity = {39+ deviceId: "ext-local-001",40+ deviceName: "Firefox on MacBook",41+ deviceType: "extension",42+ longTermPublicKeyHex: "c".repeat(64),43+ signingPublicKeyHex: "d".repeat(64),44+ vaultId: "e".repeat(64),45+};46+47+/**48+ * Scripted fake socket. Captures everything sent, and replays a queued list49+ * of inbound frames. A frame of `null` means "the socket closes here with no50+ * data" — the peach-sync wrong-code behaviour (no error frame).51+ */52+function makeFakeSocket(inbound: Array<Uint8Array | null>) {53+ const sent: Uint8Array[] = [];54+ let closed = false;55+ let lastWasClose = false;56+ const queue = [...inbound];57+ const sock: DesktopPairingSocket = {58+ send(bytes) { sent.push(bytes); },59+ async receive() {60+ const next = queue.shift();61+ if (next === undefined || next === null) {62+ closed = true;63+ lastWasClose = true;64+ throw new Error("socket closed");65+ }66+ lastWasClose = false;67+ return next;68+ },69+ close() { closed = true; },70+ closedBeforeFrame() { return lastWasClose; },71+ };72+ return { sock, sent, isClosed: () => closed };73+}74+75+/**76+ * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we77+ * can drive the PAIR_IDENTITY exchange without real crypto. We stub78+ * `noiseNKInitiator` via vi.mock below; this is the shape it returns.79+ */80+const FAKE_HANDSHAKE_HASH = Uint8Array.from([81+ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),82+]);83+84+// Mock the NK initiator: skip the two handshake messages, hand back a session85+// whose encrypt/decrypt are identity transforms and whose handshakeHash is the86+// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).87+vi.mock("../../shared/noise-js", async (orig) => {88+ const actual = await orig<typeof import("../../shared/noise-js")>();89+ return {90+ ...actual,91+ noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({92+ encrypt: (pt: Uint8Array) => pt,93+ decrypt: (ct: Uint8Array) => ct,94+ handshakeHash: FAKE_HANDSHAKE_HASH,95+ })),96+ };97+});98+99+function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {100+ deps: DesktopPairingDeps;101+ persisted: PairedDevice[];102+ sockets: ReturnType<typeof makeFakeSocket>[];103+} {104+ const persisted: PairedDevice[] = [];105+ const sockets: ReturnType<typeof makeFakeSocket>[] = [];106+ const deps: DesktopPairingDeps = {107+ findHost: vi.fn(async () => "192.168.1.50"),108+ openSocket: vi.fn(async () => {109+ // default: ACK then a PAIR_IDENTITY response110+ const s = makeFakeSocket([111+ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),112+ enc({113+ type: "PAIR_IDENTITY",114+ device_id: "desktop-001",115+ device_name: "June's iMac",116+ device_type: "desktop",117+ long_term_public_key_hex: HELPER_LONGTERM_HEX,118+ ws_port: 8765,119+ }),120+ ]);121+ sockets.push(s);122+ return s.sock;123+ }),124+ getLocalIdentity: vi.fn(async () => OUR_IDENTITY),125+ persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),126+ ...overrides,127+ };128+ return { deps, persisted, sockets };129+}130+131+function makeCallbacks(): {132+ cb: PairWithDesktopCallbacks;133+ sas: { value?: string };134+ errors: string[];135+ completed: PairedDevice[];136+} {137+ const sas: { value?: string } = {};138+ const errors: string[] = [];139+ const completed: PairedDevice[] = [];140+ const cb: PairWithDesktopCallbacks = {141+ onSasReady: (s) => { sas.value = s; },142+ onError: (e) => { errors.push(e); },143+ onComplete: (d) => { completed.push(d); },144+ };145+ return { cb, sas, errors, completed };146+}147+148+beforeEach(() => {149+ vi.clearAllMocks();150+});151+152+// ── specs ────────────────────────────────────────────────────────────────153+154+describe("pairWithDesktopHelper", () => {155+ it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {156+ const { deps, sockets } = makeDeps();157+ const { cb } = makeCallbacks();158+159+ await pairWithDesktopHelper("123", cb, deps, async () => true);160+161+ expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);162+ const firstFrame = dec(sockets[0].sent[0]);163+ expect(firstFrame.type).toBe("PEACH_HELLO");164+ // "123" must be normalised to exactly 6 ASCII digits, zero-padded.165+ expect(firstFrame.code).toBe("000123");166+ expect(firstFrame.code).toMatch(/^\d{6}$/);167+ });168+169+ it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {170+ // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY171+ // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the172+ // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces173+ // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)174+ const closeImmediately = makeFakeSocket([null]); // close, no data175+ const { deps } = makeDeps({176+ openSocket: vi.fn(async () => closeImmediately.sock),177+ });178+ const { cb, errors, completed } = makeCallbacks();179+180+ await pairWithDesktopHelper("654321", cb, deps, async () => true);181+182+ expect(errors).toEqual(["invalid_code"]);183+ expect(completed).toHaveLength(0);184+ expect(deps.persistDevice).not.toHaveBeenCalled();185+ });186+187+ it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {188+ // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake189+ // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;190+ // 263895245 mod 1_000_000 = 895245.191+ const hash = Uint8Array.from([192+ 0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),193+ ]);194+ expect(derivePairingSAS(hash)).toBe("895245");195+ });196+197+ it("surfaces that same SAS through the pairing flow", async () => {198+ // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.199+ const { deps } = makeDeps();200+ const { cb, sas } = makeCallbacks();201+202+ await pairWithDesktopHelper("000123", cb, deps, async () => true);203+204+ expect(sas.value).toBe("895245");205+ });206+207+ it("persists the device with deviceType 'desktop' on SAS confirm", async () => {208+ const { deps, persisted } = makeDeps();209+ const { cb, completed, errors } = makeCallbacks();210+211+ await pairWithDesktopHelper("000123", cb, deps, async () => true);212+213+ expect(errors).toHaveLength(0);214+ expect(persisted).toHaveLength(1);215+ expect(persisted[0].deviceType).toBe("desktop");216+ expect(persisted[0].deviceId).toBe("desktop-001");217+ expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);218+ expect(persisted[0].trustSource).toBe("direct");219+ expect(persisted[0].wsPort).toBe(8765);220+ expect(completed).toHaveLength(1);221+ });222+223+ it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {224+ const { deps, persisted } = makeDeps();225+ const { cb, errors } = makeCallbacks();226+227+ await pairWithDesktopHelper("000123", cb, deps, async () => false);228+229+ expect(errors).toEqual(["sas_mismatch"]);230+ expect(persisted).toHaveLength(0);231+ expect(deps.persistDevice).not.toHaveBeenCalled();232+ });233+234+ it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {235+ const { deps, sockets } = makeDeps();236+ const { cb } = makeCallbacks();237+238+ await pairWithDesktopHelper("000123", cb, deps, async () => true);239+240+ // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY241+ // (encrypt is identity in the mock, so it decodes directly).242+ const ourPairIdentity = dec(sockets[0].sent[1]);243+ expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");244+ expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);245+ });246+247+ it("reports no_host_found when the probe returns null", async () => {248+ const { deps } = makeDeps({ findHost: vi.fn(async () => null) });249+ const { cb, errors } = makeCallbacks();250+251+ await pairWithDesktopHelper("000123", cb, deps, async () => true);252+253+ expect(errors).toEqual(["no_host_found"]);254+ expect(deps.openSocket).not.toHaveBeenCalled();255+ });256+257+ it("reports vault_locked when local identity is unavailable", async () => {258+ const { deps } = makeDeps({259+ getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),260+ });261+ const { cb, errors } = makeCallbacks();262+263+ await pairWithDesktopHelper("000123", cb, deps, async () => true);264+265+ expect(errors).toEqual(["vault_locked"]);266+ expect(deps.findHost).not.toHaveBeenCalled();267+ });268+});