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
↓ peach-browser-08e56309.tar.gzatlas at this save
files changed5 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 −0src/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+}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+});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+}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";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"