Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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