cf2649c42e87

fix: compare the challenge responder key with constantTimeEqualBytes instead of short-circuiting every (audit PB-26-605, v5.1.11)

time
2026-09-06 14:48 UTC · 1 month ago
machine
koh2
agent
openrouter/tencent/hy4-preview
session
PB-26-601..611 audit closeout
parent
2343fcaa7564
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/noise-js.test.ts+36 −0src/shared/noise-js.ts+3 −16
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.10",4+  "version": "5.1.11",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.1.10",5+  "version": "5.1.11",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.1.10",3+  "version": "5.1.11",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/noise-js.test.ts+36 −0
@@ -7,6 +7,7 @@77  * regenerate both this vector and any peer that pairs against this build.88  */99 10+import { readFile } from "node:fs/promises";1011 import { describe, expect, it } from "vitest";1112 import { generateKeyPair as x25519GenerateKeyPair } from "@stablelib/x25519";1213 @@ -239,6 +240,41 @@239240     );240241 241242     await expect(Promise.all([initiatorPromise, responderPromise])).rejects.toThrow(/proof did not verify/);243+  });244+});245+246+describe("PB-26-605 — authenticated comparisons are constant-time", () => {247+  // Both values at this site are public keys, so nothing leaks today. The guard248+  // exists so a future refactor cannot reuse a short-circuiting comparator here249+  // or move one to a secret-bearing site.250+  it("noise-js.ts compares byte strings only through constantTimeEqualBytes", async () => {251+    const source = await readFile(new URL("./noise-js.ts", import.meta.url), "utf8");252+    expect(source).toContain("constantTimeEqualBytes");253+    expect(source).not.toContain(".every(");254+  });255+256+  it("rejects a challenge carrying a substituted responder key", async () => {257+    const initiatorStatic = x25519GenerateKeyPair();258+    const responderStatic = x25519GenerateKeyPair();259+    const attacker = x25519GenerateKeyPair();260+    const challenge = new TextEncoder().encode(261+      JSON.stringify({262+        type: "CHALLENGE",263+        nonce_hex: "00".repeat(32),264+        responder_public_key_hex: bytesToHex(attacker.publicKey),265+      }),266+    );267+268+    await expect(269+      performInitiatorAuth(270+        { handshakeHash: new Uint8Array(32) } as never,271+        initiatorStatic.secretKey,272+        initiatorStatic.publicKey,273+        responderStatic.publicKey,274+        async () => undefined,275+        async () => challenge,276+      ),277+    ).rejects.toThrow(/does not match Noise handshake/);242278   });243279 });244280 

view file

src/shared/noise-js.ts+3 −16
@@ -17,6 +17,7 @@1717   rustNoiseNkInitiator,1818   rustNoiseNkResponder,1919 } from "./noise-core";20+import { constantTimeEqualBytes } from "./constant-time";2021 2122 const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223 @@ -248,17 +249,6 @@248249 }249250 250251 /**251- * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`252- * goes through this so a same-LAN attacker can't time the HMAC compare.253- */254-function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {255-  if (a.length !== b.length) return false;256-  let diff = 0;257-  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;258-  return diff === 0;259-}260-261-/**262252  * Drive the responder side of the post-handshake challenge/proof round. We263253  * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and264254  * verify. Returns the initiator's authenticated public key on success.@@ -315,7 +305,7 @@315305   mac.update(nonce);316306   const expected = mac.digest();317307 318-  if (!constantTimeEq(expected, claimedProof)) {308+  if (!constantTimeEqualBytes(expected, claimedProof)) {319309     throw new Error("auth proof did not verify");320310   }321311   return { initiatorPublicKey };@@ -342,10 +332,7 @@342332   // Defence against an off-key challenge: the responder's claimed key MUST be343333   // the one we Noise-NK-handshook against, otherwise an active MITM that344334   // managed to substitute a different key is rerouting the auth round.345-  if (346-    responderKeyFromChallenge.length !== responderPublicKey.length ||347-    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])348-  ) {335+  if (!constantTimeEqualBytes(responderKeyFromChallenge, responderPublicKey)) {349336     throw new Error("responder key in challenge does not match Noise handshake");350337   }351338 

view file