pairing-handshake.vault-id.test.ts

4.3 KB · raw

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 mobile6// 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// browser-to-phone code-pairing flow, so this exercises the exact11// outbound path.1213import { describe, expect, it } from "vitest";14import { generateKeyPair } from "@stablelib/x25519";15import {16  memoryTransportPair,17  runPairingInitiator,18  type LocalIdentity,19} from "./pairing-handshake";20import { noiseNKResponder } from "./noise-js";21import {22  computeVaultId,23  deriveSyncIdentityKeypair,24  pubkeyToHex,25} from "./sync-identity";2627const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));2829async 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}4546/**47 * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the48 * responder ourselves and decrypting the first transport frame.49 */50async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {51  const respEphemeral = generateKeyPair();52  const [t1, t2] = memoryTransportPair();5354  let captured: Record<string, unknown> | undefined;5556  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: "android-001",71      device_name: "Pixel 8",72      device_type: "android",73      long_term_public_key_hex: "a".repeat(64),74      ws_port: 49142,75    });76    await t2.send(session.encrypt(new TextEncoder().encode(back)));77  })();7879  await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);80  await responder;81  if (!captured) throw new Error("did not capture initiator frame");82  return captured;83}8485describe("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);8990    expect(frame.type).toBe("PAIR_IDENTITY");91    expect(frame).toHaveProperty("vault_id");92    expect(typeof frame.vault_id).toBe("string");93  });9495  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);9899    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  });106107  it("omits vault_id entirely when the identity has no vaultId (locked vault / mobile peer)", async () => {108    const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });109    const frame = await captureInitiatorFrame(initIdent);110111    // 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});