12345678910import { 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";222324const INITIATOR_PRIVATE = new Uint8Array(32).fill(0x01);25const NONCE = new Uint8Array(32).fill(0x03);2627const HANDSHAKE_HASH = new Uint8Array(32).fill(0x04);2829const EXPECTED_INITIATOR_PUBLIC =30 "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209";31const EXPECTED_RESPONDER_PUBLIC =32 "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59";3334const EXPECTED_PROOF =35 "d947d11a82892a65fdde94da5bbe6ca6034cf1f7e3268fc3ce5851b55f5813ce";3637function derivePublic(_privateKey: Uint8Array): Uint8Array {38 const pair = x25519GenerateKeyPair();39 40 41 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 49 50 51 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 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 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 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, 158 initiatorStatic.publicKey, 159 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 177 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 225 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 248 249 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}