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
↓ peach-browser-ce171257.tar.gzatlas at this save
files changed2 files
src/shared/chirp-handshake.ts+13 −0src/shared/chirp-pair-identity-vault-id.test.ts+115 −0src/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 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+});