048fd9fcc6de
fix: PCH-01-003b bind NK post-handshake auth proof to Noise handshakeHash on prover + verifier — extension-only round, unilateral-safe (Android uses SAS, no auth proof) (v3.8.1)
- time
- 2026-06-10 19:36 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- cf887cb18434
↓ peach-browser-048fd9fc.tar.gzatlas at this save
files changed2 files
src/shared/noise-js.test.ts+76 −1src/shared/noise-js.ts+19 −3src/shared/noise-js.test.ts+76 −1
@@ -22,13 +22,16 @@2222 // Pinned cross-check vector for HMAC-SHA256 auth proof.2323 const INITIATOR_PRIVATE = new Uint8Array(32).fill(0x01);2424 const NONCE = new Uint8Array(32).fill(0x03);25+// PCH-01-003b: the proof now binds the Noise handshake hash. Pin a fixed one.26+const HANDSHAKE_HASH = new Uint8Array(32).fill(0x04);2527 2628 const EXPECTED_INITIATOR_PUBLIC =2729 "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209";2830 const EXPECTED_RESPONDER_PUBLIC =2931 "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59";32+// Recaptured after PCH-01-003b prepended HANDSHAKE_HASH into the proof transcript.3033 const EXPECTED_PROOF =31- "39efee52ccc9d7eaa24fcfec9a4c75eaad75a6bf3319d9794d9227f2b1466752";34+ "d947d11a82892a65fdde94da5bbe6ca6034cf1f7e3268fc3ce5851b55f5813ce";3235 3336 function derivePublic(_privateKey: Uint8Array): Uint8Array {3437 const pair = x25519GenerateKeyPair();@@ -55,6 +58,7 @@5558 initiatorPublic,5659 responderPublic,5760 NONCE,61+ HANDSHAKE_HASH,5862 );5963 6064 expect(bytesToHex(proof)).toBe(EXPECTED_PROOF);@@ -161,6 +165,77 @@161165 responderStatic.publicKey,162166 responderEncSend,163167 responderEncRecv,168+ );169+170+ await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);171+ });172+});173+174+describe("PCH-01-003b — NK auth proof binds the Noise handshake hash", () => {175+ // performInitiatorAuth/performResponderAuth only read session.handshakeHash and176+ // the send/recv callbacks, so plaintext channels + minimal fake sessions suffice.177+ function plaintextChannels() {178+ const initToResp: Uint8Array[] = [];179+ const respToInit: Uint8Array[] = [];180+ const drain = async (q: Uint8Array[]) => {181+ while (q.length === 0) await new Promise<void>((r) => setTimeout(r, 0));182+ return q.shift()!;183+ };184+ return {185+ initiatorEncSend: async (d: Uint8Array) => { initToResp.push(d); },186+ initiatorEncRecv: async () => drain(respToInit),187+ responderEncSend: async (d: Uint8Array) => { respToInit.push(d); },188+ responderEncRecv: async () => drain(initToResp),189+ };190+ }191+192+ it("auth_proof_roundtrips_with_handshake_hash_binding", async () => {193+ const initiatorStatic = x25519GenerateKeyPair();194+ const responderStatic = x25519GenerateKeyPair();195+ const hash = new Uint8Array(32).fill(0x07);196+ const ch = plaintextChannels();197+198+ const [, authResult] = await Promise.all([199+ performInitiatorAuth(200+ { handshakeHash: hash } as never,201+ initiatorStatic.secretKey,202+ initiatorStatic.publicKey,203+ responderStatic.publicKey,204+ ch.initiatorEncSend,205+ ch.initiatorEncRecv,206+ ),207+ performResponderAuth(208+ { handshakeHash: hash } as never,209+ responderStatic.secretKey,210+ responderStatic.publicKey,211+ ch.responderEncSend,212+ ch.responderEncRecv,213+ ),214+ ]);215+216+ expect(bytesToHex(authResult.initiatorPublicKey)).toBe(bytesToHex(initiatorStatic.publicKey));217+ });218+219+ it("auth_proof_rejected_when_handshake_hash_differs", async () => {220+ const initiatorStatic = x25519GenerateKeyPair();221+ const responderStatic = x25519GenerateKeyPair();222+ const ch = plaintextChannels();223+ // Same DH/keys/nonce, but the two sides hold DIFFERENT handshake hashes —224+ // as a cross-session replay would. The proof must fail to verify.225+ const initiatorPromise = performInitiatorAuth(226+ { handshakeHash: new Uint8Array(32).fill(0x07) } as never,227+ initiatorStatic.secretKey,228+ initiatorStatic.publicKey,229+ responderStatic.publicKey,230+ ch.initiatorEncSend,231+ ch.initiatorEncRecv,232+ );233+ const responderPromise = performResponderAuth(234+ { handshakeHash: new Uint8Array(32).fill(0x09) } as never,235+ responderStatic.secretKey,236+ responderStatic.publicKey,237+ ch.responderEncSend,238+ ch.responderEncRecv,164239 );165240 166241 await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);src/shared/noise-js.ts+19 −3
@@ -156,11 +156,17 @@156156 initiatorPublic: Uint8Array,157157 responderPublic: Uint8Array,158158 nonce: Uint8Array,159+ handshakeHash: Uint8Array,159160 ): Uint8Array {160161 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");162+ if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");161163 162164 const dh = sharedKey(initiatorPrivate, responderPublic);163165 const mac = new HMAC(SHA256, dh);166+ // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed167+ // onto a different handshake. handshakeHash is prepended; both sides hold the168+ // identical value and feed it in the same position.169+ mac.update(handshakeHash);164170 mac.update(AUTH_DOMAIN);165171 mac.update(initiatorPublic);166172 mac.update(responderPublic);@@ -313,7 +319,7 @@313319 sendEncryptedJson: (data: Uint8Array) => Promise<void>,314320 receiveEncryptedJson: () => Promise<Uint8Array>,315321 ): Promise<{ initiatorPublicKey: Uint8Array }> {316- void session;322+ // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.317323 318324 const nonce = new Uint8Array(32);319325 crypto.getRandomValues(nonce);@@ -343,6 +349,9 @@343349 // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)344350 const dh = sharedKey(ourPrivateKey, initiatorPublicKey);345351 const mac = new HMAC(SHA256, dh);352+ // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the353+ // existing domain/keys/nonce transcript. Same value, same position on both ends.354+ mac.update(session.handshakeHash);346355 mac.update(AUTH_DOMAIN);347356 mac.update(initiatorPublicKey);348357 mac.update(ourPublicKey);@@ -363,7 +372,8 @@363372 sendEncryptedJson: (data: Uint8Array) => Promise<void>,364373 receiveEncryptedJson: () => Promise<Uint8Array>,365374 ): Promise<void> {366- void session; // hash bound into MAC via the DH key only — kept for future fields375+ // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding376+ // it to this exact session (was previously bound via the DH key only).367377 368378 const challengeBytes = await receiveEncryptedJson();369379 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;@@ -382,7 +392,13 @@382392 throw new Error("responder key in challenge does not match Noise handshake");383393 }384394 385- const proof = computeAuthProof(ourPrivateKey, ourPublicKey, responderPublicKey, nonce);395+ const proof = computeAuthProof(396+ ourPrivateKey,397+ ourPublicKey,398+ responderPublicKey,399+ nonce,400+ session.handshakeHash,401+ );386402 const proofFrame: AuthProofFrame = {387403 type: "PROOF",388404 initiator_public_key_hex: bytesToHex(ourPublicKey),