1e1194c32494

fix: PCH-02-009 rejectZero: true on all X25519 DH calls in Noise XX and NK (v4.0.0)

time
2026-06-12 20:35 UTC · 4 months ago
machine
koh2
agent
gpt-5
session
pch-02 integration
parent
8f54f349945f
3 files src/shared/noise-js.ts+8 −6src/shared/noise-xx.ts+8 −6src/shared/noise.reject-zero-dh.test.ts+103 −0
src/shared/noise-js.ts+8 −6
@@ -81,7 +81,9 @@8181 8282   // --- Message 1: e, es ---8383   h = mixHash(h, ephemeral.publicKey);84-  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey);84+  // PCH-02-009: reject all-zero DH outputs from low-order peer points before85+  // they enter the Noise key schedule. Honest peers are unaffected.86+  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);8587   const [ck1, k1] = hkdf2(ck, es);8688   ck = ck1;8789 @@ -103,7 +105,7 @@103105   const encrypted2 = msg2.slice(32);104106 105107   h = mixHash(h, remoteEphemeralPublic);106-  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);108+  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);107109   const [ck2, k2] = hkdf2(ck, ee);108110   ck = ck2;109111 @@ -172,7 +174,7 @@172174   if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");173175   if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");174176 175-  const dh = sharedKey(initiatorPrivate, responderPublic);177+  const dh = sharedKey(initiatorPrivate, responderPublic, true);176178   const mac = new HMAC(SHA256, dh);177179   // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed178180   // onto a different handshake. handshakeHash is prepended; both sides hold the@@ -245,7 +247,7 @@245247   const encrypted1 = msg1.slice(32);246248 247249   h = mixHash(h, remoteEphemeralPublic);248-  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic);250+  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);249251   const [ck1, k1] = hkdf2(ck, es);250252   ck = ck1;251253 @@ -260,7 +262,7 @@260262   // --- Message 2: e, ee ---261263   const ephemeral = generateKeyPair();262264   h = mixHash(h, ephemeral.publicKey);263-  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);265+  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);264266   const [ck2, k2] = hkdf2(ck, ee);265267   ck = ck2;266268 @@ -359,7 +361,7 @@359361   }360362 361363   // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)362-  const dh = sharedKey(ourPrivateKey, initiatorPublicKey);364+  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);363365   const mac = new HMAC(SHA256, dh);364366   // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the365367   // existing domain/keys/nonce transcript. Same value, same position on both ends.

view file

src/shared/noise-xx.ts+8 −6
@@ -105,7 +105,9 @@105105   const encryptedPayload = msg2.slice(32 + 32 + 16);106106 107107   h = mixHash(h, remoteEphemeral);108-  const ee = sharedKey(e.secretKey, remoteEphemeral);108+  // PCH-02-009: reject all-zero DH outputs from low-order peer points before109+  // they enter the key schedule. Honest peers are unaffected.110+  const ee = sharedKey(e.secretKey, remoteEphemeral, true);109111   const [ck1, k1] = hkdf2(ck, ee);110112   ck = ck1;111113 @@ -116,7 +118,7 @@116118   }117119   h = mixHash(h, encryptedStatic);118120 119-  const es = sharedKey(e.secretKey, remoteStaticPlain);121+  const es = sharedKey(e.secretKey, remoteStaticPlain, true);120122   const [ck2, k2] = hkdf2(ck, es);121123   ck = ck2;122124 @@ -137,7 +139,7 @@137139   const encryptedOurStatic = cipherOurStatic.seal(nonceBytes(1n), ourStaticPublicKey, h);138140   h = mixHash(h, encryptedOurStatic);139141 140-  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral);142+  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);141143   const [ck3, k3] = hkdf2(ck, se);142144   ck = ck3;143145 @@ -193,7 +195,7 @@193195   // --- Message 2: -> e, ee, s, es ---194196   const e = generateKeyPair();195197   h = mixHash(h, e.publicKey);196-  const ee = sharedKey(e.secretKey, remoteEphemeral);198+  const ee = sharedKey(e.secretKey, remoteEphemeral, true);197199   const [ck1, k1] = hkdf2(ck, ee);198200   ck = ck1;199201 @@ -201,7 +203,7 @@201203   const encryptedOurStatic = cipherStatic.seal(nonceBytes(0n), ourStaticPublicKey, h);202204   h = mixHash(h, encryptedOurStatic);203205 204-  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral);206+  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);205207   const [ck2, k2] = hkdf2(ck, es);206208   ck = ck2;207209 @@ -228,7 +230,7 @@228230   }229231   h = mixHash(h, encryptedRemoteStatic);230232 231-  const se = sharedKey(e.secretKey, remoteStaticPlain);233+  const se = sharedKey(e.secretKey, remoteStaticPlain, true);232234   const [ck3, k3] = hkdf2(ck, se);233235   ck = ck3;234236 

view file

src/shared/noise.reject-zero-dh.test.ts+103 −0
@@ -0,0 +1,103 @@1+// PCH-02-009 regression tests — every Noise XX/NK DH must reject an all-zero2+// shared secret (low-order / identity peer point) instead of feeding it into3+// the key schedule.4+5+import { describe, expect, it } from "vitest";6+import { generateKeyPair } from "@stablelib/x25519";7+import { noiseNKInitiator, noiseNKResponder } from "./noise-js";8+import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";9+10+const ZERO_POINT = new Uint8Array(32);11+12+function queuePair() {13+  const makeQueue = () => {14+    const items: Uint8Array[] = [];15+    const waiters: Array<(m: Uint8Array) => void> = [];16+    return {17+      push(m: Uint8Array) {18+        const w = waiters.shift();19+        if (w) w(m);20+        else items.push(m);21+      },22+      pull(): Promise<Uint8Array> {23+        const m = items.shift();24+        return m ? Promise.resolve(m) : new Promise((r) => waiters.push(r));25+      },26+    };27+  };28+  const aToB = makeQueue();29+  const bToA = makeQueue();30+  return {31+    a: { send: async (m: Uint8Array) => aToB.push(m), receive: () => bToA.pull() },32+    b: { send: async (m: Uint8Array) => bToA.push(m), receive: () => aToB.pull() },33+  };34+}35+36+const deadTransport = {37+  send: async () => undefined,38+  receive: () => new Promise<Uint8Array>(() => undefined),39+};40+41+describe("PCH-02-009 — reject zero X25519 DH outputs", () => {42+  it("NK initiator aborts against an all-zero responder static key", async () => {43+    await expect(44+      noiseNKInitiator(ZERO_POINT, deadTransport.send, deadTransport.receive),45+    ).rejects.toThrow(/invalid shared key/);46+  });47+48+  it("NK responder aborts on an all-zero msg-1 ephemeral", async () => {49+    const ourStatic = generateKeyPair();50+    const msg1 = new Uint8Array(48);51+    await expect(52+      noiseNKResponder(53+        ourStatic.secretKey,54+        ourStatic.publicKey,55+        deadTransport.send,56+        async () => msg1,57+      ),58+    ).rejects.toThrow(/invalid shared key/);59+  });60+61+  it("XX responder aborts on an all-zero msg-1 ephemeral", async () => {62+    const ourStatic = generateKeyPair();63+    await expect(64+      noiseXXResponder(65+        ourStatic.secretKey,66+        ourStatic.publicKey,67+        deadTransport.send,68+        async () => ZERO_POINT.slice(),69+      ),70+    ).rejects.toThrow(/invalid shared key/);71+  });72+73+  it("honest NK handshake still completes and transports a frame (regression)", async () => {74+    const responderStatic = generateKeyPair();75+    const { a, b } = queuePair();76+77+    const [initiator, responder] = await Promise.all([78+      noiseNKInitiator(responderStatic.publicKey, a.send, a.receive),79+      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, b.send, b.receive),80+    ]);81+82+    const frame = new TextEncoder().encode("peach-nk-frame");83+    expect(responder.decrypt(initiator.encrypt(frame))).toEqual(frame);84+    expect(initiator.handshakeHash).toEqual(responder.handshakeHash);85+  });86+87+  it("honest XX handshake still completes and transports a frame (regression)", async () => {88+    const initiatorStatic = generateKeyPair();89+    const responderStatic = generateKeyPair();90+    const { a, b } = queuePair();91+92+    const [initiator, responder] = await Promise.all([93+      noiseXXInitiator(initiatorStatic.secretKey, initiatorStatic.publicKey, a.send, a.receive),94+      noiseXXResponder(responderStatic.secretKey, responderStatic.publicKey, b.send, b.receive),95+    ]);96+97+    expect(initiator.remoteStaticPublicKey).toEqual(responderStatic.publicKey);98+    expect(responder.remoteStaticPublicKey).toEqual(initiatorStatic.publicKey);99+100+    const frame = new TextEncoder().encode("peach-xx-frame");101+    expect(responder.decrypt(initiator.encrypt(frame))).toEqual(frame);102+  });103+});

view file