Commit a0b41707646 for nodejs

commit a0b41707646f2a946175c3d697fa8ad7d21c46c3
Author: Filip Skokan <panva.ip@gmail.com>
Date:   Thu Oct 22 11:20:43 2026 +0200

    tls: configure TLS 1.3 ciphers with BoringSSL

    Translate cipher-suite names into protocol IDs for BoringSSL's TLS 1.3
    configuration API. Handle duplicates and unknown names, reject nonempty
    lists without a supported suite, and implement honorCipherOrder through
    preference flags.

    Cover suite restrictions, explicit preference ordering, legacy-only
    lists, runtime defaults, and command-line overrides.

    Signed-off-by: Filip Skokan <panva.ip@gmail.com>
    Assisted-by: Codex
    PR-URL: https://github.com/nodejs/node/pull/66922
    Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
    Reviewed-By: James M Snell <jasnell@gmail.com>

diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc
index 025f47cd920..284ba456a49 100644
--- a/deps/ncrypto/ncrypto.cc
+++ b/deps/ncrypto/ncrypto.cc
@@ -5110,13 +5110,57 @@ bool SSLCtxPointer::setGroups(const char* groups) {
 }

 bool SSLCtxPointer::setCipherSuites(const char* ciphers) {
-#ifndef OPENSSL_IS_BORINGSSL
   if (!ctx_) return false;
+#ifndef OPENSSL_IS_BORINGSSL
   return SSL_CTX_set_ciphersuites(ctx_.get(), ciphers);
 #else
-  // BoringSSL does not allow API config of TLS 1.3 cipher suites.
-  // We treat this as a non-op.
-  return true;
+  constexpr std::array<uint16_t, 3> supported = {
+      SSL_CIPHER_AES_128_GCM_SHA256,
+      SSL_CIPHER_AES_256_GCM_SHA384,
+      SSL_CIPHER_CHACHA20_POLY1305_SHA256,
+  };
+  std::vector<uint16_t> cipher_ids;
+  std::string_view remaining(ciphers);
+  while (!remaining.empty()) {
+    const size_t end = remaining.find(':');
+    std::string_view name = remaining.substr(0, end);
+    const size_t first = name.find_first_not_of(" \t\n\r\f\v");
+    if (first != std::string_view::npos) {
+      name = name.substr(first);
+      name = name.substr(0, name.find_last_not_of(" \t\n\r\f\v") + 1);
+      for (const auto id : supported) {
+        const SSL_CIPHER* cipher = SSL_get_cipher_by_value(id);
+        if (name == SSL_CIPHER_standard_name(cipher)) {
+          // OpenSSL ignores unknown names and accepts duplicates, while
+          // BoringSSL requires unique protocol IDs.
+          if (std::find(cipher_ids.begin(), cipher_ids.end(), id) ==
+              cipher_ids.end()) {
+            cipher_ids.push_back(id);
+          }
+          break;
+        }
+      }
+    }
+    if (end == std::string_view::npos) break;
+    remaining.remove_prefix(end + 1);
+  }
+  if (*ciphers != '\0' && cipher_ids.empty()) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_NO_CIPHER_MATCH);
+    return false;
+  }
+  std::vector<uint32_t> flags(cipher_ids.size());
+  if (!flags.empty() &&
+      (SSL_CTX_get_options(get()) & SSL_OP_CIPHER_SERVER_PREFERENCE) == 0) {
+    // TLS 1.3 otherwise always uses server preference in BoringSSL. An
+    // equal-preference group lets the client choose from the configured list.
+    std::fill(flags.begin(),
+              flags.end() - 1,
+              SSL_CIPHER_FLAG_EQUAL_PREFERENCE_WITH_NEXT);
+  }
+  // An empty input resets BoringSSL's built-in preference order. The public
+  // TLS API expands empty cipher strings to Node's defaults before this call.
+  return SSL_CTX_set1_tls13_ciphers(
+             get(), cipher_ids.data(), flags.data(), cipher_ids.size()) == 1;
 #endif
 }

diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js
index 65611de32d8..d339c4d774c 100644
--- a/lib/internal/tls/wrap.js
+++ b/lib/internal/tls/wrap.js
@@ -39,6 +39,8 @@ const {

 assertCrypto();

+const { openssl_is_boringssl: isBoringSSL } = process.features;
+
 const { setImmediate } = require('timers');
 const assert = require('internal/assert');
 const crypto = require('crypto');
@@ -1126,7 +1128,7 @@ TLSSocket.prototype.renegotiate = function(options, callback) {
     this._handle.renegotiate();
   } catch (err) {
     const isBoringSSLRenegotiationUnsupported =
-      process.features.openssl_is_boringssl &&
+      isBoringSSL &&
       err?.code === 'ERR_SSL_FUNCTION_SHOULD_NOT_HAVE_BEEN_CALLED';
     const error = isBoringSSLRenegotiationUnsupported ?
       new ERR_TLS_RENEGOTIATION_UNSUPPORTED() : err;
diff --git a/test/parallel/test-tls-getcipher.js b/test/parallel/test-tls-getcipher.js
index cb8f1ba826b..560a1b2a6e2 100644
--- a/test/parallel/test-tls-getcipher.js
+++ b/test/parallel/test-tls-getcipher.js
@@ -109,10 +109,7 @@ tls.createServer({
     rejectUnauthorized: false
   }, common.mustCall(() => {
     const cipher = client.getCipher();
-    const expectedCipher = isBoringSSL ?
-      'TLS_AES_128_GCM_SHA256' :
-      'TLS_AES_256_GCM_SHA384';
-    assert.strictEqual(cipher.name, expectedCipher);
+    assert.strictEqual(cipher.name, 'TLS_AES_256_GCM_SHA384');
     assert.strictEqual(cipher.standardName, cipher.name);
     assert.strictEqual(cipher.version, isBoringSSL ?
       'TLSv1/SSLv3' :
diff --git a/test/parallel/test-tls-set-ciphers-error.js b/test/parallel/test-tls-set-ciphers-error.js
index 102186f9e39..3cfc8c391bf 100644
--- a/test/parallel/test-tls-set-ciphers-error.js
+++ b/test/parallel/test-tls-set-ciphers-error.js
@@ -7,7 +7,7 @@ if (!common.hasCrypto)
 const assert = require('assert');
 const tls = require('tls');
 const fixtures = require('../common/fixtures');
-const { hasOpenSSL, isBoringSSL } = require('../common/crypto');
+const { hasOpenSSL } = require('../common/crypto');

 {
   const options = {
@@ -21,12 +21,8 @@ const { hasOpenSSL, isBoringSSL } = require('../common/crypto');
   assert.throws(() => tls.createServer(options, common.mustNotCall()),
                 /no[_ ]cipher[_ ]match/i);
   options.ciphers = 'TLS_not_a_cipher';
-  if (isBoringSSL) {
-    tls.createServer(options).close();
-  } else {
-    assert.throws(() => tls.createServer(options, common.mustNotCall()),
-                  /no[_ ]cipher[_ ]match/i);
-  }
+  assert.throws(() => tls.createServer(options, common.mustNotCall()),
+                /no[_ ]cipher[_ ]match/i);
 }

 // Cipher name matching is case-sensitive prior to OpenSSL 4.0, and
diff --git a/test/parallel/test-tls-set-ciphers.js b/test/parallel/test-tls-set-ciphers.js
index 57fb3599112..0562c808b8d 100644
--- a/test/parallel/test-tls-set-ciphers.js
+++ b/test/parallel/test-tls-set-ciphers.js
@@ -7,9 +7,10 @@ if (!common.hasCrypto) {
 const {
   hasOpenSSL,
   hasFIPS,
+  isBoringSSL,
 } = require('../common/crypto');

-if (!hasOpenSSL(3)) {
+if (!hasOpenSSL(3) && !isBoringSSL) {
   common.skip('missing crypto, or OpenSSL version lower than 3');
 }

@@ -133,10 +134,11 @@ if (hasFIPS(3)) {
   test('TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256',
        U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER');

-  test('AES256-SHA', 'AES256-SHA256', U, expectedTLSAlertError,
+  // Use TLS 1.2 suites available on both crypto backends.
+  test('AES256-SHA', 'ECDHE-RSA-AES256-GCM-SHA384', U, expectedTLSAlertError,
        'ERR_SSL_NO_SHARED_CIPHER');
   test('AES256-SHA:TLS_AES_256_GCM_SHA384',
-       'TLS_CHACHA20_POLY1305_SHA256:AES256-SHA256',
+       'TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES256-GCM-SHA384',
        U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER');

   // Cipher order ignored, TLS1.3 chosen before TLS1.2.
@@ -154,7 +156,10 @@ if (hasFIPS(3)) {
   // default, but work.
   // However, for OpenSSL32 AES_128 is not enabled due to the
   // default security level
-  if (!hasOpenSSL(3, 2)) {
+  if (isBoringSSL) {
+    // BoringSSL does not implement the TLS 1.3 CCM cipher suites.
+    test('TLS_AES_128_CCM_8_SHA256', U, U, 'ERR_SSL_NO_CIPHER_MATCH', U);
+  } else if (!hasOpenSSL(3, 2)) {
     test('TLS_AES_128_CCM_8_SHA256', U,
          U, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', 'ERR_SSL_NO_SHARED_CIPHER');

diff --git a/test/parallel/test-tls13-ciphers.js b/test/parallel/test-tls13-ciphers.js
new file mode 100644
index 00000000000..7cf87557e8d
--- /dev/null
+++ b/test/parallel/test-tls13-ciphers.js
@@ -0,0 +1,151 @@
+'use strict';
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const { constants } = require('crypto');
+const tls = require('tls');
+const fixtures = require('../common/fixtures');
+const { hasOpenSSL, isBoringSSL } = require('../common/crypto');
+const { spawnSyncAndAssert } = require('../common/child_process');
+const { connect, keys } = require(fixtures.path('tls-connect'));
+
+const aes128 = 'TLS_AES_128_GCM_SHA256';
+const aes256 = 'TLS_AES_256_GCM_SHA384';
+const tls13Ciphers = [aes128, aes256, 'TLS_CHACHA20_POLY1305_SHA256'];
+const legacy = 'ECDHE-RSA-AES256-GCM-SHA384';
+
+function test(clientCiphers, serverCiphers, expectedCipher, options = {}) {
+  connect({
+    client: {
+      rejectUnauthorized: false,
+      minVersion: 'TLSv1.2',
+      maxVersion: 'TLSv1.3',
+      ciphers: clientCiphers,
+      ...options.client,
+    },
+    server: {
+      key: keys.agent2.key,
+      cert: keys.agent2.cert,
+      minVersion: 'TLSv1.2',
+      maxVersion: 'TLSv1.3',
+      ciphers: serverCiphers,
+      ...options.server,
+    },
+  }, common.mustCall((err, pair, cleanup) => {
+    if (expectedCipher === undefined) {
+      assert(err);
+      assert.strictEqual(pair.server.err.code, 'ERR_SSL_NO_SHARED_CIPHER');
+      assert(pair.client.err);
+    } else {
+      assert.ifError(err);
+      const expected = Array.isArray(expectedCipher) ? expectedCipher : [expectedCipher];
+      const cipher = pair.client.conn.getCipher().name;
+      assert(expected.includes(cipher));
+      for (const { conn } of [pair.client, pair.server]) {
+        assert.strictEqual(conn.getCipher().name, cipher);
+        assert.strictEqual(conn.getProtocol(),
+                           expectedCipher === legacy ? 'TLSv1.2' : 'TLSv1.3');
+      }
+    }
+    cleanup();
+  }));
+}
+
+function testLegacyOnlyCipherLists() {
+  for (const honorCipherOrder of [true, false]) {
+    const options = { server: { honorCipherOrder } };
+    // A legacy-only list retains the backend's TLS 1.3 defaults. Check that
+    // each peer can negotiate either AES suite without assuming their order.
+    test(aes128, legacy, aes128, options);
+    test(aes256, legacy, aes256, options);
+    test(legacy, aes128, aes128, options);
+    test(legacy, aes256, aes256, options);
+    test(legacy, legacy, tls13Ciphers, options);
+  }
+}
+
+if (process.argv[2] !== undefined) {
+  const mode = process.argv[2];
+  if (mode === 'runtime-defaults') {
+    tls.DEFAULT_CIPHERS = aes128;
+  } else {
+    assert.strictEqual(tls.DEFAULT_CIPHERS,
+                       mode === 'cli-defaults' ? aes128 : legacy);
+  }
+  // Explicit TLS 1.3 defaults are still applied when ciphers is omitted, but
+  // must not replace the native defaults for an explicit legacy-only list.
+  test(undefined, undefined, mode === 'cli-legacy-defaults' ? tls13Ciphers : aes128);
+  testLegacyOnlyCipherLists();
+  return;
+}
+
+// Both peers must apply the configured TLS 1.3 cipher list.
+test(aes256, undefined, aes256);
+test(undefined, aes256, aes256);
+test(aes128, aes256, undefined);
+
+// Duplicate names and trailing whitespace must not cause rejection.
+const duplicates = `${aes256} \t:${aes256}`;
+test(duplicates, duplicates, aes256);
+
+// OpenSSL 1.1.1 rejects unknown TLS 1.3 cipher names. OpenSSL 3 and BoringSSL
+// ignore them and do not interpret exclusion rules in the TLS 1.3 list.
+const list = `TLS_UNKNOWN:!${aes128}:${duplicates}`;
+if (hasOpenSSL(3) || isBoringSSL) {
+  test(list, list, aes256);
+} else {
+  assert.throws(() => tls.createSecureContext({ ciphers: list }), {
+    code: 'ERR_SSL_NO_CIPHER_MATCH',
+  });
+}
+for (const ciphers of ['TLS_UNKNOWN', `!${aes128}`]) {
+  assert.throws(() => tls.createSecureContext({ ciphers }), {
+    code: 'ERR_SSL_NO_CIPHER_MATCH',
+  });
+}
+if (isBoringSSL) {
+  assert.throws(() => tls.createSecureContext({
+    ciphers: 'TLS_AES_128_CCM_SHA256',
+  }), {
+    code: 'ERR_SSL_NO_CIPHER_MATCH',
+  });
+}
+
+// Honor the server's cipher-order option in TLS 1.3 as well as TLS 1.2.
+test(`${aes256}:${aes128}`, `${aes128}:${aes256}`, aes128, {
+  server: { honorCipherOrder: true },
+});
+test(`${aes256}:${aes128}`, `${aes128}:${aes256}`, aes256, {
+  server: { honorCipherOrder: false },
+});
+testLegacyOnlyCipherLists();
+
+// TLS 1.2 configuration and explicit protocol restrictions remain effective.
+const mixed = `${legacy}:${aes256}`;
+test(mixed, mixed, aes256);
+test(mixed, mixed, legacy, {
+  client: { maxVersion: 'TLSv1.2' },
+  server: { maxVersion: 'TLSv1.2' },
+});
+test(mixed, mixed, legacy, {
+  server: { secureOptions: constants.SSL_OP_NO_TLSv1_3 },
+});
+
+// Empty public cipher strings continue to select Node's defaults.
+test('', aes256, aes256);
+test(aes256, '', aes256);
+
+for (const [mode, ciphers] of [
+  ['runtime-defaults', undefined],
+  ['cli-defaults', aes128],
+  ['cli-legacy-defaults', legacy],
+]) {
+  spawnSyncAndAssert(process.execPath, [
+    ...process.execArgv,
+    ...(ciphers === undefined ? [] : [`--tls-cipher-list=${ciphers}`]),
+    __filename,
+    mode,
+  ], {});
+}