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
2 files src/shared/noise-js.test.ts+76 −1src/shared/noise-js.ts+19 −3
src/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/);

view file

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),

view file