Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
crypto: support ML-DSA KeyObject, sign, and verify
  • Loading branch information
panva committed Jul 29, 2025
commit 69fc44e81969dcc733f029643eec242fe9d5c1c7
49 changes: 47 additions & 2 deletions deps/ncrypto/ncrypto.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1942,7 +1942,16 @@ EVP_PKEY* EVPKeyPointer::release() {

int EVPKeyPointer::id(const EVP_PKEY* key) {
if (key == nullptr) return 0;
return EVP_PKEY_id(key);
int type = EVP_PKEY_id(key);
#if OPENSSL_VERSION_MAJOR >= 3 && OPENSSL_VERSION_MINOR >= 5
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker but, does this also need to have a OPENSSL_IS_BORING guard? I doubt boring would end up duplicating these version values but just want to be cautious.

/cc @codebytere

// https://github.com/openssl/openssl/issues/27738#issuecomment-3013215870
if (type == -1) {
if (EVP_PKEY_is_a(key, "ML-DSA-44")) return EVP_PKEY_ML_DSA_44;
if (EVP_PKEY_is_a(key, "ML-DSA-65")) return EVP_PKEY_ML_DSA_65;
if (EVP_PKEY_is_a(key, "ML-DSA-87")) return EVP_PKEY_ML_DSA_87;
}
#endif
return type;
}

int EVPKeyPointer::base_id(const EVP_PKEY* key) {
Expand Down Expand Up @@ -1998,6 +2007,31 @@ DataPointer EVPKeyPointer::rawPublicKey() const {
return {};
}

#if OPENSSL_VERSION_MAJOR >= 3 && OPENSSL_VERSION_MINOR >= 5
DataPointer EVPKeyPointer::rawSeed() const {
if (!pkey_) return {};
switch (id()) {
case EVP_PKEY_ML_DSA_44:
case EVP_PKEY_ML_DSA_65:
case EVP_PKEY_ML_DSA_87:
break;
default:
unreachable();
}

size_t seed_len = 32;
if (auto data = DataPointer::Alloc(seed_len)) {
const Buffer<unsigned char> buf = data;
size_t len = data.size();
if (EVP_PKEY_get_octet_string_param(
get(), OSSL_PKEY_PARAM_ML_DSA_SEED, buf.data, len, &seed_len) != 1)
return {};
return data;
}
return {};
}
#endif

DataPointer EVPKeyPointer::rawPrivateKey() const {
if (!pkey_) return {};
if (auto data = DataPointer::Alloc(rawPrivateKeySize())) {
Expand Down Expand Up @@ -2453,7 +2487,18 @@ bool EVPKeyPointer::isRsaVariant() const {
bool EVPKeyPointer::isOneShotVariant() const {
if (!pkey_) return false;
int type = id();
return type == EVP_PKEY_ED25519 || type == EVP_PKEY_ED448;
switch (type) {
case EVP_PKEY_ED25519:
case EVP_PKEY_ED448:
#if OPENSSL_VERSION_MAJOR >= 3 && OPENSSL_VERSION_MINOR >= 5
case EVP_PKEY_ML_DSA_44:
case EVP_PKEY_ML_DSA_65:
case EVP_PKEY_ML_DSA_87:
#endif
return true;
default:
return false;
}
}

bool EVPKeyPointer::isSigVariant() const {
Expand Down
7 changes: 7 additions & 0 deletions deps/ncrypto/ncrypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@

#if OPENSSL_VERSION_MAJOR >= 3
#define OSSL3_CONST const
#if OPENSSL_VERSION_MINOR >= 5
#include <openssl/core_names.h>
#endif
#else
#define OSSL3_CONST
#endif
Expand Down Expand Up @@ -910,6 +913,10 @@ class EVPKeyPointer final {
DataPointer rawPrivateKey() const;
BIOPointer derPublicKey() const;

#if OPENSSL_VERSION_MAJOR >= 3 && OPENSSL_VERSION_MINOR >= 5
DataPointer rawSeed() const;
#endif

Result<BIOPointer, bool> writePrivateKey(
const PrivateKeyEncodingConfig& config) const;
Result<BIOPointer, bool> writePublicKey(
Expand Down
34 changes: 32 additions & 2 deletions doc/api/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,9 @@ This can be called many times with new data as it is streamed.
<!-- YAML
added: v11.6.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA keys.
- version:
- v14.5.0
- v12.19.0
Expand Down Expand Up @@ -2021,6 +2024,9 @@ Other key details might be exposed via this API using additional attributes.
<!-- YAML
added: v11.6.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA keys.
- version:
- v13.9.0
- v12.17.0
Expand Down Expand Up @@ -2055,6 +2061,9 @@ types are:
* `'ed25519'` (OID 1.3.101.112)
* `'ed448'` (OID 1.3.101.113)
* `'dh'` (OID 1.2.840.113549.1.3.1)
* `'ml-dsa-44'`[^openssl35] (OID 2.16.840.1.101.3.4.3.17)
* `'ml-dsa-65'`[^openssl35] (OID 2.16.840.1.101.3.4.3.18)
* `'ml-dsa-87'`[^openssl35] (OID 2.16.840.1.101.3.4.3.19)

This property is `undefined` for unrecognized `KeyObject` types and symmetric
keys.
Expand Down Expand Up @@ -3403,6 +3412,9 @@ input.on('readable', () => {
<!-- YAML
added: v11.6.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA keys.
- version: v15.12.0
pr-url: https://github.com/nodejs/node/pull/37254
description: The key can also be a JWK object.
Expand Down Expand Up @@ -3439,6 +3451,9 @@ of the passphrase is limited to 1024 bytes.
<!-- YAML
added: v11.6.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA keys.
- version: v15.12.0
pr-url: https://github.com/nodejs/node/pull/37254
description: The key can also be a JWK object.
Expand Down Expand Up @@ -3648,6 +3663,9 @@ underlying hash function. See [`crypto.createHmac()`][] for more information.
<!-- YAML
added: v10.12.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA key pairs.
- version: v18.0.0
pr-url: https://github.com/nodejs/node/pull/41678
description: Passing an invalid callback to the `callback` argument
Expand Down Expand Up @@ -3767,6 +3785,9 @@ a `Promise` for an `Object` with `publicKey` and `privateKey` properties.
<!-- YAML
added: v10.12.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA key pairs.
- version: v16.10.0
pr-url: https://github.com/nodejs/node/pull/39927
description: Add ability to define `RSASSA-PSS-params` sequence parameters
Expand All @@ -3792,7 +3813,8 @@ changes:
-->

* `type` {string} Must be `'rsa'`, `'rsa-pss'`, `'dsa'`, `'ec'`, `'ed25519'`,
`'ed448'`, `'x25519'`, `'x448'`, or `'dh'`.
`'ed448'`, `'x25519'`, `'x448'`, `'dh'`, `'ml-dsa-44'`[^openssl35],
`'ml-dsa-65'`[^openssl35], or `'ml-dsa-87'`[^openssl35].
* `options` {Object}
* `modulusLength` {number} Key size in bits (RSA, DSA).
* `publicExponent` {number} Public exponent (RSA). **Default:** `0x10001`.
Expand All @@ -3816,7 +3838,7 @@ changes:
* `privateKey` {string | Buffer | KeyObject}

Generates a new asymmetric key pair of the given `type`. RSA, RSA-PSS, DSA, EC,
Ed25519, Ed448, X25519, X448, and DH are currently supported.
Ed25519, Ed448, X25519, X448, DH, and ML-DSA[^openssl35] are currently supported.

If a `publicKeyEncoding` or `privateKeyEncoding` was specified, this function
behaves as if [`keyObject.export()`][] had been called on its result. Otherwise,
Expand Down Expand Up @@ -5416,6 +5438,9 @@ Throws an error if FIPS mode is not available.
<!-- YAML
added: v12.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA signing.
- version: v18.0.0
pr-url: https://github.com/nodejs/node/pull/41678
description: Passing an invalid callback to the `callback` argument
Expand Down Expand Up @@ -5526,6 +5551,9 @@ not introduce timing vulnerabilities.
<!-- YAML
added: v12.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59259
description: Add support for ML-DSA signature verification.
- version: v18.0.0
pr-url: https://github.com/nodejs/node/pull/41678
description: Passing an invalid callback to the `callback` argument
Expand Down Expand Up @@ -6150,6 +6178,8 @@ See the [list of SSL OP Flags][] for details.
</tr>
</table>

[^openssl35]: Requires OpenSSL >= 3.5

[AEAD algorithms]: https://en.wikipedia.org/wiki/Authenticated_encryption
[CCM mode]: #ccm-mode
[CVE-2021-44532]: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-44532
Expand Down
34 changes: 19 additions & 15 deletions lib/internal/crypto/keygen.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const {
kKeyVariantRSA_SSA_PKCS1_v1_5,
EVP_PKEY_ED25519,
EVP_PKEY_ED448,
EVP_PKEY_ML_DSA_44,
EVP_PKEY_ML_DSA_65,
EVP_PKEY_ML_DSA_87,
EVP_PKEY_X25519,
EVP_PKEY_X448,
OPENSSL_EC_NAMED_CURVE,
Expand Down Expand Up @@ -162,6 +165,16 @@ function parseKeyEncoding(keyType, options = kEmptyObject) {
];
}

const ids = {
'ed25519': EVP_PKEY_ED25519,
'ed448': EVP_PKEY_ED448,
'x25519': EVP_PKEY_X25519,
'x448': EVP_PKEY_X448,
'ml-dsa-44': EVP_PKEY_ML_DSA_44,
'ml-dsa-65': EVP_PKEY_ML_DSA_65,
'ml-dsa-87': EVP_PKEY_ML_DSA_87,
};

function createJob(mode, type, options) {
validateString(type, 'type');

Expand Down Expand Up @@ -278,23 +291,14 @@ function createJob(mode, type, options) {
case 'ed448':
case 'x25519':
case 'x448':
case 'ml-dsa-44':
case 'ml-dsa-65':
case 'ml-dsa-87':
{
let id;
switch (type) {
case 'ed25519':
id = EVP_PKEY_ED25519;
break;
case 'ed448':
id = EVP_PKEY_ED448;
break;
case 'x25519':
id = EVP_PKEY_X25519;
break;
case 'x448':
id = EVP_PKEY_X448;
break;
if (ids[type] === undefined) {
throw new ERR_INVALID_ARG_VALUE('type', type, 'must be a supported key type');
}
Comment thread
Ethan-Arrowood marked this conversation as resolved.
return new NidKeyPairGenJob(mode, id, ...encoding);
return new NidKeyPairGenJob(mode, ids[type], ...encoding);
}
case 'dh':
{
Expand Down
90 changes: 88 additions & 2 deletions lib/internal/crypto/keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const {
kKeyEncodingPKCS8,
kKeyEncodingSPKI,
kKeyEncodingSEC1,
EVP_PKEY_ML_DSA_44,
EVP_PKEY_ML_DSA_65,
EVP_PKEY_ML_DSA_87,
} = internalBinding('crypto');

const {
Expand Down Expand Up @@ -509,12 +512,95 @@ function getKeyTypes(allowKeyObject, bufferOnly = false) {
return types;
}

const oids = {
'ML-DSA-44': [96, 134, 72, 1, 101, 3, 4, 3, 17],
'ML-DSA-65': [96, 134, 72, 1, 101, 3, 4, 3, 18],
'ML-DSA-87': [96, 134, 72, 1, 101, 3, 4, 3, 19],
};

function mlDsaPubLen(alg) {
switch (alg) {
case 'ML-DSA-44': return 1312;
case 'ML-DSA-65': return 1952;
case 'ML-DSA-87': return 2592;
}
}

/**
* Encodes length for use in DER
* @param {number} length
* @returns {number[]}
*/
function encodeLength(length) {
if (length < 128) {
return [length];
}

const bytes = [];
let temp = length;
while (temp > 0) {
bytes.unshift(temp & 0xff);
temp >>>= 8;
}

return [0x80 | bytes.length, ...bytes];
}

function getKeyObjectHandleFromJwk(key, ctx) {
validateObject(key, 'key');
validateOneOf(
key.kty, 'key.kty', ['RSA', 'EC', 'OKP']);
if (EVP_PKEY_ML_DSA_44 || EVP_PKEY_ML_DSA_65 || EVP_PKEY_ML_DSA_87) {
validateOneOf(
key.kty, 'key.kty', ['RSA', 'EC', 'OKP', 'AKP']);
} else {
validateOneOf(
key.kty, 'key.kty', ['RSA', 'EC', 'OKP']);
}
const isPublic = ctx === kConsumePublic || ctx === kCreatePublic;

if (key.kty === 'AKP') {
validateOneOf(
key.alg, 'key.alg', ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
validateString(key.pub, 'key.pub');

if (!isPublic)
validateString(key.priv, 'key.priv');

let keyData = Buffer.from(key.pub, 'base64url');
if (keyData.byteLength !== mlDsaPubLen(key.alg)) {
throw new ERR_CRYPTO_INVALID_JWK();
}

let encoding;
let bytes;
// Uses seed when available for both kKeyTypePublic and kKeyTypePrivate
if (key.priv) {
keyData = Buffer.from(key.priv, 'base64url');
if (keyData.byteLength !== 32) {
throw new ERR_CRYPTO_INVALID_JWK();
}

bytes = [48, 52, 2, 1, 0, 48, 11, 6, 9, ...oids[key.alg], 4, 34, 128, 32, ...keyData];
encoding = kKeyEncodingPKCS8;
} else {
bytes = [
48, ...encodeLength(keyData.length + 18),
48, 11, 6, 9, ...oids[key.alg],
3, ...encodeLength(keyData.length + 1),
0, ...keyData,
];
encoding = kKeyEncodingSPKI;
}

const keyType = isPublic ? kKeyTypePublic : kKeyTypePrivate;
const handle = new KeyObjectHandle();
try {
handle.init(keyType, new Uint8Array(bytes), kKeyFormatDER, encoding, null);
} catch {
throw new ERR_CRYPTO_INVALID_JWK();
}
return handle;
}

if (key.kty === 'OKP') {
validateString(key.crv, 'key.crv');
validateOneOf(
Expand Down
2 changes: 2 additions & 0 deletions node.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
'src/crypto/crypto_cipher.cc',
'src/crypto/crypto_context.cc',
'src/crypto/crypto_ec.cc',
'src/crypto/crypto_ml_dsa.cc',
'src/crypto/crypto_hmac.cc',
'src/crypto/crypto_random.cc',
'src/crypto/crypto_rsa.cc',
Expand Down Expand Up @@ -367,6 +368,7 @@
'src/crypto/crypto_clienthello.h',
'src/crypto/crypto_context.h',
'src/crypto/crypto_ec.h',
'src/crypto/crypto_ml_dsa.h',
'src/crypto/crypto_hkdf.h',
'src/crypto/crypto_pbkdf2.h',
'src/crypto/crypto_sig.h',
Expand Down
Loading
Loading