diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index e79668a7aa95..d155040ae789 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -7,6 +7,7 @@ #include "headers-handwritten.h" #include #include +#include #include #include #include @@ -3626,6 +3627,96 @@ bool extractP1363(const Buffer& buf, return BignumPointer::EncodePaddedInto(asn1_sig.r(), dest, n) > 0 && BignumPointer::EncodePaddedInto(asn1_sig.s(), dest + n, n) > 0; } +// ============================================================================ +// Ed25519 + +namespace { + +// Ed25519 has cofactor 8. The first eight entries are the full canonical +// small-order subgroup: the identity, one point of order 2, two points of +// order 4 and four points of order 8. The last six are non-canonical +// encodings of the same points (y >= p, or x = 0 with the sign bit set), +// which BoringSSL accepts as a public key. Same table as Node +// (src/crypto/crypto_sig.cc, nodejs/node#64026). +constexpr unsigned char kEd25519SmallOrderPoints[][ED25519_PUBLIC_KEY_LEN] = { + // Identity. + { 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + // Order 2. + { 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f }, + // Order 4. + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80 }, + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, + // Order 8. + { 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, + 0x76, 0x0d, 0x10, 0x67, 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, + 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0x7a }, + { 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, + 0x76, 0x0d, 0x10, 0x67, 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, + 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, 0x03, 0xfa }, + { 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, + 0x89, 0xf2, 0xef, 0x98, 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, + 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x05 }, + { 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, + 0x89, 0xf2, 0xef, 0x98, 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, + 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, 0xfc, 0x85 }, + // Non-canonical encodings of the same small-order points: identity and + // order 2 with x = 0 and the sign bit set, identity as y = p + 1 (both + // sign bits), order 4 as y = p (both sign bits). + { 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80 }, + { 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, + { 0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f }, + { 0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, + { 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }, + { 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f }, +}; + +bool isSmallOrderEd25519Point(const unsigned char* point) +{ + for (const auto& candidate : kEd25519SmallOrderPoints) { + if (memcmp(point, candidate, ED25519_PUBLIC_KEY_LEN) == 0) return true; + } + return false; +} + +} // namespace + +bool ed25519Verify(const Buffer& publicKey, + const Buffer& signature, + const Buffer& message) +{ + if (publicKey.len != ED25519_PUBLIC_KEY_LEN || signature.len != ED25519_SIGNATURE_LEN) { + return false; + } + if (ED25519_verify(message.data, message.len, signature.data, publicKey.data) != 1) { + return false; + } + // BoringSSL checks only the cofactorless equation. With a small-order + // public key A it is solvable without the private key: for the identity, + // R = identity and S = 0 pass for any message. A small-order R is rejected + // as well to match Node, libsodium and ed25519-dalek verify_strict. + return !isSmallOrderEd25519Point(signature.data) && !isSmallOrderEd25519Point(publicKey.data); +} + // ============================================================================ HMACCtxPointer::HMACCtxPointer() diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index 1753d1a2c9e7..84a5a41d4448 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -1268,6 +1268,15 @@ bool extractP1363(const Buffer& buf, unsigned char* dest, size_t n); +// Verifies an Ed25519 signature and rejects a small-order public key or R, +// in canonical or non-canonical encoding, like Node (nodejs/node#64026). +// crypto.subtle.verify and one-shot crypto.verify route through this. +// X509Certificate.verify goes through X509_verify and does not filter, as in +// Node. +bool ed25519Verify(const Buffer& publicKey, + const Buffer& signature, + const Buffer& message); + DataPointer hkdf(const Digest& md, const Buffer& key, const Buffer& info, diff --git a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp index b5491224c813..382e9fc83a6e 100644 --- a/src/jsc/bindings/node/crypto/CryptoSignJob.cpp +++ b/src/jsc/bindings/node/crypto/CryptoSignJob.cpp @@ -2,6 +2,7 @@ #include "NodeValidator.h" #include "KeyObject.h" #include "JSVerify.h" +#include #include using namespace JSC; @@ -100,14 +101,30 @@ void SignJobCtx::runTask(JSGlobalObject* globalObject) } ClearErrorOnReturn clearError; + const auto& key = m_keyData->asymmetricKey; + + // An Ed25519 verify with a digest still goes through verifyInit so that it + // fails the same way as before. + if (m_mode == Mode::Verify && !m_digest && key.id() == EVP_PKEY_ED25519) { + uint8_t publicKey[ED25519_PUBLIC_KEY_LEN]; + size_t publicKeyLen = sizeof(publicKey); + if (EVP_PKEY_get_raw_public_key(key.get(), publicKey, &publicKeyLen) != 1) { + m_opensslError = ERR_get_error(); + return; + } + m_verifyResult = ncrypto::ed25519Verify( + { publicKey, publicKeyLen }, + { m_signature.begin(), m_signature.size() }, + { m_data.begin(), m_data.size() }); + return; + } + auto context = EVPMDCtxPointer::New(); if (!context) [[unlikely]] { m_opensslError = ERR_get_error(); return; } - const auto& key = m_keyData->asymmetricKey; - std::optional ctx; switch (m_mode) { case Mode::Sign: diff --git a/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp b/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp index cbbb5f4a4ba4..be00809d3792 100644 --- a/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp +++ b/src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp @@ -33,6 +33,7 @@ #include // -- BUN -- +#include "ncrypto.h" #include namespace WebCore { @@ -55,13 +56,13 @@ ExceptionOr> CryptoAlgorithmEd25519::platformSign(const CryptoKe static ExceptionOr verifyEd25519(const Vector& key, size_t keyLengthInBytes, const Vector& signature, const Vector data) { - if (key.size() != ED25519_PUBLIC_KEY_LEN || keyLengthInBytes != ED25519_PUBLIC_KEY_LEN) + if (keyLengthInBytes != ED25519_PUBLIC_KEY_LEN) return false; - if (signature.size() != keyLengthInBytes * 2) - return false; - - return ED25519_verify(data.begin(), data.size(), signature.begin(), key.begin()) == 1; + return ncrypto::ed25519Verify( + { key.begin(), key.size() }, + { signature.begin(), signature.size() }, + { data.begin(), data.size() }); } ExceptionOr CryptoAlgorithmEd25519::platformVerify(const CryptoKeyOKP& key, const Vector& signature, const Vector& data) diff --git a/test/js/node/crypto/crypto.key-objects.test.ts b/test/js/node/crypto/crypto.key-objects.test.ts index 9c4b75bb442f..227fa1947ddd 100644 --- a/test/js/node/crypto/crypto.key-objects.test.ts +++ b/test/js/node/crypto/crypto.key-objects.test.ts @@ -30,6 +30,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); @@ -1834,6 +1841,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..3c40139754be 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 @@ -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 (!process.features.openssl_is_boringssl) { + 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, @@ -260,6 +298,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 efa5d8f3d49f..b8901c33b690 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 { createSecretKey, type webcrypto } from "node:crypto"; +import { createPublicKey, createSecretKey, 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", () => {