mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: https://github.com/nodejs/node/pull/64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
This commit is contained in:
1 parent
cf882a7904
commit
cc8594a9b1
6 files changed
+251
-5
No files matched your search
+36
-2
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
|
||||
large RSA intermediates. The choice of CA directly affects handshake latency.
|
||||
|
||||
Certificate compression ([RFC 8879][]) can also address this issue by
|
||||
compressing the certificate chain during the handshake. However, Node.js does
|
||||
not currently support TLS certificate compression.
|
||||
compressing the certificate chain during the handshake, often keeping the
|
||||
server's Certificate message within the amplification limit and avoiding the
|
||||
extra round trip. Certificate compression is opt-in via the
|
||||
[`certificateCompression`][] TLS option and is disabled by default. When
|
||||
enabled, it applies to both the server's certificate and, for mutual TLS,
|
||||
the client's certificate.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
@@ -2918,6 +2922,34 @@ added: v23.8.0
|
||||
The TLS certificates to use for client sessions. For server sessions,
|
||||
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
|
||||
|
||||
#### `sessionOptions.certificateCompression`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
|
||||
preference order.
|
||||
|
||||
Enables TLS certificate compression ([RFC 8879][]) for this session. When
|
||||
omitted, certificate compression is disabled.
|
||||
|
||||
On the server side, the certificate chain is compressed using the first
|
||||
listed algorithm that the client advertises support for. On the client side,
|
||||
the listed algorithms are advertised to the server so that the server may
|
||||
compress its certificate. When client authentication is in use, the option
|
||||
also controls compression of the client's certificate.
|
||||
|
||||
Compressing the certificate chain is especially useful for QUIC because it
|
||||
reduces the size of the server's first flight, which is bounded by the
|
||||
anti-amplification limit (see [Certificate size and handshake
|
||||
performance][]). Certificate compression requires TLS 1.3, which QUIC always
|
||||
uses.
|
||||
|
||||
At most three algorithms may be specified. The option is silently ignored if
|
||||
Node.js was built against a shared OpenSSL that lacks certificate compression
|
||||
support.
|
||||
|
||||
#### `sessionOptions.ciphers`
|
||||
|
||||
<!-- YAML
|
||||
@@ -4427,6 +4459,7 @@ throughput issues caused by flow control.
|
||||
|
||||
[Aborting a stream]: #aborting-a-stream
|
||||
[Callback error handling]: #callback-error-handling
|
||||
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
|
||||
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
|
||||
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
|
||||
[Permission Model]: permissions.md#permission-model
|
||||
@@ -4456,6 +4489,7 @@ throughput issues caused by flow control.
|
||||
[`application.enableConnectProtocol`]: #sessionoptionsapplication
|
||||
[`application.enableDatagrams`]: #sessionoptionsapplication
|
||||
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
|
||||
[`certificateCompression`]: #sessionoptionscertificatecompression
|
||||
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
|
||||
[`endpoint.busy`]: #endpointbusy
|
||||
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost
|
||||
|
||||
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
|
||||
* of protocol names in preference order.
|
||||
* @property {string} [ciphers] The TLS ciphers
|
||||
* @property {string} [groups] The TLS key-exchange groups
|
||||
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
|
||||
* TLS certificate compression algorithms to enable (RFC 8879), in
|
||||
* preference order. Certificate compression is disabled when omitted.
|
||||
* @property {boolean} [keylog] Enable TLS key logging
|
||||
* @property {boolean} [verifyClient] Verify the client certificate (server only)
|
||||
* @property {boolean} [tlsTrace] Enable TLS tracing
|
||||
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
|
||||
rejectUnauthorized = true,
|
||||
enableEarlyData = true,
|
||||
tlsTrace = false,
|
||||
certificateCompression,
|
||||
sni,
|
||||
// Client-only: identity options are specified directly (no sni map)
|
||||
keys,
|
||||
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
|
||||
validateBoolean(enableEarlyData, 'options.enableEarlyData');
|
||||
validateBoolean(tlsTrace, 'options.tlsTrace');
|
||||
|
||||
// Encode the certificate compression (RFC 8879) preference. The list of
|
||||
// algorithm names is packed into a single Uint32 for a cheap JS->C++
|
||||
// crossing, matching the encoding used by the node:tls implementation:
|
||||
// bits 0-7 : number of algorithms (1..3)
|
||||
// bits 8-15: algorithm id at position 0
|
||||
// bits 16-23: algorithm id at position 1
|
||||
// bits 24-31: algorithm id at position 2
|
||||
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
|
||||
// A value of 0 (the default) leaves certificate compression disabled.
|
||||
let packedCertificateCompression = 0;
|
||||
if (certificateCompression !== undefined) {
|
||||
if (!ArrayIsArray(certificateCompression)) {
|
||||
throw new ERR_INVALID_ARG_TYPE('options.certificateCompression',
|
||||
'Array', certificateCompression);
|
||||
}
|
||||
if (certificateCompression.length > 3) {
|
||||
throw new ERR_INVALID_ARG_VALUE('options.certificateCompression',
|
||||
certificateCompression,
|
||||
'can specify at most 3 algorithms');
|
||||
}
|
||||
let packed = certificateCompression.length;
|
||||
for (let i = 0; i < certificateCompression.length; i++) {
|
||||
const algoName = certificateCompression[i];
|
||||
let id;
|
||||
if (algoName === 'zlib') id = 1;
|
||||
else if (algoName === 'brotli') id = 2;
|
||||
else if (algoName === 'zstd') id = 3;
|
||||
else {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
`options.certificateCompression[${i}]`, algoName,
|
||||
"must be 'zlib', 'brotli', or 'zstd'");
|
||||
}
|
||||
packed |= id << (8 * (i + 1));
|
||||
}
|
||||
packedCertificateCompression = packed;
|
||||
}
|
||||
|
||||
// Encode the ALPN option to wire format (length-prefixed protocol names).
|
||||
// Server: array of protocol names. Client: single protocol name.
|
||||
// If not specified, the C++ default (h3) is used.
|
||||
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
|
||||
rejectUnauthorized,
|
||||
enableEarlyData,
|
||||
tlsTrace,
|
||||
certificateCompression: packedCertificateCompression,
|
||||
ca,
|
||||
crl,
|
||||
};
|
||||
|
||||
@@ -77,6 +77,7 @@ class SessionManager;
|
||||
V(bbr, "bbr") \
|
||||
V(ca, "ca") \
|
||||
V(cc_algorithm, "cc") \
|
||||
V(certificate_compression, "certificateCompression") \
|
||||
V(certs, "certs") \
|
||||
V(code, "code") \
|
||||
V(ciphers, "ciphers") \
|
||||
|
||||
+50
-3
@@ -12,6 +12,9 @@
|
||||
#include <ngtcp2/ngtcp2_crypto_ossl.h>
|
||||
#include <node_sockaddr-inl.h>
|
||||
#include <openssl/ssl.h>
|
||||
#ifdef NODE_OPENSSL_HAS_CERT_COMP
|
||||
#include <openssl/comp.h>
|
||||
#endif
|
||||
#include <util-inl.h>
|
||||
#include <v8.h>
|
||||
#include "application.h"
|
||||
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
|
||||
}
|
||||
}
|
||||
|
||||
// TLS certificate compression (RFC 8879). OpenSSL enables all available
|
||||
// algorithms by default once compression libraries are linked, so we
|
||||
// always clear the preference first to keep certificate compression
|
||||
// opt-in (matching the behavior of node:tls). When the
|
||||
// certificateCompression option is set, apply the requested algorithms
|
||||
// and pre-compress the certificate(s) loaded above. QUIC always uses
|
||||
// TLS 1.3, which is the minimum required for certificate compression.
|
||||
#ifdef NODE_OPENSSL_HAS_CERT_COMP
|
||||
{
|
||||
ClearErrorOnReturn clear_error_on_return;
|
||||
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
|
||||
|
||||
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
|
||||
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
|
||||
// _zstd (3).
|
||||
const uint32_t packed = options_.certificate_compression;
|
||||
const size_t len = packed & 0xff;
|
||||
if (len > 0) {
|
||||
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
|
||||
// number of usable algorithms is one fewer.
|
||||
constexpr size_t kMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
|
||||
if (len > kMaxCompAlgs) {
|
||||
validation_error_ = "Invalid certificate compression preference";
|
||||
return SSLCtxPointer();
|
||||
}
|
||||
int algs[kMaxCompAlgs];
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
|
||||
}
|
||||
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
|
||||
validation_error_ = "Failed to set certificate compression preference";
|
||||
return SSLCtxPointer();
|
||||
}
|
||||
// Pre-compress the loaded certificate(s) for the preferred algorithms.
|
||||
// Returns 0 when no certificate is loaded (e.g. a client context) or
|
||||
// when compression did not reduce the size; both are non-fatal.
|
||||
constexpr int kCompressAllAlgs = 0;
|
||||
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
|
||||
}
|
||||
}
|
||||
#endif // NODE_OPENSSL_HAS_CERT_COMP
|
||||
|
||||
{
|
||||
ClearErrorOnReturn clear_error_on_return;
|
||||
for (const auto& key : options_.keys) {
|
||||
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
|
||||
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
|
||||
!SET(servername) || !SET(ciphers) || !SET(groups) ||
|
||||
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
|
||||
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
|
||||
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
|
||||
!SET_VECTOR(Store, crl)) {
|
||||
!SET(certificate_compression) || !SET(authoritative) ||
|
||||
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
|
||||
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
|
||||
return Nothing<Options>();
|
||||
}
|
||||
|
||||
@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
|
||||
(verify_private_key ? std::string("yes") : std::string("no"));
|
||||
res += prefix + "ciphers: " + ciphers;
|
||||
res += prefix + "groups: " + groups;
|
||||
res += prefix +
|
||||
"certificate compression: " + std::to_string(certificate_compression);
|
||||
res += prefix + "keys: " + std::to_string(keys.size());
|
||||
res += prefix + "certs: " + std::to_string(certs.size());
|
||||
res += prefix + "ca: " + std::to_string(ca.size());
|
||||
|
||||
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
|
||||
// The list of TLS groups to use for this session.
|
||||
std::string groups = DEFAULT_GROUPS;
|
||||
|
||||
// TLS certificate compression (RFC 8879) preference, packed by the JS
|
||||
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
|
||||
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
|
||||
// 0 means certificate compression is disabled (the default). Certificate
|
||||
// compression is particularly valuable for QUIC because it reduces the
|
||||
// size of the server's Certificate message, which is otherwise likely to
|
||||
// exceed the anti-amplification limit and force an extra handshake round
|
||||
// trip. JavaScript option name "certificateCompression".
|
||||
uint32_t certificate_compression = 0;
|
||||
|
||||
// When true, enables keylog output for the session.
|
||||
bool keylog = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Flags: --experimental-quic --no-warnings
|
||||
|
||||
import { hasQuic, skip, mustCall } from '../common/index.mjs';
|
||||
import assert from 'node:assert';
|
||||
import * as fixtures from '../common/fixtures.mjs';
|
||||
|
||||
if (!hasQuic) {
|
||||
skip('QUIC is not enabled');
|
||||
}
|
||||
|
||||
const { listen, connect } = await import('node:quic');
|
||||
const { createPrivateKey } = await import('node:crypto');
|
||||
const tls = await import('node:tls');
|
||||
|
||||
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
|
||||
const cert = fixtures.readKey('agent1-cert.pem');
|
||||
|
||||
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
|
||||
// the compression libraries. Reuse the node:tls detection helper to decide
|
||||
// whether the feature is available.
|
||||
const supported = tls.getCertificateCompressionAlgorithms();
|
||||
if (supported.length === 0) {
|
||||
skip('certificate compression not supported by this OpenSSL build');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option validation. The certificateCompression option is parsed by
|
||||
// processTlsOptions during connect()/listen(), so invalid values reject the
|
||||
// returned promise before any network activity happens.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Non-array values are rejected.
|
||||
await assert.rejects(
|
||||
connect('127.0.0.1:4433', { certificateCompression: true }),
|
||||
{ code: 'ERR_INVALID_ARG_TYPE' });
|
||||
|
||||
await assert.rejects(
|
||||
connect('127.0.0.1:4433', { certificateCompression: 'zlib' }),
|
||||
{ code: 'ERR_INVALID_ARG_TYPE' });
|
||||
|
||||
// Unknown algorithm names are rejected.
|
||||
await assert.rejects(
|
||||
connect('127.0.0.1:4433', { certificateCompression: ['invalid'] }),
|
||||
{ code: 'ERR_INVALID_ARG_VALUE',
|
||||
message: /must be 'zlib', 'brotli', or 'zstd'/ });
|
||||
|
||||
// Non-string entries are rejected.
|
||||
await assert.rejects(
|
||||
connect('127.0.0.1:4433', { certificateCompression: [1] }),
|
||||
{ code: 'ERR_INVALID_ARG_VALUE',
|
||||
message: /must be 'zlib', 'brotli', or 'zstd'/ });
|
||||
|
||||
// At most three algorithms may be specified.
|
||||
await assert.rejects(
|
||||
connect('127.0.0.1:4433', {
|
||||
certificateCompression: ['zlib', 'brotli', 'zstd', 'zlib'],
|
||||
}),
|
||||
{ code: 'ERR_INVALID_ARG_VALUE', message: /at most 3 algorithms/ });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Functional handshakes. Enabling certificate compression must not break the
|
||||
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
|
||||
// node:tls certificate compression test; over QUIC the payload is encrypted
|
||||
// and carried in UDP datagrams, so here we assert the handshake completes
|
||||
// successfully with the option enabled on the server, the client, or both.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handshake({ serverAlgs, clientAlgs }) {
|
||||
const serverOpened = Promise.withResolvers();
|
||||
|
||||
const endpoint = await listen(mustCall(async (serverSession) => {
|
||||
const info = await serverSession.opened;
|
||||
assert.strictEqual(info.protocol, 'h3');
|
||||
serverOpened.resolve();
|
||||
serverSession.close();
|
||||
}), {
|
||||
sni: { '*': { keys: [key], certs: [cert] } },
|
||||
...(serverAlgs !== undefined ?
|
||||
{ certificateCompression: serverAlgs } : {}),
|
||||
});
|
||||
|
||||
const clientSession = await connect(endpoint.address, {
|
||||
servername: 'localhost',
|
||||
verifyPeer: 'manual',
|
||||
...(clientAlgs !== undefined ?
|
||||
{ certificateCompression: clientAlgs } : {}),
|
||||
});
|
||||
|
||||
const info = await clientSession.opened;
|
||||
assert.strictEqual(info.protocol, 'h3');
|
||||
|
||||
await serverOpened.promise;
|
||||
await clientSession.close();
|
||||
await endpoint.close();
|
||||
}
|
||||
|
||||
// Each supported algorithm negotiates a successful handshake when enabled on
|
||||
// both peers.
|
||||
for (const algo of supported) {
|
||||
await handshake({ serverAlgs: [algo], clientAlgs: [algo] });
|
||||
}
|
||||
|
||||
// All supported algorithms advertised together.
|
||||
await handshake({ serverAlgs: supported, clientAlgs: supported });
|
||||
|
||||
// Asymmetric configurations must also complete: a peer that does not advertise
|
||||
// compression simply receives an uncompressed certificate.
|
||||
await handshake({ serverAlgs: [supported[0]], clientAlgs: undefined });
|
||||
await handshake({ serverAlgs: undefined, clientAlgs: [supported[0]] });
|
||||
|
||||
// An empty array is equivalent to disabling compression.
|
||||
await handshake({ serverAlgs: [], clientAlgs: [] });
|
||||
Reference in new issue
Block a user