From b69ceaa365c80e65ed0c83f0934f9f1af996892f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:15:36 +0000 Subject: [PATCH 1/4] test: cover the rejection of small-order Ed25519 points in verify crypto.verify() and crypto.subtle.verify() return false when the Ed25519 public key or the R of the signature is a small-order point (89fbb78065). No test covered that. Add the cases and the fixture of the superseded pull request 46509: five cases for node:crypto, five for WebCrypto, over fourteen encodings that each have a witness message. Update test-webcrypto-sign-verify-eddsa.js to the Node v26.12.1 copy, which has Node's small-order vector. --- .../js/node/crypto/crypto.key-objects.test.ts | 77 ++++++++++ .../test-webcrypto-sign-verify-eddsa.js | 67 +++++++-- test/js/web/crypto/ed25519-small-order.ts | 134 ++++++++++++++++++ test/js/web/crypto/web-crypto.test.ts | 90 +++++++++++- 4 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 test/js/web/crypto/ed25519-small-order.ts diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index f853fb66cf6e..412fb918f85d 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -31,6 +31,13 @@ import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; import { totalmem } from "node:os"; import { createContext, runInContext, runInThisContext, Script } from "node:vm"; import path from "path"; +import { + ED25519_IDENTITY, + ED25519_SMALL_ORDER_POINTS, + hexBytes, + identityRSignature, + zeroSignatureWithR, +} from "../../web/crypto/ed25519-small-order"; function readFile(...args: [path: string, encoding: BufferEncoding]) { const result = fs.readFileSync(...args); @@ -1883,6 +1890,76 @@ test("Ed25519 should work", async () => { } }); +// https://github.com/oven-sh/bun/issues/46252 +describe("Ed25519 verify rejects small-order points", () => { + const jwkKey = (hex: string) => + createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(hexBytes(hex)).toString("base64url") }, + format: "jwk", + }); + + test("a small-order public key imports but verifies nothing", () => { + const results: Record = {}; + for (const { name, hex, message } of ED25519_SMALL_ORDER_POINTS) { + const key = jwkKey(hex); + expect(key.asymmetricKeyType).toBe("ed25519"); + results[name] = verify(null, Buffer.from(message), key, zeroSignatureWithR(ED25519_IDENTITY)); + } + expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); + }); + + test("a small-order R is rejected", () => { + const results: Record = {}; + for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { + if (messageAsR === undefined) continue; + results[name] = verify(null, Buffer.from(messageAsR), jwkKey(hex), zeroSignatureWithR(hex)); + } + expect(Object.keys(results)).toHaveLength(8); + expect(results).toEqual(Object.fromEntries(Object.keys(results).map(name => [name, false]))); + }); + + test("an identity R under an honest key is rejected", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const message = Buffer.from("hello"); + const signature = identityRSignature(privateKey.export({ format: "jwk" }), message); + expect(verify(null, message, publicKey, signature)).toBeFalse(); + }); + + test("every key input form and the callback form reject the identity key", async () => { + const keyObject = jwkKey(ED25519_IDENTITY); + const message = Buffer.from("any message at all"); + const signature = zeroSignatureWithR(ED25519_IDENTITY); + const pem = keyObject.export({ format: "pem", type: "spki" }) as string; + const der = keyObject.export({ format: "der", type: "spki" }) as Buffer; + expect(createPublicKey(pem).equals(keyObject)).toBeTrue(); + + const { promise, resolve, reject } = Promise.withResolvers(); + verify(null, message, keyObject, signature, (err, result) => (err ? reject(err) : resolve(result))); + + expect({ + keyObject: verify(null, message, keyObject, signature), + pem: verify(null, message, pem, signature), + der: verify(null, message, { key: der, format: "der", type: "spki" }, signature), + cryptoKey: verify(null, message, keyObject.toCryptoKey("Ed25519", false, ["verify"]), signature), + callback: await promise, + }).toEqual({ keyObject: false, pem: false, der: false, cryptoKey: false, callback: false }); + }); + + test("a real signature still verifies and a digest is still rejected", async () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const message = Buffer.from("hello"); + const signature = sign(null, message, privateKey); + expect(verify(null, message, publicKey, signature)).toBeTrue(); + const { promise, resolve, reject } = Promise.withResolvers(); + verify(null, message, publicKey, signature, (err, result) => (err ? reject(err) : resolve(result))); + expect(await promise).toBeTrue(); + const tampered = Buffer.from(signature); + tampered[0] ^= 1; + expect(verify(null, message, publicKey, tampered)).toBeFalse(); + expect(() => verify("sha256", message, publicKey, signature)).toThrow(); + }); +}); + test("ECDSA should work", async () => { const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); diff --git a/test/js/node/test/parallel/test-webcrypto-sign-verify-eddsa.js b/test/js/node/test/parallel/test-webcrypto-sign-verify-eddsa.js index 1dfd3ddd76a6..2c1b0bd44001 100644 --- a/test/js/node/test/parallel/test-webcrypto-sign-verify-eddsa.js +++ b/test/js/node/test/parallel/test-webcrypto-sign-verify-eddsa.js @@ -5,7 +5,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, isBoringSSL } = require('../common/crypto'); const assert = require('assert'); const crypto = require('crypto'); @@ -15,6 +15,44 @@ const vectors = require('../fixtures/crypto/eddsa')(); const supportsContext = hasOpenSSL(3, 2); +const smallOrderVerifyVectors = [ + { + name: 'Ed25519', + publicKey: Buffer.from( + 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa', + 'hex'), + signature: Buffer.from( + 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a' + + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex'), + data: Buffer.from( + '8c93255d71dcab10e8f379c26200f3c7bd5f09d9bc3068d3ef4edeb4853022b6', + 'hex'), + }, +]; + +if (!isBoringSSL) { + smallOrderVerifyVectors.push({ + name: 'Ed448', + publicKey: Buffer.concat([Buffer.from([1]), Buffer.alloc(56)]), + signature: Buffer.concat([Buffer.from([1]), Buffer.alloc(113)]), + data: Buffer.from([1, 2, 3]), + }); +} + +async function testSmallOrderVerify({ name, publicKey, signature, data }) { + const key = await subtle.importKey( + 'raw', + publicKey, + { name }, + false, + ['verify']); + + assert.strictEqual( + await subtle.verify({ name }, key, signature, data), + false); +} + async function testVerify({ name, context, publicKeyBuffer, @@ -54,7 +92,7 @@ async function testVerify({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -114,14 +152,12 @@ async function testVerify({ name, message: /Key algorithm mismatch/ }); - if (name === 'Ed448' && supportsContext) { + if (name === 'Ed448') { // Test failure when too long context await assert.rejects( - subtle.verify({ name, context: new Uint8Array(256) }, publicKey, signature, data), (err) => { - assert.strictEqual(err.name, 'OperationError'); - assert.strictEqual(err.cause.code, 'ERR_OUT_OF_RANGE'); - assert.strictEqual(err.cause.message, 'context string must be at most 255 bytes'); - return true; + subtle.verify({ name, context: new Uint8Array(256) }, publicKey, signature, data), { + name: 'OperationError', + message: 'ContextParams.context must be at most 255 bytes', }); } @@ -181,7 +217,7 @@ async function testSign({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -240,14 +276,12 @@ async function testSign({ name, message: /Key algorithm mismatch/ }); - if (name === 'Ed448' && supportsContext) { + if (name === 'Ed448') { // Test failure when too long context await assert.rejects( - subtle.sign({ name, context: new Uint8Array(256) }, privateKey, data), (err) => { - assert.strictEqual(err.name, 'OperationError'); - assert.strictEqual(err.cause.code, 'ERR_OUT_OF_RANGE'); - assert.strictEqual(err.cause.message, 'context string must be at most 255 bytes'); - return true; + subtle.sign({ name, context: new Uint8Array(256) }, privateKey, data), { + name: 'OperationError', + message: 'ContextParams.context must be at most 255 bytes', }); } } @@ -260,6 +294,9 @@ async function testSign({ name, variations.push(testVerify(vector)); variations.push(testSign(vector)); }); + smallOrderVerifyVectors.forEach((vector) => { + variations.push(testSmallOrderVerify(vector)); + }); await Promise.all(variations); })().then(common.mustCall()); diff --git a/test/js/web/crypto/ed25519-small-order.ts b/test/js/web/crypto/ed25519-small-order.ts new file mode 100644 index 000000000000..0b0843ef5750 --- /dev/null +++ b/test/js/web/crypto/ed25519-small-order.ts @@ -0,0 +1,134 @@ +import { createHash } from "node:crypto"; + +// Small-order Ed25519 points, in the RFC 8032 encoding, as a public key or as +// the R half of a signature. The cofactorless verify equation +// [S]B = R + [h]A holds for every message when A and R are small-order and +// S = 0 (with h chosen to be a multiple of the point order), so a verifier +// that does not reject these points accepts a forged signature for any +// message under such a key. Node rejects all of them (nodejs/node#64026). +// +// `message` is a message for which the unfiltered equation holds with +// R = identity and S = 0 (`messageAsR`: with R = A and S = 0). + +export const ED25519_IDENTITY = "0100000000000000000000000000000000000000000000000000000000000000"; + +export const ED25519_SMALL_ORDER_POINTS: { name: string; hex: string; message: string; messageAsR?: string }[] = [ + { name: "identity", hex: ED25519_IDENTITY, message: "msg 0", messageAsR: "msg 0" }, + { + name: "order 2", + hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + message: "msg 1", + messageAsR: "msg 0", + }, + { + name: "order 4 (a)", + hex: "0000000000000000000000000000000000000000000000000000000000000080", + message: "msg 0", + messageAsR: "msg 3", + }, + { + name: "order 4 (b)", + hex: "0000000000000000000000000000000000000000000000000000000000000000", + message: "msg 1", + messageAsR: "msg 1", + }, + { + name: "order 8 (a)", + hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + message: "msg 1", + messageAsR: "msg 6", + }, + { + name: "order 8 (b)", + hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + message: "msg 1", + messageAsR: "msg 7", + }, + { + name: "order 8 (c)", + hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + message: "msg 1", + messageAsR: "msg 1", + }, + { + name: "order 8 (d)", + hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + message: "msg 7", + messageAsR: "msg 13", + }, + // Non-canonical encodings of the same points. BoringSSL decodes y >= p + // (reduced mod p) and x = 0 with the sign bit set, so each of these is + // accepted as a public key. None of them can appear as R: ED25519_verify + // re-encodes R canonically and compares the bytes. + { + name: "identity (x = 0, sign bit set)", + hex: "0100000000000000000000000000000000000000000000000000000000000080", + message: "msg 0", + }, + { + name: "order 2 (x = 0, sign bit set)", + hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + message: "msg 0", + }, + { + name: "identity (y = p + 1)", + hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + message: "msg 0", + }, + { + name: "identity (y = p + 1, sign bit set)", + hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + message: "msg 0", + }, + { + name: "order 4 (y = p, sign bit set)", + hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + message: "msg 8", + }, + { + name: "order 4 (y = p)", + hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + message: "msg 8", + }, +]; + +export function hexBytes(hex: string): Uint8Array { + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +// Signature with the given R and S = 0. +export function zeroSignatureWithR(rHex: string): Uint8Array { + const signature = new Uint8Array(64); + signature.set(hexBytes(rHex), 0); + return signature; +} + +const L = 2n ** 252n + 27742317777372353535851937790883648493n; + +function scalarFromLE(bytes: Uint8Array): bigint { + return BigInt("0x" + Buffer.from(bytes).reverse().toString("hex")); +} + +// A signature under an honest key pair whose R is the identity point: +// R = [0]B, so S = h * a mod L with h = SHA-512(R || A || M) satisfies +// [S]B = R + [h]A. The cofactorless check alone accepts it. Only the key +// owner can build it, so it is not a forgery, but a strict verifier (Node, +// libsodium, ed25519-dalek verify_strict) rejects a small-order R. +// `privateJwk` is the jwk export of the private key (d = seed, x = A). +export function identityRSignature(privateJwk: { d?: string; x?: string }, message: Uint8Array): Uint8Array { + const seed = Buffer.from(privateJwk.d!, "base64url"); + const A = Buffer.from(privateJwk.x!, "base64url"); + const expanded = createHash("sha512").update(seed).digest(); + const aBytes = Uint8Array.from(expanded.subarray(0, 32)); + aBytes[0] &= 248; + aBytes[31] &= 127; + aBytes[31] |= 64; + const a = scalarFromLE(aBytes); + const R = hexBytes(ED25519_IDENTITY); + const h = scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L; + const S = (h * a) % L; + const signature = new Uint8Array(64); + signature.set(R, 0); + signature.set(Buffer.from(S.toString(16).padStart(64, "0"), "hex").reverse(), 32); + return signature; +} diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index c469528f324f..48072813f583 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -1,7 +1,14 @@ import { spawnSync } from "bun"; import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe } from "harness"; -import { createCipheriv, createSecretKey, KeyObject, type webcrypto } from "node:crypto"; +import { createCipheriv, createPublicKey, createSecretKey, KeyObject, type webcrypto } from "node:crypto"; +import { + ED25519_IDENTITY, + ED25519_SMALL_ORDER_POINTS, + hexBytes, + identityRSignature, + zeroSignatureWithR, +} from "./ed25519-small-order"; type AlgorithmIdentifier = webcrypto.AlgorithmIdentifier; type HmacImportParams = webcrypto.HmacImportParams; @@ -572,6 +579,87 @@ describe("Ed25519", () => { } }); }); + + // https://github.com/oven-sh/bun/issues/46252 + describe("verify rejects small-order points", () => { + const encoder = new TextEncoder(); + + it("imports a small-order public key and verifies nothing under it", async () => { + const results: Record = {}; + for (const { name, hex, message } of ED25519_SMALL_ORDER_POINTS) { + const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", true, ["verify"]); + expect(key.type).toBe("public"); + results[name] = await crypto.subtle.verify( + "Ed25519", + key, + zeroSignatureWithR(ED25519_IDENTITY), + encoder.encode(message), + ); + } + expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); + }); + + it("rejects a small-order R", async () => { + const results: Record = {}; + for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { + if (messageAsR === undefined) continue; + const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", false, ["verify"]); + results[name] = await crypto.subtle.verify("Ed25519", key, zeroSignatureWithR(hex), encoder.encode(messageAsR)); + } + expect(Object.keys(results)).toHaveLength(8); + expect(results).toEqual(Object.fromEntries(Object.keys(results).map(name => [name, false]))); + }); + + it("rejects an identity R under an honest key", async () => { + const { publicKey, privateKey } = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const message = encoder.encode("hello"); + const signature = identityRSignature(await crypto.subtle.exportKey("jwk", privateKey), message); + expect(await crypto.subtle.verify("Ed25519", publicKey, signature, message)).toBe(false); + }); + + it("rejects the identity key through jwk, spki and KeyObject.toCryptoKey", async () => { + const identity = hexBytes(ED25519_IDENTITY); + const signature = zeroSignatureWithR(ED25519_IDENTITY); + const message = encoder.encode("any message at all"); + const keyObject = createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(identity).toString("base64url") }, + format: "jwk", + }); + const keys = { + jwk: await crypto.subtle.importKey("jwk", keyObject.export({ format: "jwk" }), "Ed25519", true, ["verify"]), + spki: await crypto.subtle.importKey( + "spki", + keyObject.export({ format: "der", type: "spki" }), + "Ed25519", + true, + ["verify"], + ), + toCryptoKey: keyObject.toCryptoKey("Ed25519", true, ["verify"]), + }; + const results: Record = {}; + for (const [name, key] of Object.entries(keys)) { + results[name] = await crypto.subtle.verify("Ed25519", key, signature, message); + } + expect(results).toEqual({ jwk: false, spki: false, toCryptoKey: false }); + }); + + it("still accepts a real signature", async () => { + const { publicKey, privateKey } = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const message = encoder.encode("hello"); + const signature = new Uint8Array(await crypto.subtle.sign("Ed25519", privateKey, message)); + expect(await crypto.subtle.verify("Ed25519", publicKey, signature, message)).toBe(true); + const tampered = new Uint8Array(signature); + tampered[0] ^= 1; + expect(await crypto.subtle.verify("Ed25519", publicKey, tampered, message)).toBe(false); + expect(await crypto.subtle.verify("Ed25519", publicKey, signature.subarray(0, 63), message)).toBe(false); + }); + }); }); describe("ChaCha20-Poly1305 and AKP review fixes", () => { From 3e1469b1db2674710b9e3ef827f03a7cb73bb659 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:14:32 +0000 Subject: [PATCH 2/4] test: reach the public-key check of the small-order Ed25519 filter Every case for a small-order public key used a signature whose R is the identity point, so the check on R rejected it before the key was read. Add a forged signature with an honest R for each of the 14 encodings, in both suites: R is the public key of the RFC 8032 TEST 1 seed, S is the scalar of that seed, and the message makes h a multiple of 8. Before 89fbb78065 all 14 verify as true. Assert the error code of a digest on an Ed25519 key in place of a bare toThrow(). --- .../js/node/crypto/crypto.key-objects.test.ts | 29 ++++++++++- test/js/web/crypto/ed25519-small-order.ts | 48 +++++++++++++++---- test/js/web/crypto/web-crypto.test.ts | 12 +++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index 412fb918f85d..b0f8ce030285 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -32,8 +32,11 @@ import { totalmem } from "node:os"; import { createContext, runInContext, runInThisContext, Script } from "node:vm"; import path from "path"; import { + ED25519_HONEST_POINT, + ED25519_HONEST_SEED, ED25519_IDENTITY, ED25519_SMALL_ORDER_POINTS, + forgeryWithHonestR, hexBytes, identityRSignature, zeroSignatureWithR, @@ -1908,6 +1911,27 @@ describe("Ed25519 verify rejects small-order points", () => { expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); }); + // R is an honest point here, so the check on R cannot be what rejects the signature. + test("a small-order public key is rejected when R is not a small-order point", () => { + // The R of these signatures is the public key of the seed whose scalar is their S. + const seedKey = createPrivateKey({ + key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), hexBytes(ED25519_HONEST_SEED)]), + format: "der", + type: "pkcs8", + }); + expect(Buffer.from(createPublicKey(seedKey).export({ format: "jwk" }).x!, "base64url").toString("hex")).toBe( + ED25519_HONEST_POINT, + ); + + const results: Record = {}; + for (const { name, hex } of ED25519_SMALL_ORDER_POINTS) { + const { message, signature } = forgeryWithHonestR(hex); + expect(Buffer.from(signature.subarray(0, 32)).toString("hex")).toBe(ED25519_HONEST_POINT); + results[name] = verify(null, message, jwkKey(hex), signature); + } + expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); + }); + test("a small-order R is rejected", () => { const results: Record = {}; for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { @@ -1956,7 +1980,10 @@ describe("Ed25519 verify rejects small-order points", () => { const tampered = Buffer.from(signature); tampered[0] ^= 1; expect(verify(null, message, publicKey, tampered)).toBeFalse(); - expect(() => verify("sha256", message, publicKey, signature)).toThrow(); + // BoringSSL signs Ed25519 over the message only. It refuses a digest. + expect(() => verify("sha256", message, publicKey, signature)).toThrow( + expect.objectContaining({ name: "Error", code: "ERR_OSSL_EVP_COMMAND_NOT_SUPPORTED" }), + ); }); }); diff --git a/test/js/web/crypto/ed25519-small-order.ts b/test/js/web/crypto/ed25519-small-order.ts index 0b0843ef5750..14cf8ccc6dfd 100644 --- a/test/js/web/crypto/ed25519-small-order.ts +++ b/test/js/web/crypto/ed25519-small-order.ts @@ -109,6 +109,19 @@ function scalarFromLE(bytes: Uint8Array): bigint { return BigInt("0x" + Buffer.from(bytes).reverse().toString("hex")); } +function scalarToLE(scalar: bigint): Uint8Array { + return Uint8Array.from(Buffer.from(scalar.toString(16).padStart(64, "0"), "hex").reverse()); +} + +// The secret scalar a of a seed (RFC 8032, section 5.1.5). The public key of the seed is [a]B. +function scalarOfSeed(seed: Uint8Array): bigint { + const aBytes = Uint8Array.from(createHash("sha512").update(seed).digest().subarray(0, 32)); + aBytes[0] &= 248; + aBytes[31] &= 127; + aBytes[31] |= 64; + return scalarFromLE(aBytes); +} + // A signature under an honest key pair whose R is the identity point: // R = [0]B, so S = h * a mod L with h = SHA-512(R || A || M) satisfies // [S]B = R + [h]A. The cofactorless check alone accepts it. Only the key @@ -116,19 +129,36 @@ function scalarFromLE(bytes: Uint8Array): bigint { // libsodium, ed25519-dalek verify_strict) rejects a small-order R. // `privateJwk` is the jwk export of the private key (d = seed, x = A). export function identityRSignature(privateJwk: { d?: string; x?: string }, message: Uint8Array): Uint8Array { - const seed = Buffer.from(privateJwk.d!, "base64url"); + const a = scalarOfSeed(Buffer.from(privateJwk.d!, "base64url")); const A = Buffer.from(privateJwk.x!, "base64url"); - const expanded = createHash("sha512").update(seed).digest(); - const aBytes = Uint8Array.from(expanded.subarray(0, 32)); - aBytes[0] &= 248; - aBytes[31] &= 127; - aBytes[31] |= 64; - const a = scalarFromLE(aBytes); const R = hexBytes(ED25519_IDENTITY); const h = scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L; - const S = (h * a) % L; const signature = new Uint8Array(64); signature.set(R, 0); - signature.set(Buffer.from(S.toString(16).padStart(64, "0"), "hex").reverse(), 32); + signature.set(scalarToLE((h * a) % L), 32); return signature; } + +// RFC 8032, section 7.1, TEST 1: a seed and its public key [a]B, a point of prime order. +export const ED25519_HONEST_SEED = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; +export const ED25519_HONEST_POINT = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + +// A forged signature under the small-order public key `hex` whose R is not +// small-order, so that only the check on the public key can reject it. +// R = [a]B and S = a for the honest scalar a above. The message is the first +// "msg N" for which h = SHA-512(R || A || M) mod L is a multiple of 8. Then +// [h]A is the identity for every small-order A, and the unfiltered equation +// [S]B = R + [h]A holds. +export function forgeryWithHonestR(hex: string): { message: Uint8Array; signature: Uint8Array } { + const R = hexBytes(ED25519_HONEST_POINT); + const A = hexBytes(hex); + const signature = new Uint8Array(64); + signature.set(R, 0); + signature.set(scalarToLE(scalarOfSeed(hexBytes(ED25519_HONEST_SEED)) % L), 32); + for (let i = 0; i < 256; i++) { + const message = Uint8Array.from(Buffer.from(`msg ${i}`)); + const h = scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L; + if (h % 8n === 0n) return { message, signature }; + } + throw new Error(`no message makes h a multiple of 8 for ${hex}`); +} diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index 48072813f583..45ae49b4b400 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -5,6 +5,7 @@ import { createCipheriv, createPublicKey, createSecretKey, KeyObject, type webcr import { ED25519_IDENTITY, ED25519_SMALL_ORDER_POINTS, + forgeryWithHonestR, hexBytes, identityRSignature, zeroSignatureWithR, @@ -599,6 +600,17 @@ describe("Ed25519", () => { expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); }); + // R is an honest point here, so the check on R cannot be what rejects the signature. + it("rejects a small-order public key when R is not a small-order point", async () => { + const results: Record = {}; + for (const { name, hex } of ED25519_SMALL_ORDER_POINTS) { + const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", false, ["verify"]); + const { message, signature } = forgeryWithHonestR(hex); + results[name] = await crypto.subtle.verify("Ed25519", key, signature, message); + } + expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); + }); + it("rejects a small-order R", async () => { const results: Record = {}; for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { From 3d50816c3be453ad8c4d513bf72ca3a719a68e42 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:21:25 +0000 Subject: [PATCH 3/4] test: compare the forged R with the public key of the seed The assertion on the first 32 bytes of the forged signature compared a constant with itself. Compare the R of every forged signature with the public key that node:crypto derives from the seed. --- test/js/node/crypto/crypto.key-objects.test.ts | 11 +++++------ test/js/web/crypto/ed25519-small-order.ts | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index b0f8ce030285..82663cb18292 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -32,7 +32,6 @@ import { totalmem } from "node:os"; import { createContext, runInContext, runInThisContext, Script } from "node:vm"; import path from "path"; import { - ED25519_HONEST_POINT, ED25519_HONEST_SEED, ED25519_IDENTITY, ED25519_SMALL_ORDER_POINTS, @@ -1913,22 +1912,22 @@ describe("Ed25519 verify rejects small-order points", () => { // R is an honest point here, so the check on R cannot be what rejects the signature. test("a small-order public key is rejected when R is not a small-order point", () => { - // The R of these signatures is the public key of the seed whose scalar is their S. + // The public key of the seed whose scalar is the S of these signatures. Their R must be this point. const seedKey = createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), hexBytes(ED25519_HONEST_SEED)]), format: "der", type: "pkcs8", }); - expect(Buffer.from(createPublicKey(seedKey).export({ format: "jwk" }).x!, "base64url").toString("hex")).toBe( - ED25519_HONEST_POINT, - ); + const honestPoint = Buffer.from(createPublicKey(seedKey).export({ format: "jwk" }).x!, "base64url"); const results: Record = {}; + const forgedRs = new Set(); for (const { name, hex } of ED25519_SMALL_ORDER_POINTS) { const { message, signature } = forgeryWithHonestR(hex); - expect(Buffer.from(signature.subarray(0, 32)).toString("hex")).toBe(ED25519_HONEST_POINT); + forgedRs.add(Buffer.from(signature.subarray(0, 32)).toString("hex")); results[name] = verify(null, message, jwkKey(hex), signature); } + expect([...forgedRs]).toEqual([honestPoint.toString("hex")]); expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); }); diff --git a/test/js/web/crypto/ed25519-small-order.ts b/test/js/web/crypto/ed25519-small-order.ts index 14cf8ccc6dfd..8acf124d25a5 100644 --- a/test/js/web/crypto/ed25519-small-order.ts +++ b/test/js/web/crypto/ed25519-small-order.ts @@ -141,7 +141,7 @@ export function identityRSignature(privateJwk: { d?: string; x?: string }, messa // RFC 8032, section 7.1, TEST 1: a seed and its public key [a]B, a point of prime order. export const ED25519_HONEST_SEED = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; -export const ED25519_HONEST_POINT = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; +const ED25519_HONEST_POINT = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; // A forged signature under the small-order public key `hex` whose R is not // small-order, so that only the check on the public key can reject it. From 004d23f9301dbdab1643c0b82a8c30381950607b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:34:06 +0000 Subject: [PATCH 4/4] test: derive the message of every small-order Ed25519 forgery The fixture listed a message for each point as a constant that nothing checked. An entry with a wrong message would verify as false on every build, and the suites could not tell. Every forgery now searches for its own message, as the one with an honest R does. The constants were correct: before 89fbb78065 all 14 entries verify as true as a key, and all 8 canonical ones as R, with the old messages and with the derived ones. --- .../js/node/crypto/crypto.key-objects.test.ts | 14 +- test/js/web/crypto/ed25519-small-order.ts | 130 ++++++++---------- test/js/web/crypto/web-crypto.test.ts | 19 ++- 3 files changed, 74 insertions(+), 89 deletions(-) diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index 82663cb18292..43d683359e74 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -36,6 +36,8 @@ import { ED25519_IDENTITY, ED25519_SMALL_ORDER_POINTS, forgeryWithHonestR, + forgeryWithIdentityR, + forgeryWithKeyAsR, hexBytes, identityRSignature, zeroSignatureWithR, @@ -1902,10 +1904,11 @@ describe("Ed25519 verify rejects small-order points", () => { test("a small-order public key imports but verifies nothing", () => { const results: Record = {}; - for (const { name, hex, message } of ED25519_SMALL_ORDER_POINTS) { + for (const { name, hex } of ED25519_SMALL_ORDER_POINTS) { const key = jwkKey(hex); expect(key.asymmetricKeyType).toBe("ed25519"); - results[name] = verify(null, Buffer.from(message), key, zeroSignatureWithR(ED25519_IDENTITY)); + const { message, signature } = forgeryWithIdentityR(hex); + results[name] = verify(null, message, key, signature); } expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); }); @@ -1933,9 +1936,10 @@ describe("Ed25519 verify rejects small-order points", () => { test("a small-order R is rejected", () => { const results: Record = {}; - for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { - if (messageAsR === undefined) continue; - results[name] = verify(null, Buffer.from(messageAsR), jwkKey(hex), zeroSignatureWithR(hex)); + for (const { name, hex, canonical } of ED25519_SMALL_ORDER_POINTS) { + if (!canonical) continue; + const { message, signature } = forgeryWithKeyAsR(hex); + results[name] = verify(null, message, jwkKey(hex), signature); } expect(Object.keys(results)).toHaveLength(8); expect(results).toEqual(Object.fromEntries(Object.keys(results).map(name => [name, false]))); diff --git a/test/js/web/crypto/ed25519-small-order.ts b/test/js/web/crypto/ed25519-small-order.ts index 8acf124d25a5..7db97c19e544 100644 --- a/test/js/web/crypto/ed25519-small-order.ts +++ b/test/js/web/crypto/ed25519-small-order.ts @@ -1,94 +1,58 @@ import { createHash } from "node:crypto"; -// Small-order Ed25519 points, in the RFC 8032 encoding, as a public key or as -// the R half of a signature. The cofactorless verify equation -// [S]B = R + [h]A holds for every message when A and R are small-order and -// S = 0 (with h chosen to be a multiple of the point order), so a verifier -// that does not reject these points accepts a forged signature for any -// message under such a key. Node rejects all of them (nodejs/node#64026). +// Small-order Ed25519 points, in the RFC 8032 encoding, as a public key A or +// as the R half of a signature. The multiples of such a point repeat after at +// most 8 steps. So the cofactorless verify equation [S]B = R + [h]A holds +// with no private key when h = SHA-512(R || A || M) mod L has the right value +// modulo 8, and a verifier that does not reject these points accepts a forged +// signature. Node rejects all of them (nodejs/node#64026). // -// `message` is a message for which the unfiltered equation holds with -// R = identity and S = 0 (`messageAsR`: with R = A and S = 0). +// Each forgery below searches for its own message. So every entry proves +// that the unfiltered equation holds for it. export const ED25519_IDENTITY = "0100000000000000000000000000000000000000000000000000000000000000"; -export const ED25519_SMALL_ORDER_POINTS: { name: string; hex: string; message: string; messageAsR?: string }[] = [ - { name: "identity", hex: ED25519_IDENTITY, message: "msg 0", messageAsR: "msg 0" }, - { - name: "order 2", - hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", - message: "msg 1", - messageAsR: "msg 0", - }, - { - name: "order 4 (a)", - hex: "0000000000000000000000000000000000000000000000000000000000000080", - message: "msg 0", - messageAsR: "msg 3", - }, - { - name: "order 4 (b)", - hex: "0000000000000000000000000000000000000000000000000000000000000000", - message: "msg 1", - messageAsR: "msg 1", - }, - { - name: "order 8 (a)", - hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", - message: "msg 1", - messageAsR: "msg 6", - }, - { - name: "order 8 (b)", - hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", - message: "msg 1", - messageAsR: "msg 7", - }, - { - name: "order 8 (c)", - hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", - message: "msg 1", - messageAsR: "msg 1", - }, - { - name: "order 8 (d)", - hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", - message: "msg 7", - messageAsR: "msg 13", - }, - // Non-canonical encodings of the same points. BoringSSL decodes y >= p - // (reduced mod p) and x = 0 with the sign bit set, so each of these is - // accepted as a public key. None of them can appear as R: ED25519_verify - // re-encodes R canonically and compares the bytes. +// `canonical` is false for an encoding that only a lenient decoder accepts: y >= p (BoringSSL reduces it mod p), +// or x = 0 with the sign bit set. Such an encoding can be a public key. It cannot be R, because ED25519_verify +// encodes the R it computes canonically and compares the bytes. +export const ED25519_SMALL_ORDER_POINTS: { name: string; hex: string; canonical: boolean }[] = [ + { name: "identity", hex: ED25519_IDENTITY, canonical: true }, + { name: "order 2", hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", canonical: true }, + { name: "order 4 (a)", hex: "0000000000000000000000000000000000000000000000000000000000000080", canonical: true }, + { name: "order 4 (b)", hex: "0000000000000000000000000000000000000000000000000000000000000000", canonical: true }, + { name: "order 8 (a)", hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", canonical: true }, + { name: "order 8 (b)", hex: "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", canonical: true }, + { name: "order 8 (c)", hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", canonical: true }, + { name: "order 8 (d)", hex: "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", canonical: true }, { name: "identity (x = 0, sign bit set)", hex: "0100000000000000000000000000000000000000000000000000000000000080", - message: "msg 0", + canonical: false, }, { name: "order 2 (x = 0, sign bit set)", hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - message: "msg 0", + canonical: false, }, { name: "identity (y = p + 1)", hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", - message: "msg 0", + canonical: false, }, { name: "identity (y = p + 1, sign bit set)", hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - message: "msg 0", + canonical: false, }, { name: "order 4 (y = p, sign bit set)", hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - message: "msg 8", + canonical: false, }, { name: "order 4 (y = p)", hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", - message: "msg 8", + canonical: false, }, ]; @@ -139,26 +103,44 @@ export function identityRSignature(privateJwk: { d?: string; x?: string }, messa return signature; } +export type Ed25519Forgery = { message: Uint8Array; signature: Uint8Array }; + +// The first "msg N" whose h = SHA-512(R || A || M) mod L satisfies `holds`. +function forgedMessage(R: Uint8Array, A: Uint8Array, holds: (h: bigint) => boolean): Uint8Array { + for (let i = 0; i < 256; i++) { + const message = Uint8Array.from(Buffer.from(`msg ${i}`)); + if (holds(scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L)) return message; + } + throw new Error("no message satisfies the verify equation"); +} + +// A forged signature under the small-order public key `hex` with R = identity +// and S = 0. [S]B = R + [h]A holds when [h]A is the identity, which is true +// for every small-order A when h is a multiple of 8. +export function forgeryWithIdentityR(hex: string): Ed25519Forgery { + const message = forgedMessage(hexBytes(ED25519_IDENTITY), hexBytes(hex), h => h % 8n === 0n); + return { message, signature: zeroSignatureWithR(ED25519_IDENTITY) }; +} + +// A forged signature under the small-order public key `hex` with R = A and +// S = 0. R + [h]A = [h + 1]A is the identity when h + 1 is a multiple of 8. +export function forgeryWithKeyAsR(hex: string): Ed25519Forgery { + const A = hexBytes(hex); + return { message: forgedMessage(A, A, h => (h + 1n) % 8n === 0n), signature: zeroSignatureWithR(hex) }; +} + // RFC 8032, section 7.1, TEST 1: a seed and its public key [a]B, a point of prime order. export const ED25519_HONEST_SEED = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; const ED25519_HONEST_POINT = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; // A forged signature under the small-order public key `hex` whose R is not // small-order, so that only the check on the public key can reject it. -// R = [a]B and S = a for the honest scalar a above. The message is the first -// "msg N" for which h = SHA-512(R || A || M) mod L is a multiple of 8. Then -// [h]A is the identity for every small-order A, and the unfiltered equation -// [S]B = R + [h]A holds. -export function forgeryWithHonestR(hex: string): { message: Uint8Array; signature: Uint8Array } { +// R = [a]B and S = a for the honest scalar a above, and h is a multiple of 8. +// Then [h]A is the identity, and [S]B = R + [h]A holds. +export function forgeryWithHonestR(hex: string): Ed25519Forgery { const R = hexBytes(ED25519_HONEST_POINT); - const A = hexBytes(hex); const signature = new Uint8Array(64); signature.set(R, 0); signature.set(scalarToLE(scalarOfSeed(hexBytes(ED25519_HONEST_SEED)) % L), 32); - for (let i = 0; i < 256; i++) { - const message = Uint8Array.from(Buffer.from(`msg ${i}`)); - const h = scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L; - if (h % 8n === 0n) return { message, signature }; - } - throw new Error(`no message makes h a multiple of 8 for ${hex}`); + return { message: forgedMessage(R, hexBytes(hex), h => h % 8n === 0n), signature }; } diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts index 45ae49b4b400..404aec516beb 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -6,6 +6,8 @@ import { ED25519_IDENTITY, ED25519_SMALL_ORDER_POINTS, forgeryWithHonestR, + forgeryWithIdentityR, + forgeryWithKeyAsR, hexBytes, identityRSignature, zeroSignatureWithR, @@ -587,15 +589,11 @@ describe("Ed25519", () => { it("imports a small-order public key and verifies nothing under it", async () => { const results: Record = {}; - for (const { name, hex, message } of ED25519_SMALL_ORDER_POINTS) { + for (const { name, hex } of ED25519_SMALL_ORDER_POINTS) { const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", true, ["verify"]); expect(key.type).toBe("public"); - results[name] = await crypto.subtle.verify( - "Ed25519", - key, - zeroSignatureWithR(ED25519_IDENTITY), - encoder.encode(message), - ); + const { message, signature } = forgeryWithIdentityR(hex); + results[name] = await crypto.subtle.verify("Ed25519", key, signature, message); } expect(results).toEqual(Object.fromEntries(ED25519_SMALL_ORDER_POINTS.map(p => [p.name, false]))); }); @@ -613,10 +611,11 @@ describe("Ed25519", () => { it("rejects a small-order R", async () => { const results: Record = {}; - for (const { name, hex, messageAsR } of ED25519_SMALL_ORDER_POINTS) { - if (messageAsR === undefined) continue; + for (const { name, hex, canonical } of ED25519_SMALL_ORDER_POINTS) { + if (!canonical) continue; const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", false, ["verify"]); - results[name] = await crypto.subtle.verify("Ed25519", key, zeroSignatureWithR(hex), encoder.encode(messageAsR)); + const { message, signature } = forgeryWithKeyAsR(hex); + results[name] = await crypto.subtle.verify("Ed25519", key, signature, message); } expect(Object.keys(results)).toHaveLength(8); expect(results).toEqual(Object.fromEntries(Object.keys(results).map(name => [name, false])));