noise-xx.test.ts

5.6 KB · raw

1import { describe, expect, it } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";45function memoryPair() {6  const a: Uint8Array[] = [];7  const b: Uint8Array[] = [];8  const aR: Array<(v: Uint8Array) => void> = [];9  const bR: Array<(v: Uint8Array) => void> = [];10  const tA = {11    async send(m: Uint8Array) {12      const r = bR.shift();13      if (r) r(m); else b.push(m);14    },15    receive(): Promise<Uint8Array> {16      const v = a.shift();17      if (v) return Promise.resolve(v);18      return new Promise<Uint8Array>((resolve) => aR.push(resolve));19    },20  };21  const tB = {22    async send(m: Uint8Array) {23      const r = aR.shift();24      if (r) r(m); else a.push(m);25    },26    receive(): Promise<Uint8Array> {27      const v = b.shift();28      if (v) return Promise.resolve(v);29      return new Promise<Uint8Array>((resolve) => bR.push(resolve));30    },31  };32  return [tA, tB] as const;33}3435describe("Noise XX mutual authentication", () => {36  it("completes the three-message handshake and exchanges static keys", async () => {37    const alice = generateKeyPair();38    const bob = generateKeyPair();39    const [tA, tB] = memoryPair();4041    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);42    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);4344    const [a, b] = await Promise.all([aPromise, bPromise]);45    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);46    expect(b.remoteStaticPublicKey).toEqual(alice.publicKey);47    expect(a.handshakeHash).toEqual(b.handshakeHash);48  });4950  it("transport frames decrypt in both directions and are bound to the handshake", async () => {51    const alice = generateKeyPair();52    const bob = generateKeyPair();53    const [tA, tB] = memoryPair();5455    const [a, b] = await Promise.all([56      noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive),57      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),58    ]);5960    const ct1 = a.encrypt(new TextEncoder().encode("hello bob"));61    expect(new TextDecoder().decode(b.decrypt(ct1))).toBe("hello bob");6263    const ct2 = b.encrypt(new TextEncoder().encode("hello alice"));64    expect(new TextDecoder().decode(a.decrypt(ct2))).toBe("hello alice");6566    // Tampering with one byte breaks the MAC.67    const ct3 = a.encrypt(new TextEncoder().encode("not delivered"));68    ct3[0] ^= 1;69    expect(() => b.decrypt(ct3)).toThrow();70  });7172  it("an attacker who swaps the responder static cannot complete msg2 auth", async () => {73    const alice = generateKeyPair();74    const bobReal = generateKeyPair();75    const bobImpostor = generateKeyPair();76    const [tA, tB] = memoryPair();7778    // Responder uses impostor private key but advertises real pubkey to initiator.79    // Real XX has no out-of-band pubkey check; this test verifies the registry80    // check that the caller MUST perform on `remoteStaticPublicKey`.81    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);82    const bPromise = noiseXXResponder(bobImpostor.secretKey, bobImpostor.publicKey, tB.send, tB.receive);83    const [a] = await Promise.all([aPromise, bPromise]);84    // The "swap" is the caller seeing a remote static they did not expect.85    expect(a.remoteStaticPublicKey).not.toEqual(bobReal.publicKey);86    expect(a.remoteStaticPublicKey).toEqual(bobImpostor.publicKey);87  });8889  // PCH-01-003: the pre-msg3 verifier must abort BEFORE the initiator transmits90  // its own static (msg3), so a probed-but-unexpected LAN host never learns our91  // long-term sync identity.92  it("initiator_aborts_before_msg3_on_unexpected_responder", async () => {93    const alice = generateKeyPair();94    const bob = generateKeyPair();95    const expected = generateKeyPair(); // the peer we WANTED — not bob96    const [tA, tB] = memoryPair();9798    let sendCount = 0;99    const countingSend = async (m: Uint8Array) => {100      sendCount++;101      await tA.send(m);102    };103104    const aPromise = noiseXXInitiator(105      alice.secretKey,106      alice.publicKey,107      countingSend,108      tA.receive,109      (remoteStatic) => {110        const matches =111          remoteStatic.length === expected.publicKey.length &&112          remoteStatic.every((v, i) => v === expected.publicKey[i]);113        if (!matches) throw new Error("paired-device static key mismatch");114      },115    );116    // Honest responder advertising bob's static; it will hang waiting for msg3117    // that never arrives, so we don't await it.118    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);119    bPromise.catch(() => {});120121    await expect(aPromise).rejects.toThrow("paired-device static key mismatch");122    // Load-bearing assertion: only msg1 was sent. msg3 (our static) never left.123    expect(sendCount).toBe(1);124  });125126  it("matching_pin_completes_handshake_and_sends_msg3", async () => {127    const alice = generateKeyPair();128    const bob = generateKeyPair();129    const [tA, tB] = memoryPair();130131    let sendCount = 0;132    const countingSend = async (m: Uint8Array) => {133      sendCount++;134      await tA.send(m);135    };136137    const [a] = await Promise.all([138      noiseXXInitiator(alice.secretKey, alice.publicKey, countingSend, tA.receive, (remoteStatic) => {139        const matches = remoteStatic.every((v, i) => v === bob.publicKey[i]);140        if (!matches) throw new Error("paired-device static key mismatch");141      }),142      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),143    ]);144145    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);146    // msg1 + msg3 both sent on the honest path — handshake completed.147    expect(sendCount).toBe(2);148  });149});