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
↓ peach-browser-1e1194c3.tar.gzatlas at this save
files changed3 files
src/shared/noise-js.ts+8 −6src/shared/noise-xx.ts+8 −6src/shared/noise.reject-zero-dh.test.ts+103 −0src/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.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 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+});