pairing-handshake.test.ts

7.7 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 "./pairing-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("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 8-digit SAS from the shared handshake hash —60    // this is what the user compares across devices.61    expect(initResult.sas).toMatch(/^\d{8}$/);62    expect(initResult.sas).toBe(respResult.sas);63  });6465  it("carries a Pro license from the device that has one to the peer", async () => {66    const respEphemeral = generateKeyPair();67    // The initiator (e.g. an existing Pro phone) advertises its signed license.68    const initIdent = { ...identityFor("android-pro", "Pro Pixel", "android"), license: "PPK-AAA-BBB" };69    const respIdent = identityFor("ext-free", "Free Firefox", "extension"); // no license7071    const [t1, t2] = memoryTransportPair();72    const respPromise = runPairingResponder(respEphemeral.secretKey, respEphemeral.publicKey, respIdent, t2);73    const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);74    const [respResult, initResult] = await Promise.all([respPromise, initPromise]);7576    // The free responder learns the peer's license to activate; the initiator77    // sees none (we sent it nothing). License is never written onto the device.78    expect(respResult.peerLicense).toBe("PPK-AAA-BBB");79    expect(initResult.peerLicense).toBeUndefined();80    expect("license" in respResult.device).toBe(false);81  });8283  it("rejects a malformed identity frame", async () => {84    const respEphemeral = generateKeyPair();85    const respIdent = identityFor("ext-002", "Firefox", "extension");86    const [t1, t2] = memoryTransportPair();8788    // Initiator sends garbage instead of a PAIR_IDENTITY frame89    const respPromise = runPairingResponder(90      respEphemeral.secretKey,91      respEphemeral.publicKey,92      respIdent,93      t2,94    );95    const evilInitiator = (async () => {96      const { noiseNKInitiator } = await import("./noise-js");97      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);98      await t1.send(session.encrypt(new TextEncoder().encode("{}"))); // not a PAIR_IDENTITY99    })();100101    await expect(respPromise).rejects.toThrow(/PAIR_IDENTITY/);102    await evilInitiator.catch(() => undefined);103  });104105  it("rejects an identity with a malformed long_term_public_key_hex", async () => {106    const respEphemeral = generateKeyPair();107    const respIdent = identityFor("ext-003", "Firefox", "extension");108    const [t1, t2] = memoryTransportPair();109110    const respPromise = runPairingResponder(111      respEphemeral.secretKey,112      respEphemeral.publicKey,113      respIdent,114      t2,115    );116    const evilInitiator = (async () => {117      const { noiseNKInitiator } = await import("./noise-js");118      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);119      const frame = JSON.stringify({120        type: "PAIR_IDENTITY",121        device_id: "evil",122        device_name: "evil",123        device_type: "android",124        long_term_public_key_hex: "deadbeef", // too short125      });126      await t1.send(session.encrypt(new TextEncoder().encode(frame)));127    })();128129    await expect(respPromise).rejects.toThrow(/32-byte hex/);130    await evilInitiator.catch(() => undefined);131  });132133  it("rejects an identity with a malformed signing_public_key_hex", async () => {134    const respEphemeral = generateKeyPair();135    const respIdent = identityFor("ext-004", "Firefox", "extension");136    const [t1, t2] = memoryTransportPair();137138    const respPromise = runPairingResponder(139      respEphemeral.secretKey,140      respEphemeral.publicKey,141      respIdent,142      t2,143    );144    const evilInitiator = (async () => {145      const { noiseNKInitiator } = await import("./noise-js");146      const session = await noiseNKInitiator(respEphemeral.publicKey, t1.send, t1.receive);147      const frame = JSON.stringify({148        type: "PAIR_IDENTITY",149        device_id: "evil",150        device_name: "evil",151        device_type: "android",152        long_term_public_key_hex: "a".repeat(64),153        signing_public_key_hex: "deadbeef",154      });155      await t1.send(session.encrypt(new TextEncoder().encode(frame)));156    })();157158    await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);159    await evilInitiator.catch(() => undefined);160  });161});162163describe("derivePairingSAS (out-of-band MITM-detection code)", () => {164  it("returns a deterministic 8-digit code", () => {165    const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));166    const sas = derivePairingSAS(h);167    expect(sas).toMatch(/^\d{8}$/);168    expect(derivePairingSAS(h)).toBe(sas);169  });170171  it("both peers derive the same SAS from the shared Noise handshake hash", () => {172    // After the handshake both ends hold the identical handshakeHash, so both173    // independently compute the same comparison code.174    const shared = crypto.getRandomValues(new Uint8Array(32));175    expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));176  });177178  it("differs for different handshake hashes", () => {179    expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0))).not.toBe(180      derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)),181    );182  });183184  it("pins the cross-platform big-endian 64-bit vector", () => {185    expect(derivePairingSAS(Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7))).toBe("52306183");186    expect(derivePairingSAS(new Uint8Array(8).fill(0xff))).toBe("09551615");187  });188189  it("rejects undersized input", () => {190    expect(() => derivePairingSAS(new Uint8Array(7))).toThrow(/too short/);191  });192});