bc75d413d54d

add JS-side small-order X25519 blocklist mirroring the Rust crypto core (PB-26-014 follow-up) — isSmallOrderPubkey over the nine libhydrogen u-coordinates guards the KK/NK initiator remote static and both auth-proof DH boundaries in noise-js.ts before the DH runs, TDD regression tests cover all nine entries plus a real key, PCH-02-009 zero-point test updated for the earlier small-order rejection path; gates green (tsc, biome, vitest src/shared + src/background 2763 tests); bump 5.0.15→5.0.16

time
2026-08-30 01:54 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
small-order dh rejection
parent
65cde98607e5
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/noise-js.ts+31 −0src/shared/noise-small-order-dh.test.ts+92 −0src/shared/noise.reject-zero-dh.test.ts+3 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.15",4+  "version": "5.0.16",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.0.15",5+  "version": "5.0.16",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.0.15",3+  "version": "5.0.16",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/noise-js.ts+31 −0
@@ -20,6 +20,25 @@2020 2121 const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2222 23+// libhydrogen's contributory-behaviour blocklist: the nine small-order X2551924+// u-coordinates. Mirrors X25519_SMALL_ORDER_PUBKEYS in crypto-core (PB-26-014)25+// so the TS-side DH boundaries fail before the DH, not just the WASM core.26+const X25519_SMALL_ORDER_PUBKEYS = new Set([27+  "00".repeat(32),28+  "01" + "00".repeat(31),29+  "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",30+  "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",31+  "ec" + "ff".repeat(30) + "7f",32+  "ed" + "ff".repeat(30) + "7f",33+  "ee" + "ff".repeat(30) + "7f",34+  "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",35+  "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",36+]);37+38+export function isSmallOrderPubkey(publicKey: Uint8Array): boolean {39+  return publicKey.length === 32 && X25519_SMALL_ORDER_PUBKEYS.has(bytesToHex(publicKey));40+}41+2342 function initProtocolHash(protocolName: string): Uint8Array {2443   const nameBytes = new TextEncoder().encode(protocolName);2544   if (nameBytes.length <= 32) {@@ -82,6 +101,9 @@82101   receive: () => Promise<Uint8Array>,83102   ephemeral?: NoiseKeyPair,84103 ): Promise<NoiseNKSession> {104+  if (isSmallOrderPubkey(responderStaticPublicKey)) {105+    throw new Error("noise: small-order X25519 responder static key");106+  }85107   return rustNoiseKkInitiator(86108     ourStaticPrivateKey,87109     ourStaticPublicKey,@@ -125,6 +147,9 @@125147   receive: () => Promise<Uint8Array>,126148   ephemeral?: NoiseKeyPair,127149 ): Promise<NoiseNKSession> {150+  if (isSmallOrderPubkey(responderStaticPublicKey)) {151+    throw new Error("noise: small-order X25519 responder static key");152+  }128153   return rustNoiseNkInitiator(129154     responderStaticPublicKey,130155     send,@@ -160,6 +185,9 @@160185 ): Uint8Array {161186   if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");162187   if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");188+  if (isSmallOrderPubkey(responderPublic)) {189+    throw new Error("small-order X25519 responder public key");190+  }163191 164192   const dh = sharedKey(initiatorPrivate, responderPublic, true);165193   const mac = new HMAC(SHA256, dh);@@ -280,6 +308,9 @@280308   }281309   if (claimedProof.length !== 32) {282310     throw new Error("proof must be 32 bytes");311+  }312+  if (isSmallOrderPubkey(initiatorPublicKey)) {313+    throw new Error("small-order X25519 initiator public key");283314   }284315 285316   // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)

view file

src/shared/noise-small-order-dh.test.ts+92 −0
@@ -0,0 +1,92 @@1+// PB-26-014 follow-up — the JS-side DH boundaries in noise-js.ts mirror the2+// Rust core's small-order X25519 blocklist (libhydrogen's contributory-behaviour3+// list). The WASM core rejects these before every handshake DH; the TypeScript4+// sharedKey calls must too, before doing the DH.5+6+import { describe, expect, it } from "vitest";7+import { generateKeyPair } from "@stablelib/x25519";8+import {9+  computeAuthProof,10+  hexToBytes,11+  isSmallOrderPubkey,12+  noiseKKInitiator,13+  performResponderAuth,14+} from "./noise-js";15+import type { NoiseNKSession } from "./noise-js";16+17+const SMALL_ORDER_PUBKEY_HEX = [18+  "00".repeat(32),19+  "01" + "00".repeat(31),20+  "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",21+  "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",22+  "ec" + "ff".repeat(30) + "7f",23+  "ed" + "ff".repeat(30) + "7f",24+  "ee" + "ff".repeat(30) + "7f",25+  "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",26+  "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",27+];28+29+const deadTransport = {30+  send: async () => undefined,31+  receive: () => new Promise<Uint8Array>(() => undefined),32+};33+34+describe("PB-26-014 follow-up — JS-side small-order X25519 rejection", () => {35+  it("flags all nine small-order u-coordinates; a real generated key passes", () => {36+    for (const hex of SMALL_ORDER_PUBKEY_HEX) {37+      expect(isSmallOrderPubkey(hexToBytes(hex))).toBe(true);38+    }39+    expect(isSmallOrderPubkey(generateKeyPair().publicKey)).toBe(false);40+  });41+42+  it("KK initiator aborts against a small-order responder static", async () => {43+    const initiator = generateKeyPair();44+    for (const hex of SMALL_ORDER_PUBKEY_HEX) {45+      await expect(46+        noiseKKInitiator(47+          initiator.secretKey,48+          initiator.publicKey,49+          hexToBytes(hex),50+          deadTransport.send,51+          deadTransport.receive,52+        ),53+      ).rejects.toThrow(/small-order/i);54+    }55+  });56+57+  it("computeAuthProof refuses a small-order responder key", () => {58+    const initiator = generateKeyPair();59+    const nonce = new Uint8Array(32);60+    const handshakeHash = new Uint8Array(32);61+    expect(() =>62+      computeAuthProof(63+        initiator.secretKey,64+        initiator.publicKey,65+        hexToBytes(SMALL_ORDER_PUBKEY_HEX[3]!),66+        nonce,67+        handshakeHash,68+      ),69+    ).toThrow(/small-order/i);70+  });71+72+  it("performResponderAuth refuses a PROOF frame with a small-order initiator key", async () => {73+    const responder = generateKeyPair();74+    const session = { handshakeHash: new Uint8Array(32) } as NoiseNKSession;75+    const proofFrame = new TextEncoder().encode(76+      JSON.stringify({77+        type: "PROOF",78+        initiator_public_key_hex: SMALL_ORDER_PUBKEY_HEX[0],79+        proof_hex: "00".repeat(32),80+      }),81+    );82+    await expect(83+      performResponderAuth(84+        session,85+        responder.secretKey,86+        responder.publicKey,87+        async () => undefined,88+        async () => proofFrame,89+      ),90+    ).rejects.toThrow(/small-order/i);91+  });92+});

view file

src/shared/noise.reject-zero-dh.test.ts+3 −1
@@ -39,9 +39,11 @@3939 4040 describe("PCH-02-009 — reject zero X25519 DH outputs", () => {4141   it("NK initiator aborts against an all-zero responder static key", async () => {42+    // The zero point is also small-order: the JS blocklist guard now rejects43+    // it before the DH even runs, hence the small-order message.4244     await expect(4345       noiseNKInitiator(ZERO_POINT, deadTransport.send, deadTransport.receive),44-    ).rejects.toThrow(/diffie|dh/i);46+    ).rejects.toThrow(/diffie|dh|small-order/i);4547   });4648 4749   it("NK responder aborts on an all-zero msg-1 ephemeral", async () => {

view file