// DRAFT test spec for Change 1 โ€” vault_id in the PAIR_IDENTITY frame. // // DO NOT RUN as-is from the drafts folder. This is the companion spec that // should land alongside the Change 1 patch as // src/shared/chirp-pair-identity-vault-id.test.ts // (or be merged into src/shared/chirp-handshake.test.ts). It assumes the // `vaultId` field has been added to LocalIdentity and emitted into the // outbound PAIR_IDENTITY frame per drafts/DESKTOP-PAIRING/01-...md. // // Style mirrors the existing src/shared/chirp-handshake.test.ts: // - vitest (describe/it/expect) // - identities via deriveSyncIdentityKeypair + pubkeyToHex // - memoryTransportPair for the in-memory Noise NK round-trip // // Strategy: rather than re-parse the encrypted wire bytes, we let the peer // RECEIVE our frame through the real handshake and assert on the PairedDevice // it learned โ€” but PairedDevice currently has no vaultId getter, so we ALSO // intercept the raw decoded frame by spying on the peer's transport. To keep // this hermetic and not depend on internals, we drive a second initiator that // captures the plaintext it decrypts. See the inline note. import { describe, expect, it } from "vitest"; import { generateKeyPair } from "@stablelib/x25519"; import { memoryTransportPair, runPairingInitiator, runPairingResponder, type LocalIdentity, } from "../../shared/chirp-handshake"; import { noiseNKResponder } from "../../shared/noise-js"; import { computeVaultId, deriveSyncIdentityKeypair, pubkeyToHex, } from "../../shared/sync-identity"; const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff)); async function identityFor( deviceId: string, deviceName: string, type: LocalIdentity["deviceType"], opts: { withVaultId?: boolean } = {}, ): Promise { const kp = deriveSyncIdentityKeypair(MASTER, deviceId); return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey), signingPublicKeyHex: "b".repeat(64), vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined, }; } /** * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the * responder ourselves and decrypting the first transport frame. The browser is * the NK initiator in the desktop code-pairing flow (_GROUND-TRUTH ยง3), so this * exercises the exact outbound path. */ async function captureInitiatorFrame(initIdent: LocalIdentity): Promise> { const respEphemeral = generateKeyPair(); const [t1, t2] = memoryTransportPair(); let captured: Record | undefined; const responder = (async () => { const session = await noiseNKResponder( respEphemeral.secretKey, respEphemeral.publicKey, t2.send, t2.receive, ); // Initiator sends its PAIR_IDENTITY first. const cipher = await t2.receive(); const plain = session.decrypt(cipher); captured = JSON.parse(new TextDecoder().decode(plain)); // Send a minimal valid PAIR_IDENTITY back so the initiator resolves. const back = JSON.stringify({ type: "PAIR_IDENTITY", device_id: "desktop-001", device_name: "peach-desktop", device_type: "desktop", long_term_public_key_hex: "a".repeat(64), ws_port: 8765, }); await t2.send(session.encrypt(new TextEncoder().encode(back))); })(); await runPairingInitiator(respEphemeral.publicKey, initIdent, t1); await responder; if (!captured) throw new Error("did not capture initiator frame"); return captured; } describe("PAIR_IDENTITY vault_id (Change 1)", () => { it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => { const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true }); const frame = await captureInitiatorFrame(initIdent); expect(frame.type).toBe("PAIR_IDENTITY"); expect(frame).toHaveProperty("vault_id"); expect(typeof frame.vault_id).toBe("string"); }); it("vault_id is a non-empty 64-char lowercase hex string", async () => { const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true }); const frame = await captureInitiatorFrame(initIdent); const vaultId = frame.vault_id as string; expect(vaultId.length).toBeGreaterThan(0); // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars. expect(vaultId).toMatch(/^[0-9a-f]{64}$/); // Must equal the canonical computeVaultId output for the same master key. expect(vaultId).toBe(await computeVaultId(MASTER)); }); it("omits vault_id entirely when the identity has no vaultId (locked vault / chirp peer)", async () => { const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false }); const frame = await captureInitiatorFrame(initIdent); // JSON.stringify drops undefined keys โ€” backwards-compatible with peers // (and peach-sync) that treat vault_id as optional. expect(frame).not.toHaveProperty("vault_id"); }); });