# Change 3 — Desktop helper pairing handler > **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Proposed new module: > `/Users/june/projects/peach-browser/src/background/desktop-pairing.ts`. > Reuses Change 1 (`vault_id` in `PAIR_IDENTITY`) and Change 5 (plain > port probe). Nothing here is applied — it is an apply-ready draft. ## What this change adds A net-new browser-side **code pairing** client. The existing `chirp-handshake.ts` only implements the *audio chirp* NK flow where the **extension is the NK responder**. Desktop pairing is the OPPOSITE: the **browser is the NK initiator** against the helper's ephemeral key learned from `PEACH_HELLO_ACK` (`_GROUND-TRUTH.md` §3). ## Verified dependencies (real exported symbols) | Need | Symbol | Location | | --- | --- | --- | | NK initiator primitive | `noiseNKInitiator(responderStaticPublicKey, send, receive)` | `src/shared/noise-js.ts:71` | | SAS derivation (REUSE) | `derivePairingSAS(handshakeHash: Uint8Array): string` | `src/shared/chirp-handshake.ts:171` | | Persist paired device | `upsertPairedDevice(device: PairedDevice)` | `src/background/paired-devices.ts:56` | | Cache peer IP (optional) | `setLastKnownIp(pubkeyHex, ip)` | `src/background/paired-devices.ts:120` | | Build local identity | `buildLocalIdentity(masterKey): Promise` | `src/background/chirp-identity.ts:79` | | Vault id for `vault_id` | `computeVaultId(masterKey): Promise` | `src/shared/sync-identity.ts:75` | | Root key (for masterKey) | `getRootKeyHex()` / `isUnlocked()` | `src/background/vault.ts` (used at `messages.ts:1324`) | | Plain port probe (Change 5) | `probeSubnetForPort(subnet, port, opts?)` | `src/background/desktop-pairing-probe.ts` (NEW — see `05-subnet-probe.md`) | | PairedDevice / DeviceType | `PairedDevice`, `DeviceType` | `src/shared/sync-types.ts:86,11` (`"desktop"` already a member) | > **Why not reuse `probeSubnetForPeer`?** It is already port-parameterized > (`chirp-sync-transport.ts:238`), BUT it runs a full **Noise XX** handshake > against an *expected long-term pubkey* per host. In code pairing we have no > long-term pubkey yet (we only get the helper's *ephemeral* key, after the > plaintext HELLO). So Change 5 supplies a lighter "is there a WebSocket > server on this port" probe. See `05-subnet-probe.md`. ## The PEACH_HELLO / PEACH_HELLO_ACK frames (PINNED, see §6 of ground truth) ```ts interface PeachHelloFrame { type: "PEACH_HELLO"; code: string; } interface PeachHelloAck { type: "PEACH_HELLO_ACK"; ephemeralPublicKeyHex: string; } ``` There is **no** error/OK frame. After sending `PEACH_HELLO`, the browser expects EITHER a `PEACH_HELLO_ACK` binary frame OR the socket to close. Socket close before an ACK ⇒ wrong/expired code ⇒ `onError("invalid_code")`. ## Transport notes (from ground truth §1) - Port **49142** (pairing). The persistent sync port arrives inside the encrypted `PAIR_IDENTITY` as `ws_port` (helper always announces `8765`). - WebSocket over TCP, **binary frames only**, one Noise (or one JSON) frame per WS binary message. The browser `WebSocket` client masks frames automatically — no manual masking needed on this side. - Pre-handshake HELLO/ACK frames are PLAINTEXT JSON, sent as binary WS messages (`new TextEncoder().encode(JSON.stringify(...))`). --- ## Apply-ready module — `src/background/desktop-pairing.ts` ```ts // Desktop helper code-pairing client (Change 3). // // Pairs the extension with the `peach-sync` desktop helper: // 1. probe the /24 for a host accepting WebSocket on the pairing port // 2. open ws://:49142 // 3. send PEACH_HELLO { code } in PLAINTEXT // 4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close // (socket close before ACK == wrong/expired code — peach-sync sends // NO error frame; it silently closes and rotates the code) // 5. run Noise NK as INITIATOR against the helper's ephemeral key // 6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs) // 7. derive the SAS, surface it for user confirmation // 8. on confirm -> persist the PairedDevice (deviceType "desktop"); on // reject -> abort without persisting // // ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio // chirp flow (chirp-handshake.ts), where the extension is the responder. // The helper's `ephemeralPublicKeyHex` is the NK responder static `s`. import { noiseNKInitiator } from "../shared/noise-js"; import { derivePairingSAS } from "../shared/chirp-handshake"; import type { LocalIdentity } from "../shared/chirp-handshake"; import type { DeviceType, PairedDevice } from "../shared/sync-types"; /** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */ export const DESKTOP_PAIRING_PORT = 49142; /** Default sync port the helper announces; used when ws_port is absent. */ const DEFAULT_SYNC_PORT = 8765; export type DesktopPairingError = | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired) | "no_host_found" // no WebSocket server answered on the pairing port | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed | "sas_mismatch" // user rejected the SAS | "vault_locked"; // no master key available /** * Minimal binary WebSocket transport seam. Production wires the real * `WebSocket`; tests inject a fake. One binary message per frame. */ export interface DesktopPairingSocket { send(bytes: Uint8Array): void; /** Resolves with the next inbound binary frame, or rejects on close. */ receive(): Promise; close(): void; /** Resolves true if the socket closed before the next frame arrived. */ closedBeforeFrame(): boolean; } export interface DesktopPairingDeps { /** Change 5 — find a host answering WebSocket on the pairing port. */ findHost(port: number): Promise; /** Open a binary WebSocket to ws://:/ . */ openSocket(ip: string, port: number): Promise; /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */ getLocalIdentity(): Promise; /** Persist on user confirm. */ persistDevice(device: PairedDevice): Promise; /** Optional fast-path cache for the next sync round. */ cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise; } export interface PairWithDesktopCallbacks { /** Show the 6-digit SAS; the popup must compare against the helper's. */ onSasReady(sas: string): void; /** Pairing succeeded and the device was persisted. */ onComplete(device: PairedDevice): void; /** Pairing failed at some stage. */ onError(error: DesktopPairingError): void; } interface PairingFrame { type: "PAIR_IDENTITY"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; ws_port?: number; vault_id?: string; // Change 1 — additive, optional } function encodeJson(obj: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(obj)); } function decodeJson(bytes: Uint8Array): T { return JSON.parse(new TextDecoder().decode(bytes)) as T; } function hexToBytes(hex: string): Uint8Array { if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex"); const out = new Uint8Array(32); for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } function decodePairIdentity(bytes: Uint8Array): PairingFrame { const p = decodeJson>(bytes); if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY"); if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing"); if (typeof p.device_name !== "string") throw new Error("device_name missing"); if (typeof p.device_type !== "string") throw new Error("device_type missing"); if (typeof p.long_term_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) { throw new Error("long_term_public_key_hex is not 32-byte hex"); } return p as unknown as PairingFrame; } /** * Drive a complete desktop-helper code pairing. * * `confirm` is a function the caller resolves AFTER the user has compared * the SAS shown here against the SAS the helper displays. Resolving false * (or rejecting) aborts before any device is persisted — this is the * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts). */ export async function pairWithDesktopHelper( code: string, cb: PairWithDesktopCallbacks, deps: DesktopPairingDeps, confirm: (sas: string) => Promise, ): Promise { // The helper compares the code byte-for-byte against a [6]u8. Normalise to // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2). const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6); let identity: LocalIdentity; try { identity = await deps.getLocalIdentity(); } catch { cb.onError("vault_locked"); return; } // 1) Find a host on the pairing port. const host = await deps.findHost(DESKTOP_PAIRING_PORT); if (!host) { cb.onError("no_host_found"); return; } // 2) Open the WebSocket. let sock: DesktopPairingSocket; try { sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT); } catch { cb.onError("no_host_found"); return; } try { // 3) Send PEACH_HELLO (PLAINTEXT binary frame). sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode })); // 4) Await PEACH_HELLO_ACK OR socket close. // peach-sync sends NO error frame; a wrong/expired code closes the // socket. So "receive() rejected / closed before a frame" == invalid_code. let ackBytes: Uint8Array; try { ackBytes = await sock.receive(); } catch { cb.onError("invalid_code"); return; } if (sock.closedBeforeFrame() || ackBytes.length === 0) { cb.onError("invalid_code"); return; } let ephemeralPublicKeyHex: string; try { const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes); if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") { // A non-ACK frame on a matching code should not happen; treat the // missing ACK exactly like a wrong code. cb.onError("invalid_code"); return; } ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex; } catch { cb.onError("invalid_code"); return; } // 5) Noise NK as INITIATOR against the helper's ephemeral static key. const responderStatic = hexToBytes(ephemeralPublicKeyHex); const session = await noiseNKInitiator( responderStatic, async (msg) => sock.send(msg), () => sock.receive(), ); // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads. const ourFrame: PairingFrame = { type: "PAIR_IDENTITY", device_id: identity.deviceId, device_name: identity.deviceName, device_type: identity.deviceType, long_term_public_key_hex: identity.longTermPublicKeyHex, signing_public_key_hex: identity.signingPublicKeyHex, ws_port: identity.wsPort, vault_id: identity.vaultId, // Change 1 }; sock.send(session.encrypt(encodeJson(ourFrame))); const theirCipher = await sock.receive(); const theirFrame = decodePairIdentity(session.decrypt(theirCipher)); // 7) Derive the SAS (REUSED — same value the helper computes from the // shared handshake hash) and surface it for user confirmation. const sas = derivePairingSAS(session.handshakeHash); cb.onSasReady(sas); const confirmed = await confirm(sas); if (!confirmed) { cb.onError("sas_mismatch"); return; } // 8) Persist. deviceType "desktop"; ws_port from the helper frame, // defaulting to 8765 (the helper always announces 8765, but guard it). const device: PairedDevice = { deviceId: theirFrame.device_id, deviceName: theirFrame.device_name, deviceType: (theirFrame.device_type as DeviceType) || "desktop", longTermPublicKeyHex: theirFrame.long_term_public_key_hex, signingPublicKeyHex: theirFrame.signing_public_key_hex, trustSource: "direct", wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT, pairedAt: Date.now(), }; await deps.persistDevice(device); if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) { await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined); } cb.onComplete(device); } catch { cb.onError("handshake_failed"); } finally { sock.close(); } } ``` ### Production deps factory (sketch — lives alongside or in the wiring layer) ```ts import { upsertPairedDevice, setLastKnownIp } from "./paired-devices"; import { buildLocalIdentity } from "./chirp-identity"; import { computeVaultId } from "../shared/sync-identity"; import { getRootKeyHex, isUnlocked } from "./vault"; import { probeSubnetForPort } from "./desktop-pairing-probe"; // Change 5 import { getLocalSubnet } from "../shared/local-network"; export function productionDesktopPairingDeps(): DesktopPairingDeps { return { async findHost(port) { const subnet = await getLocalSubnet(); if (!subnet) return null; return probeSubnetForPort(subnet, port); }, async openSocket(ip, port) { return openBinaryWebSocket(ip, port); // small wrapper around WebSocket }, async getLocalIdentity() { if (!isUnlocked()) throw new Error("vault_locked"); const masterKeyHex = getRootKeyHex(); if (!masterKeyHex) throw new Error("vault_locked"); const masterKey = hexToBytesLocal(masterKeyHex); try { const base = await buildLocalIdentity(masterKey); // chirp-identity.ts:79 const vaultId = await computeVaultId(masterKey); // sync-identity.ts:75 return { ...base, vaultId }; // LocalIdentity + Change-1 field } finally { masterKey.fill(0); } }, persistDevice: (d) => upsertPairedDevice(d), cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip), }; } ``` --- ## Threading note — where new state comes from (mirrors Change 1) `LocalIdentity.vaultId` (added in Change 1) is **not** populated by `buildLocalIdentity` (`chirp-identity.ts:79` returns no `vaultId`). The production deps factory above threads it in: - `masterKey` is obtained the same way `CHIRP_GET_LOCAL_IDENTITY` does it (`messages.ts:1324-1326`): `getRootKeyHex()` → `unhexBytes` → `masterKey.fill(0)` in `finally`. **Use the stable ROOT key, not the password-derived key** (SEV-2-B), so pairing survives a master-password change. - `vaultId = await computeVaultId(masterKey)` (`sync-identity.ts:75`). - Both are zeroed after use. **INTEGRATOR TODO:** `buildLocalIdentity` returns `LocalChirpIdentity`, whose shape matches `LocalIdentity` minus `vaultId`/`wsPort`. Confirm the two types are assignable, or add `vaultId?` to `LocalChirpIdentity` directly so the spread is type-clean. The extension has no `wsPort` (it is never a sync host), so leaving `wsPort` undefined is correct. --- ## Wiring — popup ⇄ background The handshake needs `getUserMedia`-free crypto only, so it CAN run entirely in the background service worker (unlike the audio chirp flow, which must run in the popup). Recommended split: **the background runs the whole pairing**, the popup drives the UI and the SAS confirmation round-trip. ### New message types (propose) | Direction | `type` | Payload | Response | | --- | --- | --- | --- | | popup → bg | `PAIR_WITH_DESKTOP` | `{ code: string }` | `{ ok: true }` (started) or `{ ok:false, error }` | | bg → popup | `DESKTOP_PAIR_SAS` | `{ sas: string }` | — (push via `chrome.runtime.sendMessage`) | | popup → bg | `DESKTOP_PAIR_CONFIRM` | `{ confirmed: boolean }` | `{ ok: true }` | | bg → popup | `DESKTOP_PAIR_RESULT` | `{ status:"complete", device } \| { status:"error", error }` | — (push) | ### SAS / confirm round-trip Because `pairWithDesktopHelper` takes a `confirm(sas) => Promise` callback that must block on the user, the background handler bridges it to a message round-trip: 1. `PAIR_WITH_DESKTOP` handler calls `pairWithDesktopHelper(code, cb, deps, confirm)`. 2. `cb.onSasReady(sas)` and the `confirm` callback both fire `DESKTOP_PAIR_SAS` to the popup and then **await a deferred Promise** that a later `DESKTOP_PAIR_CONFIRM` message resolves. 3. The popup shows the SAS, the user compares it to the helper's display, taps confirm/reject, and the popup sends `DESKTOP_PAIR_CONFIRM { confirmed }`. 4. The handler resolves the deferred Promise with `confirmed`, completing the `confirm()` call inside `pairWithDesktopHelper`. 5. `cb.onComplete`/`cb.onError` push `DESKTOP_PAIR_RESULT` to the popup. Keep one in-flight pairing per worker (store the pending `confirm` resolver in a module-level variable, cleared on completion/error). This mirrors how `chirp-pair.ts`'s `confirmSAS(sas): Promise` dep gates persistence (`chirp-pair.ts` `confirmSAS` doc, lines ~62-71), just relocated to the background since no microphone is involved. **INTEGRATOR TODO (handler placement):** add the three `case` blocks to the `switch` in `messages.ts` (alongside `CHIRP_*` at lines 1318-1384). Gate on `vault.isUnlocked()` and `requirePro("sync")` exactly like `CHIRP_GET_LOCAL_IDENTITY` (`messages.ts:1319-1321`). Validate the persisted device with the same shape check `CHIRP_COMPLETE_PAIRING` uses (`messages.ts:1344-1360`) — except here the background builds the `PairedDevice` itself, so the validation can be skipped (the device comes from our own decode path, not from the popup). --- ## Open integration TODOs 1. **`openBinaryWebSocket` helper** — a small adapter producing a `DesktopPairingSocket` from `WebSocket` (set `binaryType="arraybuffer"`, queue inbound frames, expose `closedBeforeFrame()`). Pattern already exists in `chirp-sync-transport.ts:58-121` (inbox/readers + onclose); extract a trimmed version. Critically, `closedBeforeFrame()` must distinguish "socket closed with no ACK" from "ACK received" — this is the `invalid_code` signal. 2. **`LocalChirpIdentity` vs `LocalIdentity`** type reconciliation (see threading note). 3. **Pro gating + lock checks** in the new `messages.ts` cases. 4. **Single-flight guard** for the pending-confirm resolver in the background. 5. **Timeout** — wrap `findHost`/`receive` in a ceiling (e.g. 10s probe, 5s per receive) so a stalled helper surfaces `no_host_found`/`handshake_failed` instead of hanging the popup. `connectWebSocket` in `chirp-sync-transport.ts:174` already shows the timeout idiom.