03-desktop-pairing-handler.md

18.4 KB · raw

rendered · source

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<LocalChirpIdentity> src/background/chirp-identity.ts:79
Vault id for vault_id computeVaultId(masterKey): Promise<string> 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)

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)


Apply-ready module — src/background/desktop-pairing.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://<host>: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<Uint8Array>;
  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<string | null>;
  /** Open a binary WebSocket to ws://<ip>:<port>/ . */
  openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;
  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */
  getLocalIdentity(): Promise<LocalIdentity>;
  /** Persist on user confirm. */
  persistDevice(device: PairedDevice): Promise<void>;
  /** Optional fast-path cache for the next sync round. */
  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;
}

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<T>(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<Record<string, unknown>>(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<boolean>,
): Promise<void> {
  // 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)

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:

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<boolean> 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<boolean> 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.