From 2d278e4c8456c849e36adccb03581d8889147a2e Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 25 Jun 2026 00:19:10 +0200 Subject: [PATCH] tls: report negotiated TLS groups When OpenSSL reports no peer temporary key object for TLS key agreement, fall back to the negotiated TLS Supported Group name. This lets getEphemeralKeyInfo() identify PQ, hybrid, and other group-based key agreement as TLSGroup without synthesizing a size. Keep existing DH and ECDH results unchanged when OpenSSL provides a recognizable temporary key. Fixes: https://github.com/nodejs/node/issues/59452 Signed-off-by: Filip Skokan --- deps/ncrypto/ncrypto.cc | 11 ++++ deps/ncrypto/ncrypto.h | 1 + doc/api/tls.md | 45 +++++++++------ src/crypto/crypto_common.cc | 11 +++- src/env_properties.h | 1 + .../test-tls-client-getephemeralkeyinfo.js | 57 ++++++++++++++++++- 6 files changed, 106 insertions(+), 20 deletions(-) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 81af3ded563777..d62485e626c158 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -3086,6 +3086,17 @@ EVPKeyPointer SSLPointer::getPeerTempKey() const { return EVPKeyPointer(raw_key); } +std::optional SSLPointer::getNegotiatedGroup() const { +#if OPENSSL_VERSION_PREREQ(3, 5) + if (!ssl_) return std::nullopt; + const char* group = SSL_get0_group_name(get()); + if (group == nullptr) return std::nullopt; + return group; +#else + return std::nullopt; +#endif +} + std::optional SSLPointer::getCipherName() const { auto cipher = getCipher(); if (cipher == nullptr) return std::nullopt; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index a6befbf7cf6794..76d79652607139 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -1198,6 +1198,7 @@ class SSLPointer final { std::optional getServerName() const; X509View getCertificate() const; EVPKeyPointer getPeerTempKey() const; + std::optional getNegotiatedGroup() const; const SSL_CIPHER* getCipher() const; bool isServer() const; diff --git a/doc/api/tls.md b/doc/api/tls.md index 9a53621d4a54f7..ae9e7e9cc59463 100644 --- a/doc/api/tls.md +++ b/doc/api/tls.md @@ -131,7 +131,8 @@ the character "E" appended to the traditional abbreviations): Perfect forward secrecy using ECDHE is enabled by default. The `ecdhCurve` option can be used when creating a TLS server to customize the list of supported -ECDH curves to use. See [`tls.createServer()`][] for more info. +ECDH curves for TLSv1.2 and below, and the list of supported TLS groups for +TLSv1.3. See [`tls.createServer()`][] for more info. DHE is disabled by default but can be enabled alongside ECDHE by setting the `dhparam` option to `'auto'`. Custom DHE parameters are also supported but @@ -1196,12 +1197,19 @@ added: v5.0.0 * Returns: {Object} -Returns an object representing the type, name, and size of parameter of -an ephemeral key exchange in [perfect forward secrecy][] on a client -connection. It returns an empty object when the key exchange is not -ephemeral. As this is only supported on a client socket; `null` is returned -if called on a server socket. The supported types are `'DH'` and `'ECDH'`. The -`name` property is available only when type is `'ECDH'`. +Returns an object describing ephemeral key agreement in [perfect forward +secrecy][] on a client connection. It returns an empty object when the key +agreement is not ephemeral. As this is only supported on a client socket; +`null` is returned if called on a server socket. The supported types are `'DH'`, +`'ECDH'`, and `'TLSGroup'`. For `'DH'` and `'ECDH'`, the object describes peer +temporary key parameters. For `'TLSGroup'`, the object identifies the negotiated +TLS Supported Group used for key agreement when a peer temporary key object is +not available. + +The `name` property is available only when type is `'ECDH'` or `'TLSGroup'`. The +`size` property is not available when type is `'TLSGroup'`. For `'TLSGroup'`, +`name` is the negotiated TLS Supported Group name. Standardized TLS group names +and code points are listed in the [IANA TLS Supported Groups registry][]. For example: `{ type: 'ECDH', name: 'prime256v1', size: 256 }`. @@ -2019,12 +2027,16 @@ changes: required for non-ECDHE [perfect forward secrecy][]. If omitted or invalid, the parameters are silently discarded and DHE ciphers will not be available. [ECDHE][]-based [perfect forward secrecy][] will still be available. - * `ecdhCurve` {string} A string describing a named curve or a colon separated - list of curve NIDs or names, for example `P-521:P-384:P-256`, to use for - ECDH key agreement. Set to `auto` to select the - curve automatically. Use [`crypto.getCurves()`][] to obtain a list of - available curve names. On recent releases, `openssl ecparam -list_curves` - will also display the name and description of each available elliptic curve. + * `ecdhCurve` {string} A string describing a named curve, TLS group, or + colon-separated list of named curves or TLS groups to use for key agreement, + for example `P-521:P-384:P-256`, `X25519`, or `X25519MLKEM768`. The + historical name of this option refers to ECDH key agreement in TLSv1.2 and + below. In TLSv1.3, this option configures the TLS Supported Groups and + key share groups offered or accepted by the TLS stack. Set to `auto` to + select the group automatically. Use [`crypto.getCurves()`][] to obtain a + list of available elliptic curve names. For TLS group names, use + `openssl list -tls-groups` or consult the [IANA TLS Supported Groups + registry][]. **Default:** [`tls.DEFAULT_ECDH_CURVE`][]. * `honorCipherOrder` {boolean} Attempt to use the server's cipher suite preferences instead of the client's. When `true`, causes @@ -2442,9 +2454,9 @@ changes: description: Default value changed to `'auto'`. --> -The default curve name to use for ECDH key agreement in a tls server. The -default value is `'auto'`. See [`tls.createSecureContext()`][] for further -information. +The default named curve or TLS group list to use for key agreement in a TLS +server. The default value is `'auto'`. See [`tls.createSecureContext()`][] for +further information. ## `tls.DEFAULT_MAX_VERSION` @@ -2492,6 +2504,7 @@ added: v0.11.3 [Chrome's 'modern cryptography' setting]: https://www.chromium.org/Home/chromium-security/education/tls#TOC-Cipher-Suites [DHE]: https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange [ECDHE]: https://en.wikipedia.org/wiki/Elliptic_curve_Diffie%E2%80%93Hellman +[IANA TLS Supported Groups registry]: https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8 [Modifying the default TLS cipher suite]: #modifying-the-default-tls-cipher-suite [Mozilla's publicly trusted list of CAs]: https://hg.mozilla.org/mozilla-central/raw-file/tip/security/nss/lib/ckfw/builtins/certdata.txt [OCSP request]: https://en.wikipedia.org/wiki/OCSP_stapling diff --git a/src/crypto/crypto_common.cc b/src/crypto/crypto_common.cc index 4f117f160a9673..0db5d0eaac8fc3 100644 --- a/src/crypto/crypto_common.cc +++ b/src/crypto/crypto_common.cc @@ -215,13 +215,15 @@ MaybeLocal GetEphemeralKey(Environment* env, const SSLPointer& ssl) { Undefined(env->isolate()), // name Undefined(env->isolate()), // size }; - EVPKeyPointer key = ssl.getPeerTempKey(); + + bool found = false; if (EVPKeyPointer key = ssl.getPeerTempKey()) { int kid = key.id(); switch (kid) { case EVP_PKEY_DH: { values[0] = env->dh_string(); values[2] = Integer::New(env->isolate(), key.bits()); + found = true; break; } case EVP_PKEY_EC: @@ -237,10 +239,17 @@ MaybeLocal GetEphemeralKey(Environment* env, const SSLPointer& ssl) { values[0] = env->ecdh_string(); values[1] = OneByteString(env->isolate(), curve_name); values[2] = Integer::New(env->isolate(), key.bits()); + found = true; break; } } } + if (!found) { + if (auto name = ssl.getNegotiatedGroup()) { + values[0] = env->tls_group_string(); + values[1] = OneByteString(env->isolate(), name.value()); + } + } return scope.EscapeMaybe(NewDictionaryInstance(env->context(), tmpl, values)); } diff --git a/src/env_properties.h b/src/env_properties.h index 3c07d4a0f19e49..f4329253e96af3 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -363,6 +363,7 @@ V(target_string, "target") \ V(thread_id_string, "threadId") \ V(thread_name_string, "threadName") \ + V(tls_group_string, "TLSGroup") \ V(ticketkeycallback_string, "onticketkeycallback") \ V(timeout_string, "timeout") \ V(time_to_first_byte_string, "timeToFirstByte") \ diff --git a/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/parallel/test-tls-client-getephemeralkeyinfo.js index 2107d024012c4d..ea6dec7bdc4629 100644 --- a/test/parallel/test-tls-client-getephemeralkeyinfo.js +++ b/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -18,9 +18,6 @@ const tls = require('tls'); const key = fixtures.readKey('agent2-key.pem'); const cert = fixtures.readKey('agent2-cert.pem'); -// TODO(@sam-github) test works with TLS1.3, rework test to add -// 'ECDH' with 'TLS_AES_128_GCM_SHA256', - function loadDHParam(n) { return fixtures.readKey(`dh${n}.pem`); } @@ -89,3 +86,57 @@ test(256, 'ECDH', 'prime256v1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(521, 'ECDH', 'secp521r1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(253, 'ECDH', 'X25519', 'ECDHE-RSA-AES256-GCM-SHA384'); test(448, 'ECDH', 'X448', 'ECDHE-RSA-AES256-GCM-SHA384'); + +function testTLS13Group(size, type, name) { + const options = { + key, + cert, + ecdhCurve: name, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }; + + const server = tls.createServer(options, common.mustCall((conn) => { + assert.strictEqual(conn.getEphemeralKeyInfo(), null); + conn.end(); + })); + + server.on('close', common.mustSucceed()); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + ecdhCurve: name, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }, common.mustCall(() => { + const ekeyinfo = client.getEphemeralKeyInfo(); + assert.strictEqual(ekeyinfo.type, type); + assert.strictEqual(ekeyinfo.size, size); + assert.strictEqual(ekeyinfo.name, name); + server.close(); + })); + client.on('secureConnect', common.mustCall()); + })); +} + +testTLS13Group(253, 'ECDH', 'X25519'); + +if (hasOpenSSL(3, 5)) { + const tls13Groups = [ + 'MLKEM512', + 'MLKEM768', + 'MLKEM1024', + 'SecP256r1MLKEM768', + 'X25519MLKEM768', + 'SecP384r1MLKEM1024', + ]; + + if (hasOpenSSL(4, 0)) { + tls13Groups.push('curveSM2'); + tls13Groups.push('curveSM2MLKEM768'); + } + + tls13Groups.forEach((name) => testTLS13Group(undefined, 'TLSGroup', name)); +}