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
4 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 −0
src/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).

view file

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

view file

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

view file

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+});

view file