noise-js.test.ts

9.7 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 { describe, expect, it } from "vitest";11import { generateKeyPair as x25519GenerateKeyPair } from "@stablelib/x25519";1213import {14  bytesToHex,15  computeAuthProof,16  noiseNKInitiator,17  noiseNKResponder,18  performInitiatorAuth,19  performResponderAuth,20} from "./noise-js";2122// Pinned cross-check vector for HMAC-SHA256 auth proof.23const INITIATOR_PRIVATE = new Uint8Array(32).fill(0x01);24const NONCE = new Uint8Array(32).fill(0x03);25// PCH-01-003b: the proof now binds the Noise handshake hash. Pin a fixed one.26const HANDSHAKE_HASH = new Uint8Array(32).fill(0x04);2728const EXPECTED_INITIATOR_PUBLIC =29  "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209";30const EXPECTED_RESPONDER_PUBLIC =31  "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59";32// Recaptured after PCH-01-003b prepended HANDSHAKE_HASH into the proof transcript.33const EXPECTED_PROOF =34  "d947d11a82892a65fdde94da5bbe6ca6034cf1f7e3268fc3ce5851b55f5813ce";3536function derivePublic(_privateKey: Uint8Array): Uint8Array {37  const pair = x25519GenerateKeyPair();38  // We can't derive a public from an arbitrary private with @stablelib's39  // generateKeyPair (it samples a fresh secret). For the cross-check, the40  // expected public keys are pinned above so we just use them directly.41  void pair;42  throw new Error("derivePublic should not be called — use pinned constants");43}4445describe("noise-js cross-check", () => {46  it("computes the pinned HMAC-SHA256 proof for the known input vector", () => {47    // Use the pinned public-key constants — they correspond to the X2551948    // public derived from the all-0x01 / all-0x02 private keys. Rederiving49    // them here would require a "derivePublic from raw scalar" helper that50    // @stablelib does not expose, so we trust the pinned values.51    void derivePublic;5253    const initiatorPublic = hexToBytes(EXPECTED_INITIATOR_PUBLIC);54    const responderPublic = hexToBytes(EXPECTED_RESPONDER_PUBLIC);5556    const proof = computeAuthProof(57      INITIATOR_PRIVATE,58      initiatorPublic,59      responderPublic,60      NONCE,61      HANDSHAKE_HASH,62    );6364    expect(bytesToHex(proof)).toBe(EXPECTED_PROOF);65  });66});6768describe("noise NK responder ↔ initiator round trip (pure JS)", () => {69  it("completes handshake, encrypts/decrypts, and verifies auth proof", async () => {70    const initiatorStatic = x25519GenerateKeyPair();71    const responderStatic = x25519GenerateKeyPair();7273    const initToResp: Uint8Array[] = [];74    const respToInit: Uint8Array[] = [];7576    const initSend = async (b: Uint8Array) => { initToResp.push(b); };77    const initRecv = async () => {78      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));79      return respToInit.shift()!;80    };81    const respSend = async (b: Uint8Array) => { respToInit.push(b); };82    const respRecv = async () => {83      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));84      return initToResp.shift()!;85    };8687    const [initSession, respSession] = await Promise.all([88      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),89      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),90    ]);9192    // Transport phase: bidirectional encrypt/decrypt.93    const initiatorOut = initSession.encrypt(new TextEncoder().encode("hello from initiator"));94    expect(new TextDecoder().decode(respSession.decrypt(initiatorOut))).toBe("hello from initiator");95    const responderOut = respSession.encrypt(new TextEncoder().encode("hello from responder"));96    expect(new TextDecoder().decode(initSession.decrypt(responderOut))).toBe("hello from responder");9798    // Auth round.99    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };100    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());101    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };102    const responderEncRecv = async () => respSession.decrypt(await respRecv());103104    const [, authResult] = await Promise.all([105      performInitiatorAuth(106        initSession,107        initiatorStatic.secretKey,108        initiatorStatic.publicKey,109        responderStatic.publicKey,110        initiatorEncSend,111        initiatorEncRecv,112      ),113      performResponderAuth(114        respSession,115        responderStatic.secretKey,116        responderStatic.publicKey,117        responderEncSend,118        responderEncRecv,119      ),120    ]);121122    expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));123  });124125  it("rejects a forged proof (constant-time MAC compare)", async () => {126    const initiatorStatic = x25519GenerateKeyPair();127    const attackerStatic = x25519GenerateKeyPair();128    const responderStatic = x25519GenerateKeyPair();129130    const initToResp: Uint8Array[] = [];131    const respToInit: Uint8Array[] = [];132    const initSend = async (b: Uint8Array) => { initToResp.push(b); };133    const initRecv = async () => {134      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));135      return respToInit.shift()!;136    };137    const respSend = async (b: Uint8Array) => { respToInit.push(b); };138    const respRecv = async () => {139      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));140      return initToResp.shift()!;141    };142143    const [initSession, respSession] = await Promise.all([144      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),145      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),146    ]);147148    // Initiator lies — claims initiator's identity but signs with attacker's key.149    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };150    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());151    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };152    const responderEncRecv = async () => respSession.decrypt(await respRecv());153154    const initiatorPromise = performInitiatorAuth(155      initSession,156      attackerStatic.secretKey, // wrong private — used to compute proof157      initiatorStatic.publicKey, // claimed identity158      responderStatic.publicKey,159      initiatorEncSend,160      initiatorEncRecv,161    );162    const responderPromise = performResponderAuth(163      respSession,164      responderStatic.secretKey,165      responderStatic.publicKey,166      responderEncSend,167      responderEncRecv,168    );169170    await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);171  });172});173174describe("PCH-01-003b — NK auth proof binds the Noise handshake hash", () => {175  // performInitiatorAuth/performResponderAuth only read session.handshakeHash and176  // the send/recv callbacks, so plaintext channels + minimal fake sessions suffice.177  function plaintextChannels() {178    const initToResp: Uint8Array[] = [];179    const respToInit: Uint8Array[] = [];180    const drain = async (q: Uint8Array[]) => {181      while (q.length === 0) await new Promise<void>((r) => setTimeout(r, 0));182      return q.shift()!;183    };184    return {185      initiatorEncSend: async (d: Uint8Array) => { initToResp.push(d); },186      initiatorEncRecv: async () => drain(respToInit),187      responderEncSend: async (d: Uint8Array) => { respToInit.push(d); },188      responderEncRecv: async () => drain(initToResp),189    };190  }191192  it("auth_proof_roundtrips_with_handshake_hash_binding", async () => {193    const initiatorStatic = x25519GenerateKeyPair();194    const responderStatic = x25519GenerateKeyPair();195    const hash = new Uint8Array(32).fill(0x07);196    const ch = plaintextChannels();197198    const [, authResult] = await Promise.all([199      performInitiatorAuth(200        { handshakeHash: hash } as never,201        initiatorStatic.secretKey,202        initiatorStatic.publicKey,203        responderStatic.publicKey,204        ch.initiatorEncSend,205        ch.initiatorEncRecv,206      ),207      performResponderAuth(208        { handshakeHash: hash } as never,209        responderStatic.secretKey,210        responderStatic.publicKey,211        ch.responderEncSend,212        ch.responderEncRecv,213      ),214    ]);215216    expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));217  });218219  it("auth_proof_rejected_when_handshake_hash_differs", async () => {220    const initiatorStatic = x25519GenerateKeyPair();221    const responderStatic = x25519GenerateKeyPair();222    const ch = plaintextChannels();223    // Same DH/keys/nonce, but the two sides hold DIFFERENT handshake hashes —224    // as a cross-session replay would. The proof must fail to verify.225    const initiatorPromise = performInitiatorAuth(226      { handshakeHash: new Uint8Array(32).fill(0x07) } as never,227      initiatorStatic.secretKey,228      initiatorStatic.publicKey,229      responderStatic.publicKey,230      ch.initiatorEncSend,231      ch.initiatorEncRecv,232    );233    const responderPromise = performResponderAuth(234      { handshakeHash: new Uint8Array(32).fill(0x09) } as never,235      responderStatic.secretKey,236      responderStatic.publicKey,237      ch.responderEncSend,238      ch.responderEncRecv,239    );240241    await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);242  });243});244245function hexToBytes(hex: string): Uint8Array {246  const out = new Uint8Array(hex.length / 2);247  for (let i = 0; i < hex.length; i += 2) {248    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);249  }250  return out;251}