# Desktop Helper Pairing — Ground Truth Contract > **DRAFT ARTIFACT.** Pins the canonical wire contract for pairing the Peach > browser extension with the `peach-sync` desktop helper. Every later drafting > pass MUST conform to this file. Where the brief and the `peach-sync` source > disagree, **the source wins** and the discrepancy is flagged below. > > All citations are `file:line` against the two repos as read on 2026-06-16. > `S/` = `/Users/june/projects/peach-sync/`, `B/` = `/Users/june/projects/peach-browser/`. --- ## 0. Discrepancies vs the brief (READ FIRST) 1. **DeviceType / PairedDevice live in `sync-types.ts`, NOT `types.ts`.** The brief said DeviceType is "likely `src/shared/types.ts`". It is wrong. - `DeviceType` is defined at `B/src/shared/sync-types.ts:11`. - `B/src/shared/types.ts` has **no** `DeviceType` symbol. - `chirp-handshake.ts` imports it from `./sync-types` (`B/src/shared/chirp-handshake.ts:29`). - **`"desktop"` is ALREADY a member of `DeviceType`** (`sync-types.ts:11`) and already has a chirp byte tag `DEVICE_TYPE_DESKTOP = 0x04` (`sync-types.ts:19`), a `deviceTypeFromByte`/`deviceTypeToByte` case (`sync-types.ts:36,45`), and `chirp-payload.ts` already accepts byte `0x04` (`chirp-payload.ts:5,89`). **Change 2 is therefore largely a NO-OP for the type union itself** — see `02-desktop-device-type.md`. The brief's premise that `"desktop"` must be added is false against current source. 2. **The PEACH_HELLO acknowledgement frame is named `PEACH_HELLO_ACK`, NOT `PEACH_HELLO_OK`.** The brief proposed `PEACH_HELLO_OK` / `PEACH_HELLO_ERROR`. The source sends `PEACH_HELLO_ACK` and has **no** `PEACH_HELLO_OK` and **no** `PEACH_HELLO_ERROR` frame at all. See §2. 3. **There is no `PEACH_HELLO_ERROR` / error frame on code mismatch.** On a wrong code the helper **silently closes the connection and rotates the code** (`S/src/pairing.zig:144-147,119-125`). It does not send an error frame. A downstream browser implementation must treat connection close / no `*_ACK` as "wrong code", not wait for an error frame. 4. **The `PEACH_HELLO_ACK` payload field is `ephemeralPublicKeyHex` (camelCase), NOT snake_case.** Unlike `PAIR_IDENTITY` (which is snake_case), the HELLO/ACK frames use camelCase field names. See §2. 5. **The browser does not yet implement the code-pairing path at all.** `chirp-handshake.ts` only implements the *audio chirp* NK flow. The `PEACH_HELLO` / `PEACH_HELLO_ACK` exchange and 6-digit `deriveCode` are desktop-only and currently have **no** browser counterpart (`S/src/pairing.zig:16-23`, `S/src/noise_nk.zig:9-12`). The desktop pairing client is net-new browser code (out of scope for Changes 1 & 2, pinned here so downstream passes build the right thing). --- ## 1. Ports | Purpose | Port | Source | | --- | --- | --- | | **Pairing server** (only while `peach-sync pair` runs) | **49142** | `S/src/pairing.zig:36` `pub const PAIRING_PORT: u16 = 49142;`; `S/PROJECT.md:14` | | **Sync server** (responder, always listening) | **8765** | `S/PROJECT.md:13`; `S/README.md:30`; announced by the helper as `ws_port: 8765` in its `PAIR_IDENTITY` (`S/src/pairing.zig:179`) | - Brief's claim of pairing port **49142 is CORRECT** (verified `pairing.zig:36`). - Brief's claim of default sync port **8765 is CORRECT** (verified `PROJECT.md:13`). - **`ws_port` semantics:** it is the peer's WebSocket *sync* port (phase-2), carried inside the encrypted `PAIR_IDENTITY` frame and cached on the paired device record (`B/src/shared/sync-types.ts:99-104`). The desktop helper always announces `8765` (`pairing.zig:179`). Mobile hosts instead pick a random high port. `ws_port` is **optional** on the wire (`pairing.zig:53`, `chirp-handshake.ts:60`). - **Transport:** the pairing port speaks **WebSocket over TCP** (HTTP upgrade, binary frames only, one Noise frame per WS message) — `S/src/websocket.zig:1-4`, used by the pairing server at `S/src/pairing.zig:92`. Client frames must be masked; server frames are never masked (`websocket.zig:136-137,274`). --- ## 2. PEACH_HELLO / PEACH_HELLO_ACK frame shapes (pre-handshake, PLAINTEXT) These two frames are exchanged **before** the Noise handshake, in the clear. ### `PEACH_HELLO` (browser → helper) Source struct: `S/src/pairing.zig:39` ```zig const PeachHello = struct { type: []const u8, code: []const u8 }; ``` Wire shape (JSON): ```json { "type": "PEACH_HELLO", "code": "<6-digit string>" } ``` - `type` MUST equal the literal `"PEACH_HELLO"` (`pairing.zig:146`). - `code` MUST equal the helper's current 6-digit code, **byte-for-byte as a string** (`pairing.zig:147` compares against `&code`, a `[6]u8`). Send the 6 ASCII digits, zero-padded, no separators. - Unknown fields are ignored (`PARSE_OPTS = .ignore_unknown_fields`, `pairing.zig:57`). ### `PEACH_HELLO_ACK` (helper → browser, on code match) Source struct: `S/src/pairing.zig:40` ```zig const PeachHelloAck = struct { type: []const u8, ephemeralPublicKeyHex: []const u8 }; ``` Wire shape (JSON): ```json { "type": "PEACH_HELLO_ACK", "ephemeralPublicKeyHex": "<64 hex chars>" } ``` - Field name is **`ephemeralPublicKeyHex`** (camelCase) — `pairing.zig:40,151`. - Value is the helper's pairing **ephemeral X25519 public key**, lowercase hex, 64 chars / 32 bytes (`pairing.zig:150` `bytesToHex(..., .lower)`). - This key is the **NK responder static `s`** the browser runs the handshake against (`pairing.zig:8-9,153-154`; `noise_nk.zig:3-11`). ### Code-mismatch / error behaviour - **There is no `PEACH_HELLO_ERROR` frame and no `PEACH_HELLO_OK` frame.** - On `type != "PEACH_HELLO"` or `code` mismatch the helper returns `false`, the connection is **closed** (`defer conn.close()`, `pairing.zig:117`) and the helper rotates to a new code and waits again (`pairing.zig:119-125`). - Browser contract: after sending `PEACH_HELLO`, expect either a `PEACH_HELLO_ACK` frame or a connection close. Connection close ⇒ wrong code. --- ## 3. Noise NK handshake - **Protocol:** `Noise_NK_25519_ChaChaPoly_BLAKE2s` (`S/src/noise_nk.zig:22`). - **Roles (code pairing): BROWSER = INITIATOR, helper (peach-sync) = RESPONDER.** Confirmed: `S/src/pairing.zig:3` ("helper is the server / NK responder"), `pairing.zig:153-154` constructs `noise_nk.Responder`. The NK static `s` is the helper's pairing **ephemeral** keypair (`pairing.zig:154`, `noise_nk.zig:9-11`). The browser learns `s.public` from the `PEACH_HELLO_ACK` and runs the NK initiator against it. > NOTE — this is the OPPOSITE role assignment from the existing audio-chirp > flow, where the *extension* is the NK responder > (`B/src/shared/chirp-handshake.ts:6-7,185-196`). For DESKTOP code pairing the > browser is the initiator. Downstream code MUST use `noiseNKInitiator` on the > browser side (`B/src/shared/noise-js.ts`), mirroring `runPairingInitiator`. - **Message pattern** (`noise_nk.zig:3-7`): ``` <- s (pre-message: both MixHash(responder static)) -> e, es (msg1: initiator/browser → responder/helper) <- e, ee (msg2: responder/helper → initiator/browser) ``` - msg1 = `e(32) || encrypted-payload`; helper expects it (`pairing.zig:155-158`, `noise_nk.zig:44-51`). Browser sends it via `noiseNKInitiator` writeMessage1. - msg2 = `e(32) || encrypted-payload`; helper sends empty payload `""` (`pairing.zig:159-161`, `noise_nk.zig:54-68`). - After the two messages both sides `Split` into a transport `Session` (`pairing.zig:162`, `noise_nk.zig:70-79`). - **Handshake hash is shared** between both ends (asserted in `noise_nk.zig:168,216`) — this is the SAS input. - **Cross-impl KAT (handshake hash):** responder static seed `0xc3` (32×), initiator ephemeral `fill(1)`, responder ephemeral `fill(2)` ⇒ `0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655` (`noise_nk.zig:191-221`; same KAT in `B/src/shared/noise-blake2s-kat.test.ts`). --- ## 4. PAIR_IDENTITY frame (over the NK transport, ENCRYPTED) After the handshake, both sides exchange ONE `PAIR_IDENTITY` JSON frame, Noise-encrypted. **Initiator (browser) sends first, then receives the helper's** (`pairing.zig:164-180`; mirrored by browser `runPairingInitiator` `chirp-handshake.ts:105-119`). Helper-accepted struct: `S/src/pairing.zig:46-55` ```zig const PairIdentity = struct { type: []const u8, device_id: []const u8, device_name: []const u8, device_type: []const u8, long_term_public_key_hex: []const u8, signing_public_key_hex: ?[]const u8 = null, // OPTIONAL ws_port: ?u16 = null, // OPTIONAL vault_id: ?[]const u8 = null, // OPTIONAL ← Change 1 target }; ``` Field-by-field truth: | Field | Required? | Source | | --- | --- | --- | | `type` | required, literal `"PAIR_IDENTITY"` | `pairing.zig:69` (decode in browser); helper sends it `pairing.zig:175` | | `device_id` | required | `pairing.zig:48` | | `device_name` | required | `pairing.zig:49` | | `device_type` | required, plain string | `pairing.zig:50` | | `long_term_public_key_hex` | required, 64 lowercase hex | `pairing.zig:51`; browser validates `/^[0-9a-f]{64}$/` `chirp-handshake.ts:75` | | `signing_public_key_hex` | **optional** (`?... = null`) | `pairing.zig:52` | | `ws_port` | **optional** (`?u16 = null`) | `pairing.zig:53` | | `vault_id` | **optional** (`?... = null`) | `pairing.zig:54` | ### Is `vault_id` GENUINELY optional in peach-sync? — YES. - Declared `?[]const u8 = null` (`pairing.zig:54`). - Unknown/absent tolerated: `PARSE_OPTS = .ignore_unknown_fields` (`pairing.zig:57,167`). - Consumed defensively: `peer.value.vault_id orelse ""` (`pairing.zig:209`) and only persisted if present and non-empty (`pairing.zig:212-214`). - The helper **does NOT send `vault_id` in its own** `PAIR_IDENTITY` (`pairing.zig:173-180` omits it; `emit_null_optional_fields = false` at `pairing.zig:222` drops nulls on serialize). It only has a vault_id once a browser sends one. So adding `vault_id` to the browser-sent frame is purely **additive and backwards-compatible**: existing chirp peers (mobile) that omit it keep working, and the helper accepts it when present. ### `device_type` string the helper announces: `"desktop"` - The helper sends `.device_type = "desktop"` in its own `PAIR_IDENTITY` (`S/src/pairing.zig:177`). This is a plain string; it maps onto the browser's existing `DeviceType` member `"desktop"` (`sync-types.ts:11`). --- ## 5. SAS derivation — VERIFIED byte-identical across both repos ### peach-sync (`S/src/pairing.zig:74-79`) ```zig pub fn deriveSas(handshake_hash: [32]u8) [6]u8 { const n = std.mem.readInt(u32, handshake_hash[0..4], .big) % 1_000_000; // → "{d:0>6}" (zero-padded 6 digits) } ``` ### peach-browser (`B/src/shared/chirp-handshake.ts:171-182`) ```ts export function derivePairingSAS(handshakeHash: Uint8Array): string { if (handshakeHash.length < 4) return "000000"; const n = ((handshakeHash[0] << 24) >>> 0) + (handshakeHash[1] << 16) + (handshakeHash[2] << 8) + handshakeHash[3]; return String((n >>> 0) % 1_000_000).padStart(6, "0"); } ``` **Formula (canonical):** `SAS = (BE_u32(handshakeHash[0..4]) mod 1_000_000)`, rendered as a zero-padded 6-digit decimal string. Big-endian byte order, modulus `1_000_000`, left-pad with `0` to width 6. ### KAT confirmation — **0fbab8cd → 895245 HOLDS.** ✅ - First 4 bytes of the KAT handshake hash are `0f ba b8 cd`. - `0x0fbab8cd = 263_895_245`. - `263_895_245 mod 1_000_000 = 895_245` → `"895245"` (6 digits, no padding needed). - This is **pinned in peach-sync's own test**: `S/src/pairing.zig:275-279` (`expectEqualStrings("895245", &deriveSas(nk_hash))` over the `0fbab8cd…` KAT). The browser's `derivePairingSAS` produces the identical value by construction (same BE-u32-mod-1e6). - The brief's claimed KAT (`0fbab8cd… → 895245`) is therefore **CORRECT**. > The browser ALREADY ships `derivePairingSAS` with the exact required signature > and algorithm (`chirp-handshake.ts:171`). **No new SAS function is needed** — > the desktop pairing client reuses `derivePairingSAS(session.handshakeHash)`, > exactly as the existing chirp flow does (`chirp-handshake.ts:122,223`). --- ## 6. PINNED TypeScript contract (downstream passes MUST use VERBATIM) > These names/shapes are the binding contract. The HELLO frames are net-new > (the brief's `PeachHelloResponse`/`PeachHelloError` are renamed to match the > ACTUAL `peach-sync` wire: `PeachHelloAck`, and there is **no** error frame). ```ts // --- pre-handshake plaintext frames (DESKTOP code pairing) ------------------- /** Sent BROWSER → helper on port 49142, before the Noise handshake. */ export interface PeachHelloFrame { type: "PEACH_HELLO"; /** The 6-digit code the user read off the desktop, as a string. */ code: string; } /** Helper → browser on code match. Field name is camelCase (matches the wire). */ export interface PeachHelloAck { type: "PEACH_HELLO_ACK"; /** Helper pairing ephemeral X25519 pubkey; 64 lowercase hex chars. * This is the NK responder static the browser runs the handshake against. */ ephemeralPublicKeyHex: string; } // NOTE: peach-sync sends NO error/OK frame. A wrong code closes the socket. // Downstream MUST treat "connection closed without PEACH_HELLO_ACK" as a code // mismatch. Do NOT introduce a PeachHelloError type — there is no wire peer // that produces it. // --- new pairing message-type string literals (for any local discriminants) - // "PEACH_HELLO" (browser → helper) // "PEACH_HELLO_ACK" (helper → browser) // (PAIR_IDENTITY already exists; SYNC_* unchanged.) // --- SAS (ALREADY EXISTS — reuse, do not re-add) ---------------------------- // src/shared/chirp-handshake.ts export function derivePairingSAS(handshakeHash: Uint8Array): string; // Brief's requested signature was `derivePairingSas(...)`; the EXISTING // exported name is `derivePairingSAS` (capital SAS). Reuse the existing name. // --- DeviceType (ALREADY INCLUDES "desktop" — no change needed) ------------- // src/shared/sync-types.ts:11 export type DeviceType = "extension" | "android" | "ios" | "desktop"; // --- PAIR_IDENTITY browser-sent frame: ADD vault_id (Change 1) -------------- // src/shared/chirp-handshake.ts interface PairingFrame 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; // ← ADDED, optional, hex string (Change 1) } ``` --- ## 7. Where vaultId comes from (for Change 1 threading) - `computeVaultId(masterKey: Uint8Array): Promise` → `BLAKE3(masterKey || "peach-vault-id")` sliced to 64 hex chars (`B/src/shared/sync-identity.ts:75-82`). It is the SYNC vault id already used in `SYNC_HELLO` (`sync_coordinator.zig:59,141`). - It is **async** and needs the in-memory `masterKey`. It is NOT currently available inside `runPairingInitiator`/`runPairingResponder` (`chirp-handshake.ts` has no master key / vault id in scope). Therefore Change 1 must **thread the vault_id in via `LocalIdentity`** (the existing identity struct passed into the handshake), computed by the caller that already holds the master key — see `01-vault-id-in-pair-identity.md` §"Threading".