chirp-handshake.test.ts

5.1 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 {16    deviceId,17    deviceName,18    deviceType: type,19    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),20    signingPublicKeyHex: "b".repeat(64),21  };22}2324describe("chirp pairing handshake (Noise NK + identity exchange)", () => {25  it("exchanges long-term sync identities between initiator and responder", async () => {26    // Responder owns the ephemeral that the initiator targets.27    const respEphemeral = generateKeyPair();2829    const initIdent = identityFor("android-001", "June's Pixel 9", "android");30    const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");3132    const [t1, t2] = memoryTransportPair();33    const respPromise = runPairingResponder(34      respEphemeral.secretKey,35      respEphemeral.publicKey,36      respIdent,37      t2,38    );39    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);4041    const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);42    expect(initLearned.deviceId).toBe("ext-001");43    expect(initLearned.deviceName).toBe("Firefox on MacBook");44    expect(initLearned.deviceType).toBe("extension");45    expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);46    expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);47    expect(initLearned.trustSource).toBe("direct");4849    expect(respLearned.deviceId).toBe("android-001");50    expect(respLearned.deviceName).toBe("June's Pixel 9");51    expect(respLearned.deviceType).toBe("android");52    expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);53    expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);54    expect(respLearned.trustSource).toBe("direct");55  });5657  it("rejects a malformed identity frame", async () => {58    const respEphemeral = generateKeyPair();59    const respIdent = identityFor("ext-002", "Firefox", "extension");60    const [t1, t2] = memoryTransportPair();6162    // Initiator sends garbage instead of a PAIR_IDENTITY frame63    const respPromise = runPairingResponder(64      respEphemeral.secretKey,65      respEphemeral.publicKey,66      respIdent,67      t2,68    );69    const evilInitiator = (async () => {70      const { noiseNKInitiator } = await import("./noise-js");71      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);72      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY73    })();7475    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);76    await evilInitiator.catch(() => undefined);77  });7879  it("rejects an identity with a malformed long_term_public_key_hex", async () => {80    const respEphemeral = generateKeyPair();81    const respIdent = identityFor("ext-003", "Firefox", "extension");82    const [t1, t2] = memoryTransportPair();8384    const respPromise = runPairingResponder(85      respEphemeral.secretKey,86      respEphemeral.publicKey,87      respIdent,88      t2,89    );90    const evilInitiator = (async () => {91      const { noiseNKInitiator } = await import("./noise-js");92      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);93      const frame = JSON.stringify({94        type: "PAIR_IDENTITY",95        device_id: "evil",96        device_name: "evil",97        device_type: "android",98        long_term_public_key_hex: "deadbeef", // too short99      });100      await t1.send(session.encrypt(new TextEncoder().encode(frame)));101    })();102103    await expect(respPromise).rejects.toThrow(/32-byte hex/);104    await evilInitiator.catch(() => undefined);105  });106107  it("rejects an identity with a malformed signing_public_key_hex", async () => {108    const respEphemeral = generateKeyPair();109    const respIdent = identityFor("ext-004", "Firefox", "extension");110    const [t1, t2] = memoryTransportPair();111112    const respPromise = runPairingResponder(113      respEphemeral.secretKey,114      respEphemeral.publicKey,115      respIdent,116      t2,117    );118    const evilInitiator = (async () => {119      const { noiseNKInitiator } = await import("./noise-js");120      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);121      const frame = JSON.stringify({122        type: "PAIR_IDENTITY",123        device_id: "evil",124        device_name: "evil",125        device_type: "android",126        long_term_public_key_hex: "a".repeat(64),127        signing_public_key_hex: "deadbeef",128      });129      await t1.send(session.encrypt(new TextEncoder().encode(frame)));130    })();131132    await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);133    await evilInitiator.catch(() => undefined);134  });135});