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
↓ peach-browser-cf2649c4.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/noise-js.test.ts+36 −0src/shared/noise-js.ts+3 −16manifest.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": {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",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",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 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