_GROUND-TRUTH.md
Desktop Helper Pairing — Ground Truth Contract
DRAFT ARTIFACT. Pins the canonical wire contract for pairing the Peach browser extension with the
peach-syncdesktop helper. Every later drafting pass MUST conform to this file. Where the brief and thepeach-syncsource disagree, the source wins and the discrepancy is flagged below.All citations are
file:lineagainst 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)
-
DeviceType / PairedDevice live in
sync-types.ts, NOTtypes.ts. The brief said DeviceType is "likelysrc/shared/types.ts". It is wrong.DeviceTypeis defined atB/src/shared/sync-types.ts:11.B/src/shared/types.tshas noDeviceTypesymbol.chirp-handshake.tsimports it from./sync-types(B/src/shared/chirp-handshake.ts:29)."desktop"is ALREADY a member ofDeviceType(sync-types.ts:11) and already has a chirp byte tagDEVICE_TYPE_DESKTOP = 0x04(sync-types.ts:19), adeviceTypeFromByte/deviceTypeToBytecase (sync-types.ts:36,45), andchirp-payload.tsalready accepts byte0x04(chirp-payload.ts:5,89). Change 2 is therefore largely a NO-OP for the type union itself — see02-desktop-device-type.md. The brief's premise that"desktop"must be added is false against current source.
-
The PEACH_HELLO acknowledgement frame is named
PEACH_HELLO_ACK, NOTPEACH_HELLO_OK. The brief proposedPEACH_HELLO_OK/PEACH_HELLO_ERROR. The source sendsPEACH_HELLO_ACKand has noPEACH_HELLO_OKand noPEACH_HELLO_ERRORframe at all. See §2. -
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*_ACKas "wrong code", not wait for an error frame. -
The
PEACH_HELLO_ACKpayload field isephemeralPublicKeyHex(camelCase), NOT snake_case. UnlikePAIR_IDENTITY(which is snake_case), the HELLO/ACK frames use camelCase field names. See §2. -
The browser does not yet implement the code-pairing path at all.
chirp-handshake.tsonly implements the audio chirp NK flow. ThePEACH_HELLO/PEACH_HELLO_ACKexchange and 6-digitderiveCodeare 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_portsemantics: it is the peer's WebSocket sync port (phase-2), carried inside the encryptedPAIR_IDENTITYframe and cached on the paired device record (B/src/shared/sync-types.ts:99-104). The desktop helper always announces8765(pairing.zig:179). Mobile hosts instead pick a random high port.ws_portis 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 atS/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
const PeachHello = struct { type: []const u8, code: []const u8 };
Wire shape (JSON):
{ "type": "PEACH_HELLO", "code": "<6-digit string>" }
typeMUST equal the literal"PEACH_HELLO"(pairing.zig:146).codeMUST equal the helper's current 6-digit code, byte-for-byte as a string (pairing.zig:147compares 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
const PeachHelloAck = struct { type: []const u8, ephemeralPublicKeyHex: []const u8 };
Wire shape (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:150bytesToHex(..., .lower)). - This key is the NK responder static
sthe 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_ERRORframe and noPEACH_HELLO_OKframe. - On
type != "PEACH_HELLO"orcodemismatch the helper returnsfalse, 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 aPEACH_HELLO_ACKframe 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-154constructsnoise_nk.Responder. The NK staticsis the helper's pairing ephemeral keypair (pairing.zig:154,noise_nk.zig:9-11). The browser learnss.publicfrom thePEACH_HELLO_ACKand 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 usenoiseNKInitiatoron the browser side (B/src/shared/noise-js.ts), mirroringrunPairingInitiator. -
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 vianoiseNKInitiatorwriteMessage1. - msg2 =
e(32) || encrypted-payload; helper sends empty payload""(pairing.zig:159-161,noise_nk.zig:54-68).
- msg1 =
-
After the two messages both sides
Splitinto a transportSession(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 ephemeralfill(1), responder ephemeralfill(2)⇒0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655(noise_nk.zig:191-221; same KAT inB/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
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_idin its ownPAIR_IDENTITY(pairing.zig:173-180omits it;emit_null_optional_fields = falseatpairing.zig:222drops nulls on serialize). It only has a vault_id once a browser sends one. So addingvault_idto 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 ownPAIR_IDENTITY(S/src/pairing.zig:177). This is a plain string; it maps onto the browser's existingDeviceTypemember"desktop"(sync-types.ts:11).
5. SAS derivation — VERIFIED byte-identical across both repos
peach-sync (S/src/pairing.zig:74-79)
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)
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 the0fbab8cd…KAT). The browser'sderivePairingSASproduces the identical value by construction (same BE-u32-mod-1e6). - The brief's claimed KAT (
0fbab8cd… → 895245) is therefore CORRECT.
The browser ALREADY ships
derivePairingSASwith the exact required signature and algorithm (chirp-handshake.ts:171). No new SAS function is needed — the desktop pairing client reusesderivePairingSAS(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/PeachHelloErrorare renamed to match the ACTUALpeach-syncwire:PeachHelloAck, and there is no error frame).
// --- 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<string>→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 inSYNC_HELLO(sync_coordinator.zig:59,141).- It is async and needs the in-memory
masterKey. It is NOT currently available insiderunPairingInitiator/runPairingResponder(chirp-handshake.tshas no master key / vault id in scope). Therefore Change 1 must thread the vault_id in viaLocalIdentity(the existing identity struct passed into the handshake), computed by the caller that already holds the master key — see01-vault-id-in-pair-identity.md§"Threading".