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>
This commit is contained in:
Filip Skokan authored and Node.js GitHub Bot committed 2026-10-24 22:04:15 +00:00
1 parent 7e58295439
commit a0b4170764
6 files changed
+215 -20

No files matched your search

+48 -4
View File
@@ -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
}
+3 -1
View File
@@ -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;
+1 -4
View File
@@ -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' :
+3 -7
View File
@@ -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
+9 -4
View File
@@ -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');
+151
View File
@@ -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,
], {});
}