chirp-handshake.test.ts

6.4 KB · raw

1import { describe, expect, it } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import {4  derivePairingSAS,5  memoryTransportPair,6  runPairingInitiator,7  runPairingResponder,8  type LocalIdentity,9} from "./chirp-handshake";10import { deriveSyncIdentityKeypair, pubkeyToHex } from "./sync-identity";1112const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 5 + 3) & 0xff));1314function identityFor(deviceId: string, deviceName: string, type: LocalIdentity["deviceType"]): LocalIdentity {15  const kp = deriveSyncIdentityKeypair(MASTER, deviceId);16  return {17    deviceId,18    deviceName,19    deviceType: type,20    longTermPublicKeyHex: pubkeyToHex(kp.publicKey),21    signingPublicKeyHex: "b".repeat(64),22  };23}2425describe("chirp pairing handshake (Noise NK + identity exchange)", () => {26  it("exchanges long-term sync identities between initiator and responder", async () => {27    // Responder owns the ephemeral that the initiator targets.28    const respEphemeral = generateKeyPair();2930    const initIdent = identityFor("android-001", "June's Pixel 9", "android");31    const respIdent = identityFor("ext-001", "Firefox on MacBook", "extension");3233    const [t1, t2] = memoryTransportPair();34    const respPromise = runPairingResponder(35      respEphemeral.secretKey,36      respEphemeral.publicKey,37      respIdent,38      t2,39    );40    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);4142    const [respResult, initResult] = await Promise.all([respPromise, initPromise]);43    const initLearned = initResult.device;44    const respLearned = respResult.device;45    expect(initLearned.deviceId).toBe("ext-001");46    expect(initLearned.deviceName).toBe("Firefox on MacBook");47    expect(initLearned.deviceType).toBe("extension");48    expect(initLearned.longTermPublicKeyHex).toBe(respIdent.longTermPublicKeyHex);49    expect(initLearned.signingPublicKeyHex).toBe(respIdent.signingPublicKeyHex);50    expect(initLearned.trustSource).toBe("direct");5152    expect(respLearned.deviceId).toBe("android-001");53    expect(respLearned.deviceName).toBe("June's Pixel 9");54    expect(respLearned.deviceType).toBe("android");55    expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);56    expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);57    expect(respLearned.trustSource).toBe("direct");5859    // Both ends derive the SAME 6-digit SAS from the shared handshake hash —60    // this is what the user compares across devices.61    expect(initResult.sas).toMatch(/^\d{6}$/);62    expect(initResult.sas).toBe(respResult.sas);63  });6465  it("rejects a malformed identity frame", async () => {66    const respEphemeral = generateKeyPair();67    const respIdent = identityFor("ext-002", "Firefox", "extension");68    const [t1, t2] = memoryTransportPair();6970    // Initiator sends garbage instead of a PAIR_IDENTITY frame71    const respPromise = runPairingResponder(72      respEphemeral.secretKey,73      respEphemeral.publicKey,74      respIdent,75      t2,76    );77    const evilInitiator = (async () => {78      const { noiseNKInitiator } = await import("./noise-js");79      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);80      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY81    })();8283    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);84    await evilInitiator.catch(() => undefined);85  });8687  it("rejects an identity with a malformed long_term_public_key_hex", async () => {88    const respEphemeral = generateKeyPair();89    const respIdent = identityFor("ext-003", "Firefox", "extension");90    const [t1, t2] = memoryTransportPair();9192    const respPromise = runPairingResponder(93      respEphemeral.secretKey,94      respEphemeral.publicKey,95      respIdent,96      t2,97    );98    const evilInitiator = (async () => {99      const { noiseNKInitiator } = await import("./noise-js");100      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);101      const frame = JSON.stringify({102        type: "PAIR_IDENTITY",103        device_id: "evil",104        device_name: "evil",105        device_type: "android",106        long_term_public_key_hex: "deadbeef", // too short107      });108      await t1.send(session.encrypt(new TextEncoder().encode(frame)));109    })();110111    await expect(respPromise).rejects.toThrow(/32-byte hex/);112    await evilInitiator.catch(() => undefined);113  });114115  it("rejects an identity with a malformed signing_public_key_hex", async () => {116    const respEphemeral = generateKeyPair();117    const respIdent = identityFor("ext-004", "Firefox", "extension");118    const [t1, t2] = memoryTransportPair();119120    const respPromise = runPairingResponder(121      respEphemeral.secretKey,122      respEphemeral.publicKey,123      respIdent,124      t2,125    );126    const evilInitiator = (async () => {127      const { noiseNKInitiator } = await import("./noise-js");128      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);129      const frame = JSON.stringify({130        type: "PAIR_IDENTITY",131        device_id: "evil",132        device_name: "evil",133        device_type: "android",134        long_term_public_key_hex: "a".repeat(64),135        signing_public_key_hex: "deadbeef",136      });137      await t1.send(session.encrypt(new TextEncoder().encode(frame)));138    })();139140    await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);141    await evilInitiator.catch(() => undefined);142  });143});144145describe("derivePairingSAS (out-of-band MITM-detection code)", () => {146  it("returns a deterministic 6-digit code", () => {147    const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));148    const sas = derivePairingSAS(h);149    expect(sas).toMatch(/^\d{6}$/);150    expect(derivePairingSAS(h)).toBe(sas);151  });152153  it("both peers derive the same SAS from the shared Noise handshake hash", () => {154    // After the handshake both ends hold the identical handshakeHash, so both155    // independently compute the same comparison code.156    const shared = crypto.getRandomValues(new Uint8Array(32));157    expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));158  });159160  it("differs for different handshake hashes", () => {161    expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0))).not.toBe(162      derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff)),163    );164  });165166  it("handles undersized input without throwing", () => {167    expect(derivePairingSAS(new Uint8Array(2))).toBe("000000");168  });169});