01-pair-identity-vault-id.test.ts

5.0 KB · raw

1// DRAFT test spec for Change 1 — vault_id in the PAIR_IDENTITY frame.2//3// DO NOT RUN as-is from the drafts folder. This is the companion spec that4// should land alongside the Change 1 patch as5//   src/shared/chirp-pair-identity-vault-id.test.ts6// (or be merged into src/shared/chirp-handshake.test.ts). It assumes the7// `vaultId` field has been added to LocalIdentity and emitted into the8// outbound PAIR_IDENTITY frame per drafts/DESKTOP-PAIRING/01-...md.9//10// Style mirrors the existing src/shared/chirp-handshake.test.ts:11//   - vitest (describe/it/expect)12//   - identities via deriveSyncIdentityKeypair + pubkeyToHex13//   - memoryTransportPair for the in-memory Noise NK round-trip14//15// Strategy: rather than re-parse the encrypted wire bytes, we let the peer16// RECEIVE our frame through the real handshake and assert on the PairedDevice17// it learned — but PairedDevice currently has no vaultId getter, so we ALSO18// intercept the raw decoded frame by spying on the peer's transport. To keep19// this hermetic and not depend on internals, we drive a second initiator that20// captures the plaintext it decrypts. See the inline note.2122import { describe, expect, it } from "vitest";23import { generateKeyPair } from "@stablelib/x25519";24import {25  memoryTransportPair,26  runPairingInitiator,27  runPairingResponder,28  type LocalIdentity,29} from "../../shared/chirp-handshake";30import { noiseNKResponder } from "../../shared/noise-js";31import {32  computeVaultId,33  deriveSyncIdentityKeypair,34  pubkeyToHex,35} from "../../shared/sync-identity";3637const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));3839async function identityFor(40  deviceId: string,41  deviceName: string,42  type: LocalIdentity["deviceType"],43  opts: { withVaultId?: boolean } = {},44): Promise<LocalIdentity> {45  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);46  return {47    deviceId,48    deviceName,49    deviceType: type,50    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),51    signingPublicKeyHex: "b".repeat(64),52    vaultId: opts.withVaultId ? await computeVaultId(MASTER) : undefined,53  };54}5556/**57 * Capture the raw PAIR_IDENTITY JSON the INITIATOR sends, by standing in as the58 * responder ourselves and decrypting the first transport frame. The browser is59 * the NK initiator in the desktop code-pairing flow (_GROUND-TRUTH §3), so this60 * exercises the exact outbound path.61 */62async function captureInitiatorFrame(initIdent: LocalIdentity): Promise<Record<string, unknown>> {63  const respEphemeral = generateKeyPair();64  const [t1, t2] = memoryTransportPair();6566  let captured: Record<string, unknown> | undefined;6768  const responder = (async () => {69    const session = await noiseNKResponder(70      respEphemeral.secretKey,71      respEphemeral.publicKey,72      t2.send,73      t2.receive,74    );75    // Initiator sends its PAIR_IDENTITY first.76    const cipher = await t2.receive();77    const plain = session.decrypt(cipher);78    captured = JSON.parse(new TextDecoder().decode(plain));79    // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.80    const back = JSON.stringify({81      type: "PAIR_IDENTITY",82      device_id: "desktop-001",83      device_name: "peach-desktop",84      device_type: "desktop",85      long_term_public_key_hex: "a".repeat(64),86      ws_port: 8765,87    });88    await t2.send(session.encrypt(new TextEncoder().encode(back)));89  })();9091  await runPairingInitiator(respEphemeral.publicKey, initIdent, t1);92  await responder;93  if (!captured) throw new Error("did not capture initiator frame");94  return captured;95}9697describe("PAIR_IDENTITY vault_id (Change 1)", () => {98  it("PAIR_IDENTITY frame includes vault_id field when identity carries a vaultId", async () => {99    const initIdent = await identityFor("ext-001", "Firefox", "extension", { withVaultId: true });100    const frame = await captureInitiatorFrame(initIdent);101102    expect(frame.type).toBe("PAIR_IDENTITY");103    expect(frame).toHaveProperty("vault_id");104    expect(typeof frame.vault_id).toBe("string");105  });106107  it("vault_id is a non-empty 64-char lowercase hex string", async () => {108    const initIdent = await identityFor("ext-002", "Firefox", "extension", { withVaultId: true });109    const frame = await captureInitiatorFrame(initIdent);110111    const vaultId = frame.vault_id as string;112    expect(vaultId.length).toBeGreaterThan(0);113    // computeVaultId returns the full 32-byte BLAKE3 digest as 64 hex chars.114    expect(vaultId).toMatch(/^[0-9a-f]{64}$/);115    // Must equal the canonical computeVaultId output for the same master key.116    expect(vaultId).toBe(await computeVaultId(MASTER));117  });118119  it("omits vault_id entirely when the identity has no vaultId (locked vault / chirp peer)", async () => {120    const initIdent = await identityFor("ext-003", "Firefox", "extension", { withVaultId: false });121    const frame = await captureInitiatorFrame(initIdent);122123    // JSON.stringify drops undefined keys — backwards-compatible with peers124    // (and peach-sync) that treat vault_id as optional.125    expect(frame).not.toHaveProperty("vault_id");126  });127});