Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions test/js/node/crypto/crypto.key-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, boolean> = {};
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<string, boolean> = {};
const forgedRs = new Set<string>();
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<string, boolean> = {};
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<boolean>();
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<boolean>();
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" });

Expand Down
67 changes: 52 additions & 15 deletions test/js/node/test/parallel/test-webcrypto-sign-verify-eddsa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
});
}

Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
});
}
}
Expand All @@ -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());
Expand Down
146 changes: 146 additions & 0 deletions test/js/web/crypto/ed25519-small-order.ts
Original file line number Diff line number Diff line change
@@ -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,
},
Comment thread
robobun marked this conversation as resolved.
Resolved
];

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 };
}
Loading
Loading