import { beforeEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairFromSeed } from "@stablelib/x25519"; import { initHash as xxInitHash, hkdf2 as xxHkdf2 } from "./noise-xx"; import { initHash as nkInitHash, hkdf2 as nkHkdf2 } from "./noise-js"; // PCH-01-002: the Noise handshakes derive `h` (protocol-name init hash) and the // HKDF in MixKey/Split over BLAKE2s, matching the declared pattern name. These // are KNOWN-ANSWER tests over a fully deterministic handshake (fixed statics + // seeded ephemerals): a revert to SHA-256 changes every derived byte, so the // pinned handshakeHash below stops matching and the test fails. That failure is // the intended signal of an accidental hash-algorithm change. // Mock ONLY ephemeral generation so the handshake is deterministic; sharedKey // and the seeded keygen stay real. File-scoped, so other noise tests are // unaffected. The counter lives on globalThis and is reset per test. vi.mock("@stablelib/x25519", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, generateKeyPair: () => { const g = globalThis as { __kpCounter?: number }; g.__kpCounter = (g.__kpCounter ?? 0) + 1; return actual.generateKeyPairFromSeed(new Uint8Array(32).fill(g.__kpCounter)); }, }; }); function memoryPair() { const a: Uint8Array[] = []; const b: Uint8Array[] = []; const aR: Array<(v: Uint8Array) => void> = []; const bR: Array<(v: Uint8Array) => void> = []; const tA = { async send(m: Uint8Array) { const r = bR.shift(); if (r) r(m); else b.push(m); }, receive(): Promise { const v = a.shift(); if (v) return Promise.resolve(v); return new Promise((resolve) => aR.push(resolve)); }, }; const tB = { async send(m: Uint8Array) { const r = aR.shift(); if (r) r(m); else a.push(m); }, receive(): Promise { const v = b.shift(); if (v) return Promise.resolve(v); return new Promise((resolve) => bR.push(resolve)); }, }; return [tA, tB] as const; } function hex(b: Uint8Array): string { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); } // Cross-repo frozen vectors for the two changed primitives. These exact hex // values are ALSO pinned in peach-android's CrossPlatformKATTest.kt — a mismatch // on either side means the BLAKE2s wire derivations have diverged. describe("PCH-01-002 — BLAKE2s primitive vectors (shared with peach-android)", () => { const CK = new Uint8Array(32).map((_, i) => i); // 0x00..0x1f const IKM = new Uint8Array(32).map((_, i) => i + 32); // 0x20..0x3f it("initHash is BLAKE2s-256 of the XX protocol name", () => { expect(hex(xxInitHash())).toBe( "1ceedd81c5f458b225923dc2507787bf156f9251fc17c45af63263a929fc1ed2", ); }); it("initHash is BLAKE2s-256 of the NK protocol name", () => { expect(hex(nkInitHash())).toBe( "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e", ); }); it("hkdf2 (HKDF-BLAKE2s) matches the frozen vector and is identical XX vs NK", () => { const [k1, k2] = xxHkdf2(CK, IKM); expect(hex(k1)).toBe("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0"); expect(hex(k2)).toBe("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5"); // The hkdf2 logic is duplicated in noise-xx.ts and noise-js.ts — they must agree. const [n1, n2] = nkHkdf2(CK, IKM); expect(hex(n1)).toBe(hex(k1)); expect(hex(n2)).toBe(hex(k2)); }); }); describe("PCH-01-002 — Noise handshakes derive over BLAKE2s", () => { beforeEach(() => { (globalThis as { __kpCounter?: number }).__kpCounter = 0; }); it("noise_xx_handshake_hash_is_blake2s_derived", async () => { const { noiseXXInitiator, noiseXXResponder } = await import("./noise-xx"); const alice = generateKeyPairFromSeed(new Uint8Array(32).fill(0xa1)); const bob = generateKeyPairFromSeed(new Uint8Array(32).fill(0xb2)); const [tA, tB] = memoryPair(); const [a, b] = await Promise.all([ noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive), noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive), ]); // Both directions agree (consistency) ... expect(hex(a.handshakeHash)).toBe(hex(b.handshakeHash)); // ... and equal the pinned BLAKE2s vector (a SHA-256 revert changes this). expect(hex(a.handshakeHash)).toBe( "11c8bf7fcefad1f00ff8573c7bca66891626d053addae6d2b78b2f195a9ffd8a", ); // Transport keys are also BLAKE2s-derived; a frame must still round-trip. const ct = a.encrypt(new TextEncoder().encode("hello")); expect(new TextDecoder().decode(b.decrypt(ct))).toBe("hello"); }); it("noise_nk_handshake_hash_is_blake2s_derived", async () => { const { noiseNKInitiator, noiseNKResponder } = await import("./noise-js"); const responder = generateKeyPairFromSeed(new Uint8Array(32).fill(0xc3)); const [tA, tB] = memoryPair(); const [a, b] = await Promise.all([ noiseNKInitiator(responder.publicKey, tA.send, tA.receive), noiseNKResponder(responder.secretKey, responder.publicKey, tB.send, tB.receive), ]); expect(hex(a.handshakeHash)).toBe(hex(b.handshakeHash)); expect(hex(a.handshakeHash)).toBe( "0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655", ); const ct = a.encrypt(new TextEncoder().encode("ping")); expect(new TextDecoder().decode(b.decrypt(ct))).toBe("ping"); }); });