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
↓ peach-browser-bc75d413.tar.gzatlas at this save
files changed6 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 −1manifest.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": {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",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",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)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+});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 () => {