ce1712575251

feat: add vault_id to PAIR_IDENTITY frame for desktop helper pairing (v4.0.2)

time
2026-06-17 12:19 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
desktop helper pairing integration
parent
9d352e0795f8
2 files src/shared/chirp-handshake.ts+13 −0src/shared/chirp-pair-identity-vault-id.test.ts+115 −0
src/shared/chirp-handshake.ts+13 −0
@@ -48,6 +48,14 @@4848   signingPublicKeyHex?: string;4949   /** Server-only field — Android/iOS/desktop sync hosts announce their port. */5050   wsPort?: number;51+  /**52+   * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the53+   * outbound PAIR_IDENTITY so a desktop helper can record which vault it54+   * paired to. Optional: the caller omits it when the vault is locked (no55+   * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE56+   * at sync-identity.ts:62 (PCH-02-006).57+   */58+  vaultId?: string;5159 }5260 5361 interface PairingFrame {@@ -58,6 +66,9 @@5866   long_term_public_key_hex: string;5967   signing_public_key_hex?: string;6068   ws_port?: number;69+  // Optional: present only when the sender knows its vault id. peach-sync70+  // treats this as optional (pairing.zig:54); chirp mobile peers omit it.71+  vault_id?: string;6172 }6273 6374 function encodeFrame(frame: PairingFrame): Uint8Array {@@ -111,6 +122,7 @@111122     long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,112123     signing_public_key_hex: ourIdentity.signingPublicKeyHex,113124     ws_port: ourIdentity.wsPort,125+    vault_id: ourIdentity.vaultId,114126   });115127   await transport.send(session.encrypt(ourFrame));116128 @@ -214,6 +226,7 @@214226     long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,215227     signing_public_key_hex: ourIdentity.signingPublicKeyHex,216228     ws_port: ourIdentity.wsPort,229+    vault_id: ourIdentity.vaultId,217230   });218231   await transport.send(session.encrypt(ourFrame));219232 

view file

src/shared/chirp-pair-identity-vault-id.test.ts+115 −0
@@ -0,0 +1,115 @@1+// Change 1 — vault_id in the PAIR_IDENTITY frame.2+//3+// Relocated from drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts.4+// Asserts the `vaultId` field added to LocalIdentity is emitted into the5+// outbound PAIR_IDENTITY frame, and is omitted entirely when absent (so chirp6+// mobile peers and a locked vault stay backwards-compatible).7+//8+// Strategy: stand in as the NK responder ourselves and decrypt the first9+// transport frame the initiator sends — the browser is the NK initiator in the10+// desktop code-pairing flow (_GROUND-TRUTH §3), so this exercises the exact11+// outbound path.12+13+import { describe, expect, it } from "vitest";14+import { generateKeyPair } from "@stablelib/x25519";15+import {16+  memoryTransportPair,17+  runPairingInitiator,18+  type LocalIdentity,19+} from "./chirp-handshake";20+import { noiseNKResponder } from "./noise-js";21+import {22+  computeVaultId,23+  deriveSyncIdentityKeypair,24+  pubkeyToHex,25+} from "./sync-identity";26+27+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));28+29+async function identityFor(30+  deviceId: string,31+  deviceName: string,32+  type: LocalIdentity["deviceType"],33+  opts: { withVaultId?: boolean } = {},34+): Promise<LocalIdentity> {35+  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);36+  return {37+    deviceId,38+    deviceName,39+    deviceType: type,40+    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),41+    signingPublicKeyHex: "b".repeat(64),42+    vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined,43+  };44+}45+46+/**47+ * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the48+ * responder ourselves and decrypting the first transport frame.49+ */50+async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {51+  const respEphemeral = generateKeyPair();52+  const [t1, t2] = memoryTransportPair();53+54+  let captured: Record<string, unknown> | undefined;55+56+  const responder = (async () => {57+    const session = await noiseNKResponder(58+      respEphemeral.secretKey,59+      respEphemeral.publicKey,60+      t2.send,61+      t2.receive,62+    );63+    // Initiator sends its PAIR_IDENTITY first.64+    const cipher = await t2.receive();65+    const plain = session.decrypt(cipher);66+    captured = JSON.parse(new TextDecoder().decode(plain));67+    // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.68+    const back = JSON.stringify({69+      type: "PAIR_IDENTITY",70+      device_id: "desktop-001",71+      device_name: "peach-desktop",72+      device_type: "desktop",73+      long_term_public_key_hex: "a".repeat(64),74+      ws_port: 8765,75+    });76+    await t2.send(session.encrypt(new TextEncoder().encode(back)));77+  })();78+79+  await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);80+  await responder;81+  if (!captured) throw new Error("did not capture initiator frame");82+  return captured;83+}84+85+describe("PAIR_IDENTITY vault_id (Change 1)", () => {86+  it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => {87+    const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true });88+    const frame = await captureInitiatorFrame(initIdent);89+90+    expect(frame.type).toBe("PAIR_IDENTITY");91+    expect(frame).toHaveProperty("vault_id");92+    expect(typeof frame.vault_id).toBe("string");93+  });94+95+  it("vault_id is a non-empty 64-char lowercase hex string", async () => {96+    const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true });97+    const frame = await captureInitiatorFrame(initIdent);98+99+    const vaultId = frame.vault_id as string;100+    expect(vaultId.length).toBeGreaterThan(0);101+    // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars.102+    expect(vaultId).toMatch(/^[0-9a-f]{64}$/);103+    // Must equal the canonical computeVaultId output for the same master key.104+    expect(vaultId).toBe(await computeVaultId(MASTER));105+  });106+107+  it("omits vault_id entirely when the identity has no vaultId (locked vault / chirp peer)", async () => {108+    const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });109+    const frame = await captureInitiatorFrame(initIdent);110+111+    // JSON.stringify drops undefined keys — backwards-compatible with peers112+    // (and peach-sync) that treat vault_id as optional.113+    expect(frame).not.toHaveProperty("vault_id");114+  });115+});

view file