4b3171f697b0
add DESKTOP-PAIRING ground-truth contract + Change 1/2 drafts — verify peach-sync wire (port 49142, PEACH_HELLO_ACK not _OK, no error frame, browser=NK initiator, SAS 0fbab8cd→895245 holds, vault_id genuinely optional); draft vault_id-in-PAIR_IDENTITY patch + test; flag Change 2 as no-op (desktop already in DeviceType)
- time
- 2026-06-17 01:34 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- desktop helper pairing drafts
- parent
- 0755f29c5e2b
↓ peach-browser-4b3171f6.tar.gzatlas at this save
files changed4 files
src/drafts/DESKTOP-PAIRING/01-vault-id-in-pair-identity.md+151 −0src/drafts/DESKTOP-PAIRING/02-desktop-device-type.md+66 −0src/drafts/DESKTOP-PAIRING/_GROUND-TRUTH.md+322 −0src/drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts+127 −0src/drafts/DESKTOP-PAIRING/01-vault-id-in-pair-identity.md+151 −0
@@ -0,0 +1,151 @@1+# Change 1 — Add `vault_id` to the PAIR_IDENTITY frame2+3+> **DRAFT.** Conforms to `_GROUND-TRUTH.md`. Target file:4+> `/Users/june/projects/peach-browser/src/shared/chirp-handshake.ts`.5+6+## Goal7+Have the extension **send** `vault_id` in the `PAIR_IDENTITY` frame so the8+desktop helper can persist which vault it was paired to. peach-sync already9+accepts `vault_id` as an **optional** field10+(`peach-sync/src/pairing.zig:54`, tolerated/ignored when absent), so this is11+purely additive and backwards-compatible.12+13+## Backwards-compatibility statement14+- `vault_id` is `?[]const u8 = null` in peach-sync and parsed with15+ `ignore_unknown_fields` (`pairing.zig:54,57,167`); it is consumed defensively16+ (`orelse ""`, persisted only when non-empty — `pairing.zig:209,212-214`).17+- Existing chirp peers (mobile Android/iOS) that **omit** `vault_id` keep18+ working unchanged — the field is optional on both the TS interface and the19+ helper struct.20+- The helper does not send `vault_id` back (`pairing.zig:173-180`), so the21+ extension's `decodeFrame` does not require it on receive. The patch below only22+ affects the OUTBOUND frame.23+24+## Privacy consideration (carry the existing note forward)25+`vault_id` is the same stable, LAN-broadcast identifier documented at26+`src/shared/sync-identity.ts:62-74` (PRIVACY NOTE / PCH-02-006): it is stable27+across sessions and lets a passive LAN observer link every device sharing a28+vault. Putting it in `PAIR_IDENTITY` keeps it inside the **encrypted** NK29+transport, so this change does not add network exposure beyond what mDNS sync30+discovery already leaks. The patch comment references that note rather than31+restating it.32+33+## Threading: where `vault_id` enters the handshake34+`computeVaultId` is async and needs the in-memory `masterKey`35+(`sync-identity.ts:75-82`), neither of which is in scope inside36+`runPairingInitiator`/`runPairingResponder`. The clean seam is the existing37+`LocalIdentity` argument already passed into both functions: add an optional38+`vaultId` field, computed by the caller that already holds the master key (the39+pairing-flow entry point in the popup/background that builds `LocalIdentity`).40+No master key is introduced into `chirp-handshake.ts`.41+42+- Caller obligation (documented, not patched here): the code that constructs43+ `LocalIdentity` for pairing must call44+ `vaultId = await computeVaultId(masterKey)` (`sync-identity.ts:75`) and set it45+ on the identity. If the caller has no master key (locked vault), it leaves46+ `vaultId` undefined and the field is omitted — still valid.47+48+---49+50+## Patch (apply to `src/shared/chirp-handshake.ts`)51+52+### Hunk A — extend `LocalIdentity` with optional `vaultId`53+Context: lines 43-51.54+```diff55+ export interface LocalIdentity {56+ deviceId: string;57+ deviceName: string;58+ deviceType: DeviceType;59+ longTermPublicKeyHex: string;60+ signingPublicKeyHex?: string;61+ /** Server-only field — Android/iOS/desktop sync hosts announce their port. */62+ wsPort?: number;63++ /**64++ * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the65++ * outbound PAIR_IDENTITY so a desktop helper can record which vault it66++ * paired to. Optional: the caller omits it when the vault is locked (no67++ * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE68++ * at sync-identity.ts:62 (PCH-02-006).69++ */70++ vaultId?: string;71+ }72+```73+74+### Hunk B — add `vault_id` to the `PairingFrame` interface75+Context: lines 53-61.76+```diff77+ interface PairingFrame {78+ type: "PAIR_IDENTITY";79+ device_id: string;80+ device_name: string;81+ device_type: DeviceType;82+ long_term_public_key_hex: string;83+ signing_public_key_hex?: string;84+ ws_port?: number;85++ // Optional: present only when the sender knows its vault id. peach-sync86++ // treats this as optional (pairing.zig:54); chirp mobile peers omit it.87++ vault_id?: string;88+ }89+```90+91+### Hunk C — emit `vault_id` from the INITIATOR frame92+Context: lines 106-114 (`runPairingInitiator`).93+```diff94+ const ourFrame = encodeFrame({95+ type: "PAIR_IDENTITY",96+ device_id: ourIdentity.deviceId,97+ device_name: ourIdentity.deviceName,98+ device_type: ourIdentity.deviceType,99+ long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,100+ signing_public_key_hex: ourIdentity.signingPublicKeyHex,101+ ws_port: ourIdentity.wsPort,102++ vault_id: ourIdentity.vaultId,103+ });104+```105+106+### Hunk D — emit `vault_id` from the RESPONDER frame107+Context: lines 209-217 (`runPairingResponder`).108+```diff109+ const ourFrame = encodeFrame({110+ type: "PAIR_IDENTITY",111+ device_id: ourIdentity.deviceId,112+ device_name: ourIdentity.deviceName,113+ device_type: ourIdentity.deviceType,114+ long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,115+ signing_public_key_hex: ourIdentity.signingPublicKeyHex,116+ ws_port: ourIdentity.wsPort,117++ vault_id: ourIdentity.vaultId,118+ });119+```120+121+> `encodeFrame` is `JSON.stringify` (line 63-65): when `vaultId` is `undefined`,122+> `JSON.stringify` **drops the key entirely**, so the omitted-field123+> backwards-compat behaviour is automatic — no extra guard needed.124+125+### (Optional) Hunk E — validate `vault_id` on decode if present126+`decodeFrame` (lines 67-85) currently ignores extra fields. The helper never127+sends `vault_id`, so receive-side validation is **not required** for desktop128+pairing. If a future symmetric flow has the peer send it, add a lenient guard:129+```diff130+ if (131+ parsed.signing_public_key_hex !== undefined &&132+ !/^[0-9a-f]{64}$/.test(parsed.signing_public_key_hex)133+ ) {134+ throw new Error("signing_public_key_hex is not 32-byte hex");135+ }136++ // vault_id, when present, is a 64-char lowercase hex BLAKE3 digest137++ // (computeVaultId). Lenient: only validate shape if the peer sent one.138++ if (parsed.vault_id !== undefined && !/^[0-9a-f]{64}$/.test(parsed.vault_id)) {139++ throw new Error("vault_id is not 32-byte hex");140++ }141+ return parsed as PairingFrame;142+```143+**Recommendation:** ship Hunks A–D only for the desktop-pairing milestone; defer144+Hunk E until a peer actually sends `vault_id`, to avoid over-constraining.145+146+## Notes for the implementer147+- `vault_id` format is the 64-char lowercase hex BLAKE3 output of148+ `computeVaultId` (`sync-identity.ts:28,81`). Keep it lowercase hex to match149+ `SYNC_HELLO`'s `vaultId` so the helper's later vault check150+ (`sync_coordinator.zig:141`) compares equal.151+- Do not log `vault_id`. It is the LAN-linkable id (privacy note above).src/drafts/DESKTOP-PAIRING/02-desktop-device-type.md+66 −0
@@ -0,0 +1,66 @@1+# Change 2 — Add `"desktop"` DeviceType2+3+> **DRAFT.** Conforms to `_GROUND-TRUTH.md`.4+5+## VERDICT: already done. This change is a NO-OP against current source.6+7+The brief assumed `"desktop"` was missing from `DeviceType` and that the type8+"likely" lives in `src/shared/types.ts`. **Both assumptions are wrong against the9+ground-truth source:**10+11+1. `DeviceType` is defined in **`src/shared/sync-types.ts:11`**, not `types.ts`12+ (`types.ts` has no `DeviceType` symbol). `chirp-handshake.ts:29` and13+ `paired-devices.ts:23` both import device types from `./sync-types`.14+15+2. `"desktop"` is **already a member**:16+ ```ts17+ // src/shared/sync-types.ts:1118+ export type DeviceType = "extension" | "android" | "ios" | "desktop";19+ ```20+21+3. The full desktop plumbing already exists:22+ - `DEVICE_TYPE_DESKTOP = 0x04` chirp byte tag (`sync-types.ts:19`)23+ - `DeviceTypeByte` includes it (`sync-types.ts:25`)24+ - `deviceTypeFromByte(0x04) → "desktop"` (`sync-types.ts:36`)25+ - `deviceTypeToByte("desktop") → 0x04` (`sync-types.ts:45`)26+ - `chirp-payload.ts` accepts byte `0x04` on the wire (`chirp-payload.ts:5,89`)27+28+4. peach-sync announces exactly this string: `.device_type = "desktop"`29+ (`peach-sync/src/pairing.zig:177`), which lands on the existing union member.30+31+**No edit to the type union is required.** If a downstream pass "adds" it again32+it will be a redundant no-op.33+34+---35+36+## What ACTUALLY needs verifying: the storage gate (`paired-devices.ts`)37+38+The real question is whether the paired-device registry rejects a `"desktop"`39+device. **It does not — there is no enum gate.**40+41+`upsertPairedDevice` (`src/background/paired-devices.ts:56-70`) validates only42+*presence*, never membership against the `DeviceType` union:43+```ts44+// paired-devices.ts:6045+if (!device.deviceType) throw new Error("device.deviceType is required");46+```47+It checks the field is truthy, not that it equals a known literal. `"desktop"`48+(or any non-empty string) passes. Storage round-trips it verbatim49+(`isPairedDevice`, `paired-devices.ts:174-180`, only checks `deviceId` is a50+non-empty string and `pairedAt` is a number — `deviceType` is not gated).51+52+`PairedDevice.deviceType` is `DeviceType | undefined` (`sync-types.ts:89`), so53+`"desktop"` is statically valid; nothing to extend.54+55+### Conclusion / action for implementer56+- **No code change for Change 2.** Optionally, add a regression test asserting a57+ `"desktop"` device upserts and round-trips through `paired-devices.ts`58+ (kept minimal; the registry already supports it).59+- If the brief's reviewer insists on a literal "edit", the only honest one is a60+ doc/comment — there is no missing union member and no gate to widen.61+62+## Discrepancy summary (for the report)63+- Brief said: add `"desktop"` to `DeviceType` in `src/shared/types.ts`.64+- Source truth: `"desktop"` already present in `src/shared/sync-types.ts:11`;65+ `types.ts` is the wrong file; the storage layer already accepts it. **Change is66+ unnecessary.**src/drafts/DESKTOP-PAIRING/_GROUND-TRUTH.md+322 −0
@@ -0,0 +1,322 @@1+# Desktop Helper Pairing — Ground Truth Contract2+3+> **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/`.10+11+---12+13+## 0. Discrepancies vs the brief (READ FIRST)14+15+1. **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.27+28+2. **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.32+33+3. **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.38+39+4. **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.42+43+5. **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).50+51+---52+53+## 1. Ports54+55+| 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`) |59+60+- 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`).68+69+- **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`).73+74+---75+76+## 2. PEACH_HELLO / PEACH_HELLO_ACK frame shapes (pre-handshake, PLAINTEXT)77+78+These two frames are exchanged **before** the Noise handshake, in the clear.79+80+### `PEACH_HELLO` (browser → helper)81+Source struct: `S/src/pairing.zig:39`82+```zig83+const PeachHello = struct { type: []const u8, code: []const u8 };84+```85+Wire 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`).94+95+### `PEACH_HELLO_ACK` (helper → browser, on code match)96+Source struct: `S/src/pairing.zig:40`97+```zig98+const PeachHelloAck = struct { type: []const u8, ephemeralPublicKeyHex: []const u8 };99+```100+Wire 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`).109+110+### 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.117+118+---119+120+## 3. Noise NK handshake121+122+- **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.129+130+ > 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`.135+136+- **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`).155+156+---157+158+## 4. PAIR_IDENTITY frame (over the NK transport, ENCRYPTED)159+160+After the handshake, both sides exchange ONE `PAIR_IDENTITY` JSON frame,161+Noise-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`).164+165+Helper-accepted struct: `S/src/pairing.zig:46-55`166+```zig167+const 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+```178+179+Field-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` |190+191+### 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.202+203+### `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`).207+208+---209+210+## 5. SAS derivation — VERIFIED byte-identical across both repos211+212+### peach-sync (`S/src/pairing.zig:74-79`)213+```zig214+pub 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+```219+220+### peach-browser (`B/src/shared/chirp-handshake.ts:171-182`)221+```ts222+export 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+```229+230+**Formula (canonical):** `SAS = (BE_u32(handshakeHash[0..4]) mod 1_000_000)`,231+rendered as a zero-padded 6-digit decimal string. Big-endian byte order, modulus232+`1_000_000`, left-pad with `0` to width 6.233+234+### 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**.243+244+> 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`).248+249+---250+251+## 6. PINNED TypeScript contract (downstream passes MUST use VERBATIM)252+253+> 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).256+257+```ts258+// --- pre-handshake plaintext frames (DESKTOP code pairing) -------------------259+260+/** Sent BROWSER → helper on port 49142, before the Noise handshake. */261+export interface PeachHelloFrame {262+ type: "PEACH_HELLO";263+ /** The 6-digit code the user read off the desktop, as a string. */264+ code: string;265+}266+267+/** Helper → browser on code match. Field name is camelCase (matches the wire). */268+export interface PeachHelloAck {269+ type: "PEACH_HELLO_ACK";270+ /** Helper pairing ephemeral X25519 pubkey; 64 lowercase hex chars.271+ * This is the NK responder static the browser runs the handshake against. */272+ ephemeralPublicKeyHex: string;273+}274+275+// NOTE: peach-sync sends NO error/OK frame. A wrong code closes the socket.276+// Downstream MUST treat "connection closed without PEACH_HELLO_ACK" as a code277+// mismatch. Do NOT introduce a PeachHelloError type — there is no wire peer278+// that produces it.279+280+// --- new pairing message-type string literals (for any local discriminants) -281+// "PEACH_HELLO" (browser → helper)282+// "PEACH_HELLO_ACK" (helper → browser)283+// (PAIR_IDENTITY already exists; SYNC_* unchanged.)284+285+// --- SAS (ALREADY EXISTS — reuse, do not re-add) ----------------------------286+// src/shared/chirp-handshake.ts287+export function derivePairingSAS(handshakeHash: Uint8Array): string;288+// Brief's requested signature was `derivePairingSas(...)`; the EXISTING289+// exported name is `derivePairingSAS` (capital SAS). Reuse the existing name.290+291+// --- DeviceType (ALREADY INCLUDES "desktop" — no change needed) -------------292+// src/shared/sync-types.ts:11293+export type DeviceType = "extension" | "android" | "ios" | "desktop";294+295+// --- PAIR_IDENTITY browser-sent frame: ADD vault_id (Change 1) --------------296+// src/shared/chirp-handshake.ts interface PairingFrame297+interface 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; // ← ADDED, optional, hex string (Change 1)306+}307+```308+309+---310+311+## 7. Where vaultId comes from (for Change 1 threading)312+313+- `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".src/drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts+127 −0
@@ -0,0 +1,127 @@1+// DRAFT test spec for Change 1 — vault_id in the PAIR_IDENTITY frame.2+//3+// DO NOT RUN as-is from the drafts folder. This is the companion spec that4+// should land alongside the Change 1 patch as5+// src/shared/chirp-pair-identity-vault-id.test.ts6+// (or be merged into src/shared/chirp-handshake.test.ts). It assumes the7+// `vaultId` field has been added to LocalIdentity and emitted into the8+// outbound PAIR_IDENTITY frame per drafts/DESKTOP-PAIRING/01-...md.9+//10+// Style mirrors the existing src/shared/chirp-handshake.test.ts:11+// - vitest (describe/it/expect)12+// - identities via deriveSyncIdentityKeypair + pubkeyToHex13+// - memoryTransportPair for the in-memory Noise NK round-trip14+//15+// Strategy: rather than re-parse the encrypted wire bytes, we let the peer16+// RECEIVE our frame through the real handshake and assert on the PairedDevice17+// it learned — but PairedDevice currently has no vaultId getter, so we ALSO18+// intercept the raw decoded frame by spying on the peer's transport. To keep19+// this hermetic and not depend on internals, we drive a second initiator that20+// captures the plaintext it decrypts. See the inline note.21+22+import { describe, expect, it } from "vitest";23+import { generateKeyPair } from "@stablelib/x25519";24+import {25+ memoryTransportPair,26+ runPairingInitiator,27+ runPairingResponder,28+ type LocalIdentity,29+} from "../../shared/chirp-handshake";30+import { noiseNKResponder } from "../../shared/noise-js";31+import {32+ computeVaultId,33+ deriveSyncIdentityKeypair,34+ pubkeyToHex,35+} from "../../shared/sync-identity";36+37+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));38+39+async function identityFor(40+ deviceId: string,41+ deviceName: string,42+ type: LocalIdentity["deviceType"],43+ opts: { withVaultId?: boolean } = {},44+): Promise<LocalIdentity> {45+ const kp = deriveSyncIdentityKeypair(MASTER, deviceId);46+ return {47+ deviceId,48+ deviceName,49+ deviceType: type,50+ longTermPublicKeyHex: pubkeyToHex(kp.publicKey),51+ signingPublicKeyHex: "b".repeat(64),52+ vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined,53+ };54+}55+56+/**57+ * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the58+ * responder ourselves and decrypting the first transport frame. The browser is59+ * the NK initiator in the desktop code-pairing flow (_GROUND-TRUTH §3), so this60+ * exercises the exact outbound path.61+ */62+async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {63+ const respEphemeral = generateKeyPair();64+ const [t1, t2] = memoryTransportPair();65+66+ let captured: Record<string, unknown> | undefined;67+68+ const responder = (async () => {69+ const session = await noiseNKResponder(70+ respEphemeral.secretKey,71+ respEphemeral.publicKey,72+ t2.send,73+ t2.receive,74+ );75+ // Initiator sends its PAIR_IDENTITY first.76+ const cipher = await t2.receive();77+ const plain = session.decrypt(cipher);78+ captured = JSON.parse(new TextDecoder().decode(plain));79+ // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.80+ const back = JSON.stringify({81+ type: "PAIR_IDENTITY",82+ device_id: "desktop-001",83+ device_name: "peach-desktop",84+ device_type: "desktop",85+ long_term_public_key_hex: "a".repeat(64),86+ ws_port: 8765,87+ });88+ await t2.send(session.encrypt(new TextEncoder().encode(back)));89+ })();90+91+ await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);92+ await responder;93+ if (!captured) throw new Error("did not capture initiator frame");94+ return captured;95+}96+97+describe("PAIR_IDENTITY vault_id (Change 1)", () => {98+ it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => {99+ const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true });100+ const frame = await captureInitiatorFrame(initIdent);101+102+ expect(frame.type).toBe("PAIR_IDENTITY");103+ expect(frame).toHaveProperty("vault_id");104+ expect(typeof frame.vault_id).toBe("string");105+ });106+107+ it("vault_id is a non-empty 64-char lowercase hex string", async () => {108+ const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true });109+ const frame = await captureInitiatorFrame(initIdent);110+111+ const vaultId = frame.vault_id as string;112+ expect(vaultId.length).toBeGreaterThan(0);113+ // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars.114+ expect(vaultId).toMatch(/^[0-9a-f]{64}$/);115+ // Must equal the canonical computeVaultId output for the same master key.116+ expect(vaultId).toBe(await computeVaultId(MASTER));117+ });118+119+ it("omits vault_id entirely when the identity has no vaultId (locked vault / chirp peer)", async () => {120+ const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });121+ const frame = await captureInitiatorFrame(initIdent);122+123+ // JSON.stringify drops undefined keys — backwards-compatible with peers124+ // (and peach-sync) that treat vault_id as optional.125+ expect(frame).not.toHaveProperty("vault_id");126+ });127+});