// Change 1 — vault_id in the PAIR_IDENTITY frame. // // Relocated from drafts/DESKTOP-PAIRING/tests/01-pair-identity-vault-id.test.ts. // Asserts the `vaultId` field added to LocalIdentity is emitted into the // outbound PAIR_IDENTITY frame, and is omitted entirely when absent (so mobile // mobile peers and a locked vault stay backwards-compatible). // // Strategy: stand in as the NK responder ourselves and decrypt the first // transport frame the initiator sends — the browser is the NK initiator in the // browser-to-phone code-pairing flow, so this exercises the exact // outbound path. import { describe, expect, it } from "vitest"; import { generateKeyPair } from "@stablelib/x25519"; import { memoryTransportPair, runPairingInitiator, type LocalIdentity, } from "./pairing-handshake"; import { noiseNKResponder } from "./noise-js"; import { computeVaultId, deriveSyncIdentityKeypair, pubkeyToHex, } from "./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. */ 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: "android-001", device_name: "Pixel 8", device_type: "android", long_term_public_key_hex: "a".repeat(64), ws_port: 49142, }); 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 / mobile 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"); }); });