diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index f853fb66cf6e..43d683359e74 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -31,6 +31,17 @@ 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_HONEST_SEED, + ED25519_IDENTITY, + ED25519_SMALL_ORDER_POINTS, + forgeryWithHonestR, + forgeryWithIdentityR, + forgeryWithKeyAsR, + hexBytes, + identityRSignature, + zeroSignatureWithR, +} from "../../web/crypto/ed25519-small-order"; function readFile(...args: [path: string, encoding: BufferEncoding]) { const result = fs.readFileSync(...args); @@ -1883,6 +1894,102 @@ 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 } of ED25519_SMALL_ORDER_POINTS) { + const key = jwkKey(hex); + expect(key.asymmetricKeyType).toBe("ed25519"); + 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]))); + }); + + // 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 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", + }); + 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); + 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]))); + }); + + test("a small-order R is rejected", () => { + const results: Record = {}; + 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]))); + }); + + 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(); + // 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" }), + ); + }); +}); + 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..7db97c19e544 --- /dev/null +++ b/test/js/web/crypto/ed25519-small-order.ts @@ -0,0 +1,146 @@ +import { createHash } from "node:crypto"; + +// 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). +// +// Each forgery below searches for its own message. So every entry proves +// that the unfiltered equation holds for it. + +export const ED25519_IDENTITY = "0100000000000000000000000000000000000000000000000000000000000000"; + +// `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", + canonical: false, + }, + { + name: "order 2 (x = 0, sign bit set)", + hex: "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + canonical: false, + }, + { + name: "identity (y = p + 1)", + hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + canonical: false, + }, + { + name: "identity (y = p + 1, sign bit set)", + hex: "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + canonical: false, + }, + { + name: "order 4 (y = p, sign bit set)", + hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + canonical: false, + }, + { + name: "order 4 (y = p)", + hex: "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + canonical: false, + }, +]; + +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")); +} + +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 +// 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 a = scalarOfSeed(Buffer.from(privateJwk.d!, "base64url")); + const A = Buffer.from(privateJwk.x!, "base64url"); + const R = hexBytes(ED25519_IDENTITY); + const h = scalarFromLE(createHash("sha512").update(R).update(A).update(message).digest()) % L; + const signature = new Uint8Array(64); + signature.set(R, 0); + signature.set(scalarToLE((h * a) % L), 32); + 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, 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 signature = new Uint8Array(64); + signature.set(R, 0); + signature.set(scalarToLE(scalarOfSeed(hexBytes(ED25519_HONEST_SEED)) % L), 32); + 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 c469528f324f..404aec516beb 100644 --- a/test/js/web/crypto/web-crypto.test.ts +++ b/test/js/web/crypto/web-crypto.test.ts @@ -1,7 +1,17 @@ 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, + forgeryWithHonestR, + forgeryWithIdentityR, + forgeryWithKeyAsR, + hexBytes, + identityRSignature, + zeroSignatureWithR, +} from "./ed25519-small-order"; type AlgorithmIdentifier = webcrypto.AlgorithmIdentifier; type HmacImportParams = webcrypto.HmacImportParams; @@ -572,6 +582,95 @@ 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 } of ED25519_SMALL_ORDER_POINTS) { + const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", true, ["verify"]); + expect(key.type).toBe("public"); + 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]))); + }); + + // 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, canonical } of ED25519_SMALL_ORDER_POINTS) { + if (!canonical) continue; + const key = await crypto.subtle.importKey("raw", hexBytes(hex), "Ed25519", false, ["verify"]); + 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]))); + }); + + 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", () => {