08e56309a014

feat: desktop helper pairing handler — PEACH_HELLO, NK initiator, SAS, persist (v4.0.2)

time
2026-06-17 12:25 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
desktop helper pairing integration
parent
ef38059b9120
5 files src/background/desktop-pairing-bg.ts+183 −0src/background/desktop-pairing.test.ts+268 −0src/background/desktop-pairing.ts+244 −0src/background/messages.ts+27 −0src/shared/types.ts+7 −0
src/background/desktop-pairing-bg.ts+183 −0
@@ -0,0 +1,183 @@1+// Background wiring for desktop helper code pairing (Change 3 §Wiring).2+//3+// Keeps `desktop-pairing.ts` a PURE, dependency-injected client (so its unit4+// tests run without chrome / vault / WebSocket). This module supplies the5+// production `DesktopPairingDeps`, the real `WebSocket` adapter, and the6+// single-flight SAS/confirm round-trip that bridges the blocking7+// `confirm(sas)` callback to the popup via runtime messages:8+//9+//   PAIR_WITH_DESKTOP  → startDesktopPairing()         (popup → bg, messages.ts)10+//   DESKTOP_PAIR_SAS   ← push when onSasReady fires     (bg → popup)11+//   DESKTOP_PAIR_CONFIRM → resolveDesktopPairingConfirm (popup → bg, messages.ts)12+//   DESKTOP_PAIR_RESULT ← push on complete/error        (bg → popup)13+14+import {15+  pairWithDesktopHelper,16+  type DesktopPairingDeps,17+  type DesktopPairingSocket,18+  type PairWithDesktopCallbacks,19+} from "./desktop-pairing";20+import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";21+import { buildLocalIdentity } from "./chirp-identity";22+import { computeVaultId } from "../shared/sync-identity";23+import { isUnlocked, getRootKeyHex } from "./vault";24+import { probeSubnetForPort } from "./desktop-pairing-probe";25+import { getLocalSubnet } from "../shared/local-network";26+import type { LocalIdentity } from "../shared/chirp-handshake";27+28+function hexToBytesLocal(hex: string): Uint8Array {29+  const out = new Uint8Array(hex.length / 2);30+  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);31+  return out;32+}33+34+/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */35+function push(type: string, payload: unknown): void {36+  try {37+    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;38+    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {39+      (maybe as Promise<unknown>).catch(() => undefined);40+    }41+  } catch {42+    // Popup closed / no receiver — fine.43+  }44+}45+46+/** Adapt a real `WebSocket` to the `DesktopPairingSocket` seam (binary frames). */47+export function openBinaryWebSocket(ip: string, port: number): Promise<DesktopPairingSocket> {48+  return new Promise((resolve, reject) => {49+    let ws: WebSocket;50+    try {51+      ws = new WebSocket(`ws://${ip}:${port}/`);52+    } catch (err) {53+      reject(err instanceof Error ? err : new Error("websocket open failed"));54+      return;55+    }56+    ws.binaryType = "arraybuffer";57+58+    const inbox: Uint8Array[] = [];59+    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];60+    let opened = false;61+    let closed = false;62+    // True iff the socket closed with no frame delivered for the pending read —63+    // the wrong/expired-code signal (peach-sync sends no error frame).64+    let sawCloseBeforeFrame = false;65+66+    ws.onopen = () => {67+      opened = true;68+      resolve(sock);69+    };70+    ws.onmessage = (ev: MessageEvent) => {71+      const bytes = new Uint8Array(ev.data as ArrayBuffer);72+      const r = readers.shift();73+      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }74+      else inbox.push(bytes);75+    };76+    ws.onerror = () => {77+      if (!opened) reject(new Error("websocket connection failed"));78+    };79+    ws.onclose = () => {80+      closed = true;81+      if (readers.length) {82+        sawCloseBeforeFrame = true;83+        while (readers.length) readers.shift()!.reject(new Error("socket closed"));84+      }85+      if (!opened) reject(new Error("socket closed before open"));86+    };87+88+    const sock: DesktopPairingSocket = {89+      send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },90+      receive(): Promise<Uint8Array> {91+        const queued = inbox.shift();92+        if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }93+        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }94+        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));95+      },96+      close() { try { ws.close(); } catch { /* ignore */ } },97+      closedBeforeFrame() { return sawCloseBeforeFrame; },98+    };99+  });100+}101+102+/** Production dependency set — real probe, WebSocket, identity, and storage. */103+export function productionDesktopPairingDeps(): DesktopPairingDeps {104+  return {105+    async findHost(port) {106+      const subnet = await getLocalSubnet();107+      if (!subnet) return null;108+      return probeSubnetForPort(subnet, port);109+    },110+    openSocket: (ip, port) => openBinaryWebSocket(ip, port),111+    async getLocalIdentity(): Promise<LocalIdentity> {112+      if (!isUnlocked()) throw new Error("vault_locked");113+      const masterKeyHex = getRootKeyHex();114+      if (!masterKeyHex) throw new Error("vault_locked");115+      // SEV-2-B: derive from the stable ROOT key so pairing survives a116+      // master-password change. Zero the copy when done.117+      const masterKey = hexToBytesLocal(masterKeyHex);118+      try {119+        const base = await buildLocalIdentity(masterKey);120+        const vaultId = await computeVaultId(masterKey);121+        return { ...base, vaultId };122+      } finally {123+        masterKey.fill(0);124+      }125+    },126+    persistDevice: (d) => upsertPairedDevice(d),127+    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),128+  };129+}130+131+// ── Single in-flight pairing per worker ──────────────────────────────────────132+// One pairing at a time. The blocking confirm() resolver is parked here while133+// the popup shows the SAS; DESKTOP_PAIR_CONFIRM resolves it.134+let pendingConfirm: ((confirmed: boolean) => void) | null = null;135+let inFlight = false;136+137+function clearPairingState(): void {138+  pendingConfirm = null;139+  inFlight = false;140+}141+142+export function isDesktopPairingInFlight(): boolean {143+  return inFlight;144+}145+146+/**147+ * Kick off a desktop pairing. Returns immediately; the SAS and the terminal148+ * result are delivered to the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT149+ * pushes. `deps` is injectable for tests.150+ */151+export function startDesktopPairing(152+  code: string,153+  deps: DesktopPairingDeps = productionDesktopPairingDeps(),154+): void {155+  inFlight = true;156+  const cb: PairWithDesktopCallbacks = {157+    onSasReady: (sas) => push("DESKTOP_PAIR_SAS", { sas }),158+    onComplete: (device) => {159+      push("DESKTOP_PAIR_RESULT", { status: "complete", device });160+      clearPairingState();161+    },162+    onError: (error) => {163+      push("DESKTOP_PAIR_RESULT", { status: "error", error });164+      clearPairingState();165+    },166+  };167+  // The confirm callback blocks on the popup's DESKTOP_PAIR_CONFIRM reply.168+  const confirm = (_sas: string): Promise<boolean> =>169+    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });170+171+  void pairWithDesktopHelper(code, cb, deps, confirm).finally(() => {172+    // onComplete/onError already cleared state; this is the backstop for an173+    // unexpected throw that skipped the callbacks.174+    if (inFlight) clearPairingState();175+  });176+}177+178+/** Resolve the parked confirm() with the user's decision from the popup. */179+export function resolveDesktopPairingConfirm(confirmed: boolean): void {180+  const resolve = pendingConfirm;181+  pendingConfirm = null;182+  if (resolve) resolve(confirmed);183+}

view file

src/background/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 "./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

src/background/desktop-pairing.ts+244 −0
@@ -0,0 +1,244 @@1+// Desktop helper code-pairing client (Change 3).2+//3+// Pairs the extension with the `peach-sync` desktop helper:4+//   1. probe the /24 for a host accepting WebSocket on the pairing port5+//   2. open ws://<host>:491426+//   3. send PEACH_HELLO { code } in PLAINTEXT7+//   4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close8+//        (socket close before ACK == wrong/expired code — peach-sync sends9+//         NO error frame; it silently closes and rotates the code)10+//   5. run Noise NK as INITIATOR against the helper's ephemeral key11+//   6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)12+//   7. derive the SAS, surface it for user confirmation13+//   8. on confirm -> persist the PairedDevice (deviceType "desktop"); on14+//        reject -> abort without persisting15+//16+// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio17+// chirp flow (chirp-handshake.ts), where the extension is the responder.18+// The helper's `ephemeralPublicKeyHex` is the NK responder static `s`.19+20+import { noiseNKInitiator } from "../shared/noise-js";21+import { derivePairingSAS } from "../shared/chirp-handshake";22+import type { LocalIdentity } from "../shared/chirp-handshake";23+import type { DeviceType, PairedDevice } from "../shared/sync-types";24+25+/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */26+export const DESKTOP_PAIRING_PORT = 49142;27+/** Default sync port the helper announces; used when ws_port is absent. */28+const DEFAULT_SYNC_PORT = 8765;29+30+export type DesktopPairingError =31+  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)32+  | "no_host_found"     // no WebSocket server answered on the pairing port33+  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed34+  | "sas_mismatch"      // user rejected the SAS35+  | "vault_locked";     // no master key available36+37+/**38+ * Minimal binary WebSocket transport seam. Production wires the real39+ * `WebSocket`; tests inject a fake. One binary message per frame.40+ */41+export interface DesktopPairingSocket {42+  send(bytes: Uint8Array): void;43+  /** Resolves with the next inbound binary frame, or rejects on close. */44+  receive(): Promise<Uint8Array>;45+  close(): void;46+  /** Resolves true if the socket closed before the next frame arrived. */47+  closedBeforeFrame(): boolean;48+}49+50+export interface DesktopPairingDeps {51+  /** Change 5 — find a host answering WebSocket on the pairing port. */52+  findHost(port: number): Promise<string | null>;53+  /** Open a binary WebSocket to ws://<ip>:<port>/ . */54+  openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;55+  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */56+  getLocalIdentity(): Promise<LocalIdentity>;57+  /** Persist on user confirm. */58+  persistDevice(device: PairedDevice): Promise<void>;59+  /** Optional fast-path cache for the next sync round. */60+  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;61+}62+63+export interface PairWithDesktopCallbacks {64+  /** Show the 6-digit SAS; the popup must compare against the helper's. */65+  onSasReady(sas: string): void;66+  /** Pairing succeeded and the device was persisted. */67+  onComplete(device: PairedDevice): void;68+  /** Pairing failed at some stage. */69+  onError(error: DesktopPairingError): void;70+}71+72+interface PairingFrame {73+  type: "PAIR_IDENTITY";74+  device_id: string;75+  device_name: string;76+  device_type: DeviceType;77+  long_term_public_key_hex: string;78+  signing_public_key_hex?: string;79+  ws_port?: number;80+  vault_id?: string; // Change 1 — additive, optional81+}82+83+function encodeJson(obj: unknown): Uint8Array {84+  return new TextEncoder().encode(JSON.stringify(obj));85+}86+87+function decodeJson<T>(bytes: Uint8Array): T {88+  return JSON.parse(new TextDecoder().decode(bytes)) as T;89+}90+91+function hexToBytes(hex: string): Uint8Array {92+  if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");93+  const out = new Uint8Array(32);94+  for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);95+  return out;96+}97+98+function decodePairIdentity(bytes: Uint8Array): PairingFrame {99+  const p = decodeJson<Record<string, unknown>>(bytes);100+  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");101+  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");102+  if (typeof p.device_name !== "string") throw new Error("device_name missing");103+  if (typeof p.device_type !== "string") throw new Error("device_type missing");104+  if (typeof p.long_term_public_key_hex !== "string" ||105+      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {106+    throw new Error("long_term_public_key_hex is not 32-byte hex");107+  }108+  return p as unknown as PairingFrame;109+}110+111+/**112+ * Drive a complete desktop-helper code pairing.113+ *114+ * `confirm` is a function the caller resolves AFTER the user has compared115+ * the SAS shown here against the SAS the helper displays. Resolving false116+ * (or rejecting) aborts before any device is persisted — this is the117+ * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).118+ */119+export async function pairWithDesktopHelper(120+  code: string,121+  cb: PairWithDesktopCallbacks,122+  deps: DesktopPairingDeps,123+  confirm: (sas: string) => Promise<boolean>,124+): Promise<void> {125+  // The helper compares the code byte-for-byte against a [6]u8. Normalise to126+  // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2).127+  const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);128+129+  let identity: LocalIdentity;130+  try {131+    identity = await deps.getLocalIdentity();132+  } catch {133+    cb.onError("vault_locked");134+    return;135+  }136+137+  // 1) Find a host on the pairing port.138+  const host = await deps.findHost(DESKTOP_PAIRING_PORT);139+  if (!host) {140+    cb.onError("no_host_found");141+    return;142+  }143+144+  // 2) Open the WebSocket.145+  let sock: DesktopPairingSocket;146+  try {147+    sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);148+  } catch {149+    cb.onError("no_host_found");150+    return;151+  }152+153+  try {154+    // 3) Send PEACH_HELLO (PLAINTEXT binary frame).155+    sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));156+157+    // 4) Await PEACH_HELLO_ACK OR socket close.158+    //    peach-sync sends NO error frame; a wrong/expired code closes the159+    //    socket. So "receive() rejected / closed before a frame" == invalid_code.160+    let ackBytes: Uint8Array;161+    try {162+      ackBytes = await sock.receive();163+    } catch {164+      cb.onError("invalid_code");165+      return;166+    }167+    if (sock.closedBeforeFrame() || ackBytes.length === 0) {168+      cb.onError("invalid_code");169+      return;170+    }171+172+    let ephemeralPublicKeyHex: string;173+    try {174+      const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);175+      if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {176+        // A non-ACK frame on a matching code should not happen; treat the177+        // missing ACK exactly like a wrong code.178+        cb.onError("invalid_code");179+        return;180+      }181+      ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;182+    } catch {183+      cb.onError("invalid_code");184+      return;185+    }186+187+    // 5) Noise NK as INITIATOR against the helper's ephemeral static key.188+    const responderStatic = hexToBytes(ephemeralPublicKeyHex);189+    const session = await noiseNKInitiator(190+      responderStatic,191+      async (msg) => sock.send(msg),192+      () => sock.receive(),193+    );194+195+    // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.196+    const ourFrame: PairingFrame = {197+      type: "PAIR_IDENTITY",198+      device_id: identity.deviceId,199+      device_name: identity.deviceName,200+      device_type: identity.deviceType,201+      long_term_public_key_hex: identity.longTermPublicKeyHex,202+      signing_public_key_hex: identity.signingPublicKeyHex,203+      ws_port: identity.wsPort,204+      vault_id: identity.vaultId, // Change 1205+    };206+    sock.send(session.encrypt(encodeJson(ourFrame)));207+208+    const theirCipher = await sock.receive();209+    const theirFrame = decodePairIdentity(session.decrypt(theirCipher));210+211+    // 7) Derive the SAS (REUSED — same value the helper computes from the212+    //    shared handshake hash) and surface it for user confirmation.213+    const sas = derivePairingSAS(session.handshakeHash);214+    cb.onSasReady(sas);215+216+    const confirmed = await confirm(sas);217+    if (!confirmed) {218+      cb.onError("sas_mismatch");219+      return;220+    }221+222+    // 8) Persist. deviceType "desktop"; ws_port from the helper frame,223+    //    defaulting to 8765 (the helper always announces 8765, but guard it).224+    const device: PairedDevice = {225+      deviceId: theirFrame.device_id,226+      deviceName: theirFrame.device_name,227+      deviceType: (theirFrame.device_type as DeviceType) || "desktop",228+      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,229+      signingPublicKeyHex: theirFrame.signing_public_key_hex,230+      trustSource: "direct",231+      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,232+      pairedAt: Date.now(),233+    };234+    await deps.persistDevice(device);235+    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {236+      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);237+    }238+    cb.onComplete(device);239+  } catch {240+    cb.onError("handshake_failed");241+  } finally {242+    sock.close();243+  }244+}

view file

src/background/messages.ts+27 −0
@@ -1383,6 +1383,33 @@13831383         return { ok: true };13841384       }13851385 1386+      // ── Desktop helper code pairing (peach-sync) ──────────────────────────1387+      // Start: gated exactly like CHIRP_GET_LOCAL_IDENTITY (unlocked + Pro).1388+      // The whole NK pairing runs in the background; the SAS and the terminal1389+      // result reach the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes.1390+      case "PAIR_WITH_DESKTOP": {1391+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1392+        const proCheck = await requirePro("sync");1393+        if (!proCheck.allowed) return { ok: false, error: "pro_required" };1394+        const { code } = (msg.payload ?? {}) as { code?: string };1395+        if (typeof code !== "string" || !/\d/.test(code)) {1396+          return { ok: false, error: "invalid_payload" };1397+        }1398+        const { startDesktopPairing, isDesktopPairingInFlight } = await import(1399+          "./desktop-pairing-bg"1400+        );1401+        if (isDesktopPairingInFlight()) return { ok: false, error: "pairing_in_progress" };1402+        startDesktopPairing(code);1403+        return { ok: true };1404+      }1405+1406+      case "DESKTOP_PAIR_CONFIRM": {1407+        const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1408+        const { resolveDesktopPairingConfirm } = await import("./desktop-pairing-bg");1409+        resolveDesktopPairingConfirm(confirmed === true);1410+        return { ok: true };1411+      }1412+13861413       case "TRIGGER_SYNC": {13871414         const payload = (msg.payload ?? {}) as {13881415           reason?: "vault_change" | "foreground" | "manual" | "mdns";

view file

src/shared/types.ts+7 −0
@@ -301,6 +301,13 @@301301   | "CHIRP_LIST_PAIRED_DEVICES"302302   | "CHIRP_UNPAIR_DEVICE"303303   | "CHIRP_RENAME_LOCAL_DEVICE"304+  // Desktop helper code pairing (peach-sync). PAIR_WITH_DESKTOP /305+  // DESKTOP_PAIR_CONFIRM are popup→bg requests handled in the switch;306+  // DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT are bg→popup pushes.307+  | "PAIR_WITH_DESKTOP"308+  | "DESKTOP_PAIR_CONFIRM"309+  | "DESKTOP_PAIR_SAS"310+  | "DESKTOP_PAIR_RESULT"304311   | "TRIGGER_SYNC"305312   | "GET_SYNC_STATE"306313   | "GET_PENDING_PASSKEY"

view file