1# Desktop Helper Pairing — Ground Truth Contract23> **DRAFT ARTIFACT.** Pins the canonical wire contract for pairing the Peach4> browser extension with the `peach-sync` desktop helper. Every later drafting5> pass MUST conform to this file. Where the brief and the `peach-sync` source6> disagree, **the source wins** and the discrepancy is flagged below.7>8> All citations are `file:line` against the two repos as read on 2026-06-16.9> `S/` = `/Users/june/projects/peach-sync/`, `B/` = `/Users/june/projects/peach-browser/`.1011---1213## 0. Discrepancies vs the brief (READ FIRST)14151. **DeviceType / PairedDevice live in `sync-types.ts`, NOT `types.ts`.**16 The brief said DeviceType is "likely `src/shared/types.ts`". It is wrong.17 - `DeviceType` is defined at `B/src/shared/sync-types.ts:11`.18 - `B/src/shared/types.ts` has **no** `DeviceType` symbol.19 - `chirp-handshake.ts` imports it from `./sync-types` (`B/src/shared/chirp-handshake.ts:29`).20 - **`"desktop"` is ALREADY a member of `DeviceType`** (`sync-types.ts:11`) and21 already has a chirp byte tag `DEVICE_TYPE_DESKTOP = 0x04` (`sync-types.ts:19`),22 a `deviceTypeFromByte`/`deviceTypeToByte` case (`sync-types.ts:36,45`), and23 `chirp-payload.ts` already accepts byte `0x04` (`chirp-payload.ts:5,89`).24 **Change 2 is therefore largely a NO-OP for the type union itself** — see25 `02-desktop-device-type.md`. The brief's premise that `"desktop"` must be26 added is false against current source.27282. **The PEACH_HELLO acknowledgement frame is named `PEACH_HELLO_ACK`, NOT29 `PEACH_HELLO_OK`.** The brief proposed `PEACH_HELLO_OK` / `PEACH_HELLO_ERROR`.30 The source sends `PEACH_HELLO_ACK` and has **no** `PEACH_HELLO_OK` and **no**31 `PEACH_HELLO_ERROR` frame at all. See §2.32333. **There is no `PEACH_HELLO_ERROR` / error frame on code mismatch.** On a wrong34 code the helper **silently closes the connection and rotates the code**35 (`S/src/pairing.zig:144-147,119-125`). It does not send an error frame. A36 downstream browser implementation must treat connection close / no `*_ACK` as37 "wrong code", not wait for an error frame.38394. **The `PEACH_HELLO_ACK` payload field is `ephemeralPublicKeyHex`40 (camelCase), NOT snake_case.** Unlike `PAIR_IDENTITY` (which is snake_case),41 the HELLO/ACK frames use camelCase field names. See §2.42435. **The browser does not yet implement the code-pairing path at all.**44 `chirp-handshake.ts` only implements the *audio chirp* NK flow. The45 `PEACH_HELLO` / `PEACH_HELLO_ACK` exchange and 6-digit `deriveCode` are46 desktop-only and currently have **no** browser counterpart47 (`S/src/pairing.zig:16-23`, `S/src/noise_nk.zig:9-12`). The desktop pairing48 client is net-new browser code (out of scope for Changes 1 & 2, pinned here49 so downstream passes build the right thing).5051---5253## 1. Ports5455| Purpose | Port | Source |56| --- | --- | --- |57| **Pairing server** (only while `peach-sync pair` runs) | **49142** | `S/src/pairing.zig:36` `pub const PAIRING_PORT: u16 = 49142;`; `S/PROJECT.md:14` |58| **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`) |5960- Brief's claim of pairing port **49142 is CORRECT** (verified `pairing.zig:36`).61- Brief's claim of default sync port **8765 is CORRECT** (verified `PROJECT.md:13`).62- **`ws_port` semantics:** it is the peer's WebSocket *sync* port (phase-2),63 carried inside the encrypted `PAIR_IDENTITY` frame and cached on the paired64 device record (`B/src/shared/sync-types.ts:99-104`). The desktop helper always65 announces `8765` (`pairing.zig:179`). Mobile hosts instead pick a random high66 port. `ws_port` is **optional** on the wire (`pairing.zig:53`,67 `chirp-handshake.ts:60`).6869- **Transport:** the pairing port speaks **WebSocket over TCP** (HTTP upgrade,70 binary frames only, one Noise frame per WS message) — `S/src/websocket.zig:1-4`,71 used by the pairing server at `S/src/pairing.zig:92`. Client frames must be72 masked; server frames are never masked (`websocket.zig:136-137,274`).7374---7576## 2. PEACH_HELLO / PEACH_HELLO_ACK frame shapes (pre-handshake, PLAINTEXT)7778These two frames are exchanged **before** the Noise handshake, in the clear.7980### `PEACH_HELLO` (browser → helper)81Source struct: `S/src/pairing.zig:39`82```zig83const PeachHello = struct { type: []const u8, code: []const u8 };84```85Wire shape (JSON):86```json87{ "type": "PEACH_HELLO", "code": "<6-digit string>" }88```89- `type` MUST equal the literal `"PEACH_HELLO"` (`pairing.zig:146`).90- `code` MUST equal the helper's current 6-digit code, **byte-for-byte as a91 string** (`pairing.zig:147` compares against `&code`, a `[6]u8`). Send the 692 ASCII digits, zero-padded, no separators.93- Unknown fields are ignored (`PARSE_OPTS = .ignore_unknown_fields`, `pairing.zig:57`).9495### `PEACH_HELLO_ACK` (helper → browser, on code match)96Source struct: `S/src/pairing.zig:40`97```zig98const PeachHelloAck = struct { type: []const u8, ephemeralPublicKeyHex: []const u8 };99```100Wire shape (JSON):101```json102{ "type": "PEACH_HELLO_ACK", "ephemeralPublicKeyHex": "<64 hex chars>" }103```104- Field name is **`ephemeralPublicKeyHex`** (camelCase) — `pairing.zig:40,151`.105- Value is the helper's pairing **ephemeral X25519 public key**, lowercase hex,106 64 chars / 32 bytes (`pairing.zig:150` `bytesToHex(..., .lower)`).107- This key is the **NK responder static `s`** the browser runs the handshake108 against (`pairing.zig:8-9,153-154`; `noise_nk.zig:3-11`).109110### Code-mismatch / error behaviour111- **There is no `PEACH_HELLO_ERROR` frame and no `PEACH_HELLO_OK` frame.**112- On `type != "PEACH_HELLO"` or `code` mismatch the helper returns `false`,113 the connection is **closed** (`defer conn.close()`, `pairing.zig:117`) and the114 helper rotates to a new code and waits again (`pairing.zig:119-125`).115- Browser contract: after sending `PEACH_HELLO`, expect either a116 `PEACH_HELLO_ACK` frame or a connection close. Connection close ⇒ wrong code.117118---119120## 3. Noise NK handshake121122- **Protocol:** `Noise_NK_25519_ChaChaPoly_BLAKE2s` (`S/src/noise_nk.zig:22`).123- **Roles (code pairing): BROWSER = INITIATOR, helper (peach-sync) = RESPONDER.**124 Confirmed: `S/src/pairing.zig:3` ("helper is the server / NK responder"),125 `pairing.zig:153-154` constructs `noise_nk.Responder`. The NK static `s` is the126 helper's pairing **ephemeral** keypair (`pairing.zig:154`,127 `noise_nk.zig:9-11`). The browser learns `s.public` from the128 `PEACH_HELLO_ACK` and runs the NK initiator against it.129130 > NOTE — this is the OPPOSITE role assignment from the existing audio-chirp131 > flow, where the *extension* is the NK responder132 > (`B/src/shared/chirp-handshake.ts:6-7,185-196`). For DESKTOP code pairing the133 > browser is the initiator. Downstream code MUST use `noiseNKInitiator` on the134 > browser side (`B/src/shared/noise-js.ts`), mirroring `runPairingInitiator`.135136- **Message pattern** (`noise_nk.zig:3-7`):137 ```138 <- s (pre-message: both MixHash(responder static))139 -> e, es (msg1: initiator/browser → responder/helper)140 <- e, ee (msg2: responder/helper → initiator/browser)141 ```142 - msg1 = `e(32) || encrypted-payload`; helper expects it143 (`pairing.zig:155-158`, `noise_nk.zig:44-51`). Browser sends it via144 `noiseNKInitiator` writeMessage1.145 - msg2 = `e(32) || encrypted-payload`; helper sends empty payload `""`146 (`pairing.zig:159-161`, `noise_nk.zig:54-68`).147- After the two messages both sides `Split` into a transport `Session`148 (`pairing.zig:162`, `noise_nk.zig:70-79`).149- **Handshake hash is shared** between both ends (asserted in150 `noise_nk.zig:168,216`) — this is the SAS input.151- **Cross-impl KAT (handshake hash):** responder static seed `0xc3` (32×),152 initiator ephemeral `fill(1)`, responder ephemeral `fill(2)` ⇒153 `0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655`154 (`noise_nk.zig:191-221`; same KAT in `B/src/shared/noise-blake2s-kat.test.ts`).155156---157158## 4. PAIR_IDENTITY frame (over the NK transport, ENCRYPTED)159160After the handshake, both sides exchange ONE `PAIR_IDENTITY` JSON frame,161Noise-encrypted. **Initiator (browser) sends first, then receives the helper's**162(`pairing.zig:164-180`; mirrored by browser `runPairingInitiator`163`chirp-handshake.ts:105-119`).164165Helper-accepted struct: `S/src/pairing.zig:46-55`166```zig167const PairIdentity = struct {168 type: []const u8,169 device_id: []const u8,170 device_name: []const u8,171 device_type: []const u8,172 long_term_public_key_hex: []const u8,173 signing_public_key_hex: ?[]const u8 = null, // OPTIONAL174 ws_port: ?u16 = null, // OPTIONAL175 vault_id: ?[]const u8 = null, // OPTIONAL ← Change 1 target176};177```178179Field-by-field truth:180| Field | Required? | Source |181| --- | --- | --- |182| `type` | required, literal `"PAIR_IDENTITY"` | `pairing.zig:69` (decode in browser); helper sends it `pairing.zig:175` |183| `device_id` | required | `pairing.zig:48` |184| `device_name` | required | `pairing.zig:49` |185| `device_type` | required, plain string | `pairing.zig:50` |186| `long_term_public_key_hex` | required, 64 lowercase hex | `pairing.zig:51`; browser validates `/^[0-9a-f]{64}$/` `chirp-handshake.ts:75` |187| `signing_public_key_hex` | **optional** (`?... = null`) | `pairing.zig:52` |188| `ws_port` | **optional** (`?u16 = null`) | `pairing.zig:53` |189| `vault_id` | **optional** (`?... = null`) | `pairing.zig:54` |190191### Is `vault_id` GENUINELY optional in peach-sync? — YES.192- Declared `?[]const u8 = null` (`pairing.zig:54`).193- Unknown/absent tolerated: `PARSE_OPTS = .ignore_unknown_fields` (`pairing.zig:57,167`).194- Consumed defensively: `peer.value.vault_id orelse ""` (`pairing.zig:209`) and195 only persisted if present and non-empty (`pairing.zig:212-214`).196- The helper **does NOT send `vault_id` in its own** `PAIR_IDENTITY`197 (`pairing.zig:173-180` omits it; `emit_null_optional_fields = false` at198 `pairing.zig:222` drops nulls on serialize). It only has a vault_id once a199 browser sends one. So adding `vault_id` to the browser-sent frame is purely200 **additive and backwards-compatible**: existing chirp peers (mobile) that omit201 it keep working, and the helper accepts it when present.202203### `device_type` string the helper announces: `"desktop"`204- The helper sends `.device_type = "desktop"` in its own `PAIR_IDENTITY`205 (`S/src/pairing.zig:177`). This is a plain string; it maps onto the browser's206 existing `DeviceType` member `"desktop"` (`sync-types.ts:11`).207208---209210## 5. SAS derivation — VERIFIED byte-identical across both repos211212### peach-sync (`S/src/pairing.zig:74-79`)213```zig214pub fn deriveSas(handshake_hash: [32]u8) [6]u8 {215 const n = std.mem.readInt(u32, handshake_hash[0..4], .big) % 1_000_000;216 // → "{d:0>6}" (zero-padded 6 digits)217}218```219220### peach-browser (`B/src/shared/chirp-handshake.ts:171-182`)221```ts222export function derivePairingSAS(handshakeHash: Uint8Array): string {223 if (handshakeHash.length < 4) return "000000";224 const n = ((handshakeHash[0] << 24) >>> 0) + (handshakeHash[1] << 16)225 + (handshakeHash[2] << 8) + handshakeHash[3];226 return String((n >>> 0) % 1_000_000).padStart(6, "0");227}228```229230**Formula (canonical):** `SAS = (BE_u32(handshakeHash[0..4]) mod 1_000_000)`,231rendered as a zero-padded 6-digit decimal string. Big-endian byte order, modulus232`1_000_000`, left-pad with `0` to width 6.233234### KAT confirmation — **0fbab8cd → 895245 HOLDS.** ✅235- First 4 bytes of the KAT handshake hash are `0f ba b8 cd`.236- `0x0fbab8cd = 263_895_245`.237- `263_895_245 mod 1_000_000 = 895_245` → `"895245"` (6 digits, no padding needed).238- This is **pinned in peach-sync's own test**: `S/src/pairing.zig:275-279`239 (`expectEqualStrings("895245", &deriveSas(nk_hash))` over the240 `0fbab8cd…` KAT). The browser's `derivePairingSAS` produces the identical value241 by construction (same BE-u32-mod-1e6).242- The brief's claimed KAT (`0fbab8cd… → 895245`) is therefore **CORRECT**.243244> The browser ALREADY ships `derivePairingSAS` with the exact required signature245> and algorithm (`chirp-handshake.ts:171`). **No new SAS function is needed** —246> the desktop pairing client reuses `derivePairingSAS(session.handshakeHash)`,247> exactly as the existing chirp flow does (`chirp-handshake.ts:122,223`).248249---250251## 6. PINNED TypeScript contract (downstream passes MUST use VERBATIM)252253> These names/shapes are the binding contract. The HELLO frames are net-new254> (the brief's `PeachHelloResponse`/`PeachHelloError` are renamed to match the255> ACTUAL `peach-sync` wire: `PeachHelloAck`, and there is **no** error frame).256257```ts258259260261export interface PeachHelloFrame {262 type: "PEACH_HELLO";263 264 code: string;265}266267268export interface PeachHelloAck {269 type: "PEACH_HELLO_ACK";270 271272 ephemeralPublicKeyHex: string;273}274275276277278279280281282283284285286287export function derivePairingSAS(handshakeHash: Uint8Array): string;288289290291292293export type DeviceType = "extension" | "android" | "ios" | "desktop";294295296297interface PairingFrame {298 type: "PAIR_IDENTITY";299 device_id: string;300 device_name: string;301 device_type: DeviceType;302 long_term_public_key_hex: string;303 signing_public_key_hex?: string;304 ws_port?: number;305 vault_id?: string; 306}307```308309---310311## 7. Where vaultId comes from (for Change 1 threading)312313- `computeVaultId(masterKey: Uint8Array): Promise<string>` →314 `BLAKE3(masterKey || "peach-vault-id")` sliced to 64 hex chars315 (`B/src/shared/sync-identity.ts:75-82`). It is the SYNC vault id already used316 in `SYNC_HELLO` (`sync_coordinator.zig:59,141`).317- It is **async** and needs the in-memory `masterKey`. It is NOT currently318 available inside `runPairingInitiator`/`runPairingResponder`319 (`chirp-handshake.ts` has no master key / vault id in scope). Therefore Change 1320 must **thread the vault_id in via `LocalIdentity`** (the existing identity321 struct passed into the handshake), computed by the caller that already holds322 the master key — see `01-vault-id-in-pair-identity.md` §"Threading".