03-desktop-pairing-handler.md
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_idinPAIR_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. See05-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)
- Port 49142 (pairing). The persistent sync port arrives inside the
encrypted
PAIR_IDENTITYasws_port(helper always announces8765). - WebSocket over TCP, binary frames only, one Noise (or one JSON) frame
per WS binary message. The browser
WebSocketclient masks frames automatically — no manual masking needed on this side. - Pre-handshake HELLO/ACK frames are PLAINTEXT JSON, sent as binary WS
messages (
new TextEncoder().encode(JSON.stringify(...))).
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:
masterKeyis obtained the same wayCHIRP_GET_LOCAL_IDENTITYdoes it (messages.ts:1324-1326):getRootKeyHex()→unhexBytes→masterKey.fill(0)infinally. Use the stable ROOT key, not the password-derived key (SEV-2-B), so pairing survives a master-password change.vaultId = await computeVaultId(masterKey)(sync-identity.ts:75).- Both are zeroed after use.
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:
PAIR_WITH_DESKTOPhandler callspairWithDesktopHelper(code, cb, deps, confirm).cb.onSasReady(sas)and theconfirmcallback both fireDESKTOP_PAIR_SASto the popup and then await a deferred Promise that a laterDESKTOP_PAIR_CONFIRMmessage resolves.- 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 }. - The handler resolves the deferred Promise with
confirmed, completing theconfirm()call insidepairWithDesktopHelper. cb.onComplete/cb.onErrorpushDESKTOP_PAIR_RESULTto 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
openBinaryWebSockethelper — a small adapter producing aDesktopPairingSocketfromWebSocket(setbinaryType="arraybuffer", queue inbound frames, exposeclosedBeforeFrame()). Pattern already exists inchirp-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 theinvalid_codesignal.LocalChirpIdentityvsLocalIdentitytype reconciliation (see threading note).- Pro gating + lock checks in the new
messages.tscases. - Single-flight guard for the pending-confirm resolver in the background.
- Timeout — wrap
findHost/receivein a ceiling (e.g. 10s probe, 5s per receive) so a stalled helper surfacesno_host_found/handshake_failedinstead of hanging the popup.connectWebSocketinchirp-sync-transport.ts:174already shows the timeout idiom.