_GROUND-TRUTH.md

15.1 KB · raw

rendered · source

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)

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>" }

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>" }

Code-mismatch / error behaviour


3. Noise NK handshake


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.

device_type string the helper announces: "desktop"


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. ✅

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).

// --- 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)