noise-js.test.ts

11.1 KB · raw

1/**2 * Noise NK auth proof regression test.3 *4 * Pins a known input → output for the HMAC-SHA256 proof so any change to the5 * proof format is caught immediately. The pinned vector was captured against6 * the originally-shipped implementation; if the format ever needs to change,7 * regenerate both this vector and any peer that pairs against this build.8 */910import { readFile } from "node:fs/promises";11import { describe, expect, it } from "vitest";12import { generateKeyPair as x25519GenerateKeyPair } from "@stablelib/x25519";1314import {15  bytesToHex,16  computeAuthProof,17  noiseNKInitiator,18  noiseNKResponder,19  performInitiatorAuth,20  performResponderAuth,21} from "./noise-js";2223// Pinned cross-check vector for HMAC-SHA256 auth proof.24const INITIATOR_PRIVATE = new Uint8Array(32).fill(0x01);25const NONCE = new Uint8Array(32).fill(0x03);26// PCH-01-003b: the proof now binds the Noise handshake hash. Pin a fixed one.27const HANDSHAKE_HASH = new Uint8Array(32).fill(0x04);2829const EXPECTED_INITIATOR_PUBLIC =30  "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209";31const EXPECTED_RESPONDER_PUBLIC =32  "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59";33// Recaptured after PCH-01-003b prepended HANDSHAKE_HASH into the proof transcript.34const EXPECTED_PROOF =35  "d947d11a82892a65fdde94da5bbe6ca6034cf1f7e3268fc3ce5851b55f5813ce";3637function derivePublic(_privateKey: Uint8Array): Uint8Array {38  const pair = x25519GenerateKeyPair();39  // We can't derive a public from an arbitrary private with @stablelib's40  // generateKeyPair (it samples a fresh secret). For the cross-check, the41  // expected public keys are pinned above so we just use them directly.42  void pair;43  throw new Error("derivePublic should not be called — use pinned constants");44}4546describe("noise-js cross-check", () => {47  it("computes the pinned HMAC-SHA256 proof for the known input vector", () => {48    // Use the pinned public-key constants — they correspond to the X2551949    // public derived from the all-0x01 / all-0x02 private keys. Rederiving50    // them here would require a "derivePublic from raw scalar" helper that51    // @stablelib does not expose, so we trust the pinned values.52    void derivePublic;5354    const initiatorPublic = hexToBytes(EXPECTED_INITIATOR_PUBLIC);55    const responderPublic = hexToBytes(EXPECTED_RESPONDER_PUBLIC);5657    const proof = computeAuthProof(58      INITIATOR_PRIVATE,59      initiatorPublic,60      responderPublic,61      NONCE,62      HANDSHAKE_HASH,63    );6465    expect(bytesToHex(proof)).toBe(EXPECTED_PROOF);66  });67});6869describe("noise NK responder ↔ initiator round trip (pure JS)", () => {70  it("completes handshake, encrypts/decrypts, and verifies auth proof", async () => {71    const initiatorStatic = x25519GenerateKeyPair();72    const responderStatic = x25519GenerateKeyPair();7374    const initToResp: Uint8Array[] = [];75    const respToInit: Uint8Array[] = [];7677    const initSend = async (b: Uint8Array) => { initToResp.push(b); };78    const initRecv = async () => {79      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));80      return respToInit.shift()!;81    };82    const respSend = async (b: Uint8Array) => { respToInit.push(b); };83    const respRecv = async () => {84      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));85      return initToResp.shift()!;86    };8788    const [initSession, respSession] = await Promise.all([89      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),90      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),91    ]);9293    // Transport phase: bidirectional encrypt/decrypt.94    const initiatorOut = initSession.encrypt(new TextEncoder().encode("hello from initiator"));95    expect(new TextDecoder().decode(respSession.decrypt(initiatorOut))).toBe("hello from initiator");96    const responderOut = respSession.encrypt(new TextEncoder().encode("hello from responder"));97    expect(new TextDecoder().decode(initSession.decrypt(responderOut))).toBe("hello from responder");9899    // Auth round.100    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };101    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());102    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };103    const responderEncRecv = async () => respSession.decrypt(await respRecv());104105    const [, authResult] = await Promise.all([106      performInitiatorAuth(107        initSession,108        initiatorStatic.secretKey,109        initiatorStatic.publicKey,110        responderStatic.publicKey,111        initiatorEncSend,112        initiatorEncRecv,113      ),114      performResponderAuth(115        respSession,116        responderStatic.secretKey,117        responderStatic.publicKey,118        responderEncSend,119        responderEncRecv,120      ),121    ]);122123    expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));124  });125126  it("rejects a forged proof (constant-time MAC compare)", async () => {127    const initiatorStatic = x25519GenerateKeyPair();128    const attackerStatic = x25519GenerateKeyPair();129    const responderStatic = x25519GenerateKeyPair();130131    const initToResp: Uint8Array[] = [];132    const respToInit: Uint8Array[] = [];133    const initSend = async (b: Uint8Array) => { initToResp.push(b); };134    const initRecv = async () => {135      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));136      return respToInit.shift()!;137    };138    const respSend = async (b: Uint8Array) => { respToInit.push(b); };139    const respRecv = async () => {140      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));141      return initToResp.shift()!;142    };143144    const [initSession, respSession] = await Promise.all([145      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),146      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),147    ]);148149    // Initiator lies — claims initiator's identity but signs with attacker's key.150    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };151    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());152    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };153    const responderEncRecv = async () => respSession.decrypt(await respRecv());154155    const initiatorPromise = performInitiatorAuth(156      initSession,157      attackerStatic.secretKey, // wrong private — used to compute proof158      initiatorStatic.publicKey, // claimed identity159      responderStatic.publicKey,160      initiatorEncSend,161      initiatorEncRecv,162    );163    const responderPromise = performResponderAuth(164      respSession,165      responderStatic.secretKey,166      responderStatic.publicKey,167      responderEncSend,168      responderEncRecv,169    );170171    await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);172  });173});174175describe("PCH-01-003b — NK auth proof binds the Noise handshake hash", () => {176  // performInitiatorAuth/performResponderAuth only read session.handshakeHash and177  // the send/recv callbacks, so plaintext channels + minimal fake sessions suffice.178  function plaintextChannels() {179    const initToResp: Uint8Array[] = [];180    const respToInit: Uint8Array[] = [];181    const drain = async (q: Uint8Array[]) => {182      while (q.length === 0) await new Promise<void>((r) => setTimeout(r, 0));183      return q.shift()!;184    };185    return {186      initiatorEncSend: async (d: Uint8Array) => { initToResp.push(d); },187      initiatorEncRecv: async () => drain(respToInit),188      responderEncSend: async (d: Uint8Array) => { respToInit.push(d); },189      responderEncRecv: async () => drain(initToResp),190    };191  }192193  it("auth_proof_roundtrips_with_handshake_hash_binding", async () => {194    const initiatorStatic = x25519GenerateKeyPair();195    const responderStatic = x25519GenerateKeyPair();196    const hash = new Uint8Array(32).fill(0x07);197    const ch = plaintextChannels();198199    const [, authResult] = await Promise.all([200      performInitiatorAuth(201        { handshakeHash: hash } as never,202        initiatorStatic.secretKey,203        initiatorStatic.publicKey,204        responderStatic.publicKey,205        ch.initiatorEncSend,206        ch.initiatorEncRecv,207      ),208      performResponderAuth(209        { handshakeHash: hash } as never,210        responderStatic.secretKey,211        responderStatic.publicKey,212        ch.responderEncSend,213        ch.responderEncRecv,214      ),215    ]);216217    expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));218  });219220  it("auth_proof_rejected_when_handshake_hash_differs", async () => {221    const initiatorStatic = x25519GenerateKeyPair();222    const responderStatic = x25519GenerateKeyPair();223    const ch = plaintextChannels();224    // Same DH/keys/nonce, but the two sides hold DIFFERENT handshake hashes —225    // as a cross-session replay would. The proof must fail to verify.226    const initiatorPromise = performInitiatorAuth(227      { handshakeHash: new Uint8Array(32).fill(0x07) } as never,228      initiatorStatic.secretKey,229      initiatorStatic.publicKey,230      responderStatic.publicKey,231      ch.initiatorEncSend,232      ch.initiatorEncRecv,233    );234    const responderPromise = performResponderAuth(235      { handshakeHash: new Uint8Array(32).fill(0x09) } as never,236      responderStatic.secretKey,237      responderStatic.publicKey,238      ch.responderEncSend,239      ch.responderEncRecv,240    );241242    await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);243  });244});245246describe("PB-26-605 — authenticated comparisons are constant-time", () => {247  // Both values at this site are public keys, so nothing leaks today. The guard248  // exists so a future refactor cannot reuse a short-circuiting comparator here249  // or move one to a secret-bearing site.250  it("noise-js.ts compares byte strings only through constantTimeEqualBytes", async () => {251    const source = await readFile(new URL("./noise-js.ts", import.meta.url), "utf8");252    expect(source).toContain("constantTimeEqualBytes");253    expect(source).not.toContain(".every(");254  });255256  it("rejects a challenge carrying a substituted responder key", async () => {257    const initiatorStatic = x25519GenerateKeyPair();258    const responderStatic = x25519GenerateKeyPair();259    const attacker = x25519GenerateKeyPair();260    const challenge = new TextEncoder().encode(261      JSON.stringify({262        type: "CHALLENGE",263        nonce_hex: "00".repeat(32),264        responder_public_key_hex: bytesToHex(attacker.publicKey),265      }),266    );267268    await expect(269      performInitiatorAuth(270        { handshakeHash: new Uint8Array(32) } as never,271        initiatorStatic.secretKey,272        initiatorStatic.publicKey,273        responderStatic.publicKey,274        async () => undefined,275        async () => challenge,276      ),277    ).rejects.toThrow(/does not match Noise handshake/);278  });279});280281function hexToBytes(hex: string): Uint8Array {282  const out = new Uint8Array(hex.length / 2);283  for (let i = 0; i < hex.length; i += 2) {284    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);285  }286  return out;287}