1# Change 3 — Desktop helper pairing handler23> **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.78## What this change adds910A 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 learned14from `PEACH_HELLO_ACK` (`_GROUND-TRUTH.md` §3).1516## Verified dependencies (real exported symbols)1718| 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) |2930> **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`.3637## The PEACH_HELLO / PEACH_HELLO_ACK frames (PINNED, see §6 of ground truth)3839```ts40interface PeachHelloFrame { type: "PEACH_HELLO"; code: string; }41interface PeachHelloAck { type: "PEACH_HELLO_ACK"; ephemeralPublicKeyHex: string; }42```4344There is **no** error/OK frame. After sending `PEACH_HELLO`, the browser45expects EITHER a `PEACH_HELLO_ACK` binary frame OR the socket to close.46Socket close before an ACK ⇒ wrong/expired code ⇒ `onError("invalid_code")`.4748## Transport notes (from ground truth §1)4950- 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(...))`).5758---5960## Apply-ready module — `src/background/desktop-pairing.ts`6162```ts6364656667686970717273747576777879808182import { noiseNKInitiator } from "../shared/noise-js";83import { derivePairingSAS } from "../shared/chirp-handshake";84import type { LocalIdentity } from "../shared/chirp-handshake";85import type { DeviceType, PairedDevice } from "../shared/sync-types";868788export const DESKTOP_PAIRING_PORT = 49142;8990const DEFAULT_SYNC_PORT = 8765;9192export type DesktopPairingError =93 | "invalid_code" 94 | "no_host_found" 95 | "handshake_failed" 96 | "sas_mismatch" 97 | "vault_locked"; 9899100101102103export interface DesktopPairingSocket {104 send(bytes: Uint8Array): void;105 106 receive(): Promise<Uint8Array>;107 close(): void;108 109 closedBeforeFrame(): boolean;110}111112export interface DesktopPairingDeps {113 114 findHost(port: number): Promise<string | null>;115 116 openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;117 118 getLocalIdentity(): Promise<LocalIdentity>;119 120 persistDevice(device: PairedDevice): Promise<void>;121 122 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;123}124125export interface PairWithDesktopCallbacks {126 127 onSasReady(sas: string): void;128 129 onComplete(device: PairedDevice): void;130 131 onError(error: DesktopPairingError): void;132}133134interface 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; 143}144145function encodeJson(obj: unknown): Uint8Array {146 return new TextEncoder().encode(JSON.stringify(obj));147}148149function decodeJson<T>(bytes: Uint8Array): T {150 return JSON.parse(new TextDecoder().decode(bytes)) as T;151}152153function 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}159160function 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}172173174175176177178179180181export async function pairWithDesktopHelper(182 code: string,183 cb: PairWithDesktopCallbacks,184 deps: DesktopPairingDeps,185 confirm: (sas: string) => Promise<boolean>,186): Promise<void> {187 188 189 const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);190191 let identity: LocalIdentity;192 try {193 identity = await deps.getLocalIdentity();194 } catch {195 cb.onError("vault_locked");196 return;197 }198199 200 const host = await deps.findHost(DESKTOP_PAIRING_PORT);201 if (!host) {202 cb.onError("no_host_found");203 return;204 }205206 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 }214215 try {216 217 sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));218219 220 221 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 }233234 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 239 240 cb.onError("invalid_code");241 return;242 }243 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;244 } catch {245 cb.onError("invalid_code");246 return;247 }248249 250 const responderStatic = hexToBytes(ephemeralPublicKeyHex);251 const session = await noiseNKInitiator(252 responderStatic,253 async (msg) => sock.send(msg),254 () => sock.receive(),255 );256257 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, 267 };268 sock.send(session.encrypt(encodeJson(ourFrame)));269270 const theirCipher = await sock.receive();271 const theirFrame = decodePairIdentity(session.decrypt(theirCipher));272273 274 275 const sas = derivePairingSAS(session.handshakeHash);276 cb.onSasReady(sas);277278 const confirmed = await confirm(sas);279 if (!confirmed) {280 cb.onError("sas_mismatch");281 return;282 }283284 285 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```308309### Production deps factory (sketch — lives alongside or in the wiring layer)310311```ts312import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";313import { buildLocalIdentity } from "./chirp-identity";314import { computeVaultId } from "../shared/sync-identity";315import { getRootKeyHex, isUnlocked } from "./vault";316import { probeSubnetForPort } from "./desktop-pairing-probe"; 317import { getLocalSubnet } from "../shared/local-network";318319export 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); 328 },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); 336 const vaultId = await computeVaultId(masterKey); 337 return { ...base, vaultId }; 338 } finally {339 masterKey.fill(0);340 }341 },342 persistDevice: (d) => upsertPairedDevice(d),343 cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),344 };345}346```347348---349350## Threading note — where new state comes from (mirrors Change 1)351352`LocalIdentity.vaultId` (added in Change 1) is **not** populated by353`buildLocalIdentity` (`chirp-identity.ts:79` returns no `vaultId`). The354production deps factory above threads it in:355356- `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.362363**INTEGRATOR TODO:** `buildLocalIdentity` returns `LocalChirpIdentity`, whose364shape matches `LocalIdentity` minus `vaultId`/`wsPort`. Confirm the two types365are assignable, or add `vaultId?` to `LocalChirpIdentity` directly so the366spread is type-clean. The extension has no `wsPort` (it is never a sync host),367so leaving `wsPort` undefined is correct.368369---370371## Wiring — popup ⇄ background372373The handshake needs `getUserMedia`-free crypto only, so it CAN run entirely374in the background service worker (unlike the audio chirp flow, which must run375in the popup). Recommended split: **the background runs the whole pairing**,376the popup drives the UI and the SAS confirmation round-trip.377378### New message types (propose)379380| 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) |386387### SAS / confirm round-trip388389Because `pairWithDesktopHelper` takes a `confirm(sas) => Promise<boolean>`390callback that must block on the user, the background handler bridges it to a391message round-trip:3923931. `PAIR_WITH_DESKTOP` handler calls `pairWithDesktopHelper(code, cb, deps, confirm)`.3942. `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.3973. 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 }`.3994. The handler resolves the deferred Promise with `confirmed`, completing the400 `confirm()` call inside `pairWithDesktopHelper`.4015. `cb.onComplete`/`cb.onError` push `DESKTOP_PAIR_RESULT` to the popup.402403Keep one in-flight pairing per worker (store the pending `confirm` resolver404in 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 the407background since no microphone is involved.408409**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 persisted413device 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 from416our own decode path, not from the popup).417418---419420## Open integration TODOs4214221. **`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.4282. **`LocalChirpIdentity` vs `LocalIdentity`** type reconciliation (see429 threading note).4303. **Pro gating + lock checks** in the new `messages.ts` cases.4314. **Single-flight guard** for the pending-confirm resolver in the background.4325. **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.