Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
38 changes: 36 additions & 2 deletions doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
rejectUnauthorized,
enableEarlyData,
tlsTrace,
certificateCompression: packedCertificateCompression,
ca,
crl,
};
Expand Down
1 change: 1 addition & 0 deletions src/quic/bindingdata.h
Original file line number Diff line number Diff line change
Expand Up @@ -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") \
Expand Down
53 changes: 50 additions & 3 deletions src/quic/tlscontext.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>();
}

Expand Down Expand Up @@ -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());
Expand Down
10 changes: 10 additions & 0 deletions src/quic/tlscontext.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
112 changes: 112 additions & 0 deletions test/parallel/test-quic-certificate-compression.mjs
Original file line number Diff line number Diff line change
@@ -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: [] });
Loading