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
3 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 −0
src/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.

view file

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.

view file

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+});

view file