chirp-handshake.test.ts

3.7 KB · raw

1import { describe, expect, it } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import {4  memoryTransportPair,5  runPairingInitiator,6  runPairingResponder,7  type LocalIdentity,8} from "./chirp-handshake";9import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";1011const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));1213function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {14  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);15  return { deviceId, deviceName, deviceType: type, longTermPublicKeyHex: pubkeyToHex(kp.publicKey) };16}1718describe("chirp pairing handshake (Noise NK + identity exchange)", () => {19  it("exchanges long-term sync identities between initiator and responder", async () => {20    // Responder owns the ephemeral that the initiator targets.21    const respEphemeral = generateKeyPair();2223    const initIdent = identityFor("android-001", "June's Pixel 9", "android");24    const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");2526    const [t1, t2] = memoryTransportPair();27    const respPromise = runPairingResponder(28      respEphemeral.secretKey,29      respEphemeral.publicKey,30      respIdent,31      t2,32    );33    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);3435    const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);36    expect(initLearned.deviceId).toBe("ext-001");37    expect(initLearned.deviceName).toBe("Firefox on MacBook");38    expect(initLearned.deviceType).toBe("extension");39    expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);4041    expect(respLearned.deviceId).toBe("android-001");42    expect(respLearned.deviceName).toBe("June's Pixel 9");43    expect(respLearned.deviceType).toBe("android");44    expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);45  });4647  it("rejects a malformed identity frame", async () => {48    const respEphemeral = generateKeyPair();49    const respIdent = identityFor("ext-002", "Firefox", "extension");50    const [t1, t2] = memoryTransportPair();5152    // Initiator sends garbage instead of a PAIR_IDENTITY frame53    const respPromise = runPairingResponder(54      respEphemeral.secretKey,55      respEphemeral.publicKey,56      respIdent,57      t2,58    );59    const evilInitiator = (async () => {60      const { noiseNKInitiator } = await import("./noise-js");61      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);62      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY63    })();6465    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);66    await evilInitiator.catch(() => undefined);67  });6869  it("rejects an identity with a malformed long_term_public_key_hex", async () => {70    const respEphemeral = generateKeyPair();71    const respIdent = identityFor("ext-003", "Firefox", "extension");72    const [t1, t2] = memoryTransportPair();7374    const respPromise = runPairingResponder(75      respEphemeral.secretKey,76      respEphemeral.publicKey,77      respIdent,78      t2,79    );80    const evilInitiator = (async () => {81      const { noiseNKInitiator } = await import("./noise-js");82      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);83      const frame = JSON.stringify({84        type: "PAIR_IDENTITY",85        device_id: "evil",86        device_name: "evil",87        device_type: "android",88        long_term_public_key_hex: "deadbeef", // too short89      });90      await t1.send(session.encrypt(new TextEncoder().encode(frame)));91    })();9293    await expect(respPromise).rejects.toThrow(/32-byte hex/);94    await evilInitiator.catch(() => undefined);95  });96});