03-desktop-pairing-handler.md

18.4 KB · raw

rendered · source

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```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`.8182import { 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";8687/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */88export const DESKTOP_PAIRING_PORT = 49142;89/** Default sync port the helper announces; used when ws_port is absent. */90const DEFAULT_SYNC_PORT = 8765;9192export 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 available9899/**100 * Minimal binary WebSocket transport seam. Production wires the real101 * `WebSocket`; tests inject a fake. One binary message per frame.102 */103export 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}111112export 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}124125export 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}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; // Change 1 — additive, optional143}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}172173/**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 */181export 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);190191  let identity: LocalIdentity;192  try {193    identity = await deps.getLocalIdentity();194  } catch {195    cb.onError("vault_locked");196    return;197  }198199  // 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  }205206  // 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  }214215  try {216    // 3) Send PEACH_HELLO (PLAINTEXT binary frame).217    sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));218219    // 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    }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        // 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    }248249    // 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    );256257    // 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)));269270    const theirCipher = await sock.receive();271    const theirFrame = decodePairIdentity(session.decrypt(theirCipher));272273    // 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);277278    const confirmed = await confirm(sas);279    if (!confirmed) {280      cb.onError("sas_mismatch");281      return;282    }283284    // 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```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"; // Change 5317import { 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); // 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```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.