noise-js.test.ts

7.2 KB · raw

1/**2 * Noise NK auth proof cross-check.3 *4 * The JS implementation in `noise-js.ts` MUST produce byte-identical proofs to5 * the native Rust host (see `native/peach-sync-host/src/auth.rs`). The fixed6 * test vector below was captured by running:7 *8 *   cargo test --manifest-path native/peach-sync-host/Cargo.toml \9 *     print_known_proof_for_js_cross_check -- --nocapture10 *11 * If either side ever changes the auth proof format, ONE of the two pinned12 * hex strings (here and in auth.rs) will fail and CI catches the divergence13 * before any device pair gets wedged.14 */1516import { describe, expect, it } from "vitest";17import { generateKeyPair as x25519GenerateKeyPair } from "@stablelib/x25519";1819import {20  bytesToHex,21  computeAuthProof,22  noiseNKInitiator,23  noiseNKResponder,24  performInitiatorAuth,25  performResponderAuth,26} from "./noise-js";2728// Inputs match the Rust test in auth.rs::tests::print_known_proof_for_js_cross_check29const INITIATOR_PRIVATE = new Uint8Array(32).fill(0x01);30const RESPONDER_PRIVATE = new Uint8Array(32).fill(0x02);31const NONCE = new Uint8Array(32).fill(0x03);3233const EXPECTED_INITIATOR_PUBLIC =34  "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209";35const EXPECTED_RESPONDER_PUBLIC =36  "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59";37const EXPECTED_PROOF =38  "39efee52ccc9d7eaa24fcfec9a4c75eaad75a6bf3319d9794d9227f2b1466752";3940function derivePublic(privateKey: Uint8Array): Uint8Array {41  const pair = x25519GenerateKeyPair();42  // We can't derive a public from an arbitrary private with @stablelib's43  // generateKeyPair (it samples a fresh secret). For the cross-check, the44  // expected public keys are pinned above so we just use them directly.45  void pair;46  throw new Error("derivePublic should not be called — use pinned constants");47}4849describe("noise-js cross-check", () => {50  it("computes the same proof bytes as the Rust auth.rs HMAC-SHA256 scheme", () => {51    // Use the pinned public-key constants — they correspond to the X2551952    // public derived from the all-0x01 / all-0x02 private keys. Rederiving53    // them here would require a "derivePublic from raw scalar" helper that54    // @stablelib does not expose, so we trust the Rust pinned values.55    void derivePublic;5657    const initiatorPublic = hexToBytes(EXPECTED_INITIATOR_PUBLIC);58    const responderPublic = hexToBytes(EXPECTED_RESPONDER_PUBLIC);5960    const proof = computeAuthProof(61      INITIATOR_PRIVATE,62      initiatorPublic,63      responderPublic,64      NONCE,65    );6667    expect(bytesToHex(proof)).toBe(EXPECTED_PROOF);68  });69});7071describe("noise NK responder ↔ initiator round trip (pure JS)", () => {72  it("completes handshake, encrypts/decrypts, and verifies auth proof", async () => {73    const initiatorStatic = x25519GenerateKeyPair();74    const responderStatic = x25519GenerateKeyPair();7576    const initToResp: Uint8Array[] = [];77    const respToInit: Uint8Array[] = [];7879    const initSend = async (b: Uint8Array) => { initToResp.push(b); };80    const initRecv = async () => {81      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));82      return respToInit.shift()!;83    };84    const respSend = async (b: Uint8Array) => { respToInit.push(b); };85    const respRecv = async () => {86      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));87      return initToResp.shift()!;88    };8990    const [initSession, respSession] = await Promise.all([91      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),92      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),93    ]);9495    // Transport phase: bidirectional encrypt/decrypt.96    const initiatorOut = initSession.encrypt(new TextEncoder().encode("hello from initiator"));97    expect(new TextDecoder().decode(respSession.decrypt(initiatorOut))).toBe("hello from initiator");98    const responderOut = respSession.encrypt(new TextEncoder().encode("hello from responder"));99    expect(new TextDecoder().decode(initSession.decrypt(responderOut))).toBe("hello from responder");100101    // Auth round.102    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };103    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());104    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };105    const responderEncRecv = async () => respSession.decrypt(await respRecv());106107    const [, authResult] = await Promise.all([108      performInitiatorAuth(109        initSession,110        initiatorStatic.secretKey,111        initiatorStatic.publicKey,112        responderStatic.publicKey,113        initiatorEncSend,114        initiatorEncRecv,115      ),116      performResponderAuth(117        respSession,118        responderStatic.secretKey,119        responderStatic.publicKey,120        responderEncSend,121        responderEncRecv,122      ),123    ]);124125    expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));126  });127128  it("rejects a forged proof (constant-time MAC compare)", async () => {129    const initiatorStatic = x25519GenerateKeyPair();130    const attackerStatic = x25519GenerateKeyPair();131    const responderStatic = x25519GenerateKeyPair();132133    const initToResp: Uint8Array[] = [];134    const respToInit: Uint8Array[] = [];135    const initSend = async (b: Uint8Array) => { initToResp.push(b); };136    const initRecv = async () => {137      while (respToInit.length === 0) await new Promise<void>((r) => setTimeout(r, 0));138      return respToInit.shift()!;139    };140    const respSend = async (b: Uint8Array) => { respToInit.push(b); };141    const respRecv = async () => {142      while (initToResp.length === 0) await new Promise<void>((r) => setTimeout(r, 0));143      return initToResp.shift()!;144    };145146    const [initSession, respSession] = await Promise.all([147      noiseNKInitiator(responderStatic.publicKey, initSend, initRecv),148      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, respSend, respRecv),149    ]);150151    // Initiator lies — claims initiator's identity but signs with attacker's key.152    const initiatorEncSend = async (data: Uint8Array) => { initToResp.push(initSession.encrypt(data)); };153    const initiatorEncRecv = async () => initSession.decrypt(await initRecv());154    const responderEncSend = async (data: Uint8Array) => { respToInit.push(respSession.encrypt(data)); };155    const responderEncRecv = async () => respSession.decrypt(await respRecv());156157    const initiatorPromise = performInitiatorAuth(158      initSession,159      attackerStatic.secretKey, // wrong private — used to compute proof160      initiatorStatic.publicKey, // claimed identity161      responderStatic.publicKey,162      initiatorEncSend,163      initiatorEncRecv,164    );165    const responderPromise = performResponderAuth(166      respSession,167      responderStatic.secretKey,168      responderStatic.publicKey,169      responderEncSend,170      responderEncRecv,171    );172173    await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);174  });175});176177function hexToBytes(hex: string): Uint8Array {178  const out = new Uint8Array(hex.length / 2);179  for (let i = 0; i < hex.length; i += 2) {180    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);181  }182  return out;183}