Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)
, '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

Commit 50e639b

Browse files
bjohansebasaduh95
authored andcommitted
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com> PR-URL: #64434 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent de4ca2c commit 50e639b

6 files changed

Lines changed: 251 additions & 5 deletions

File tree

β€Ždoc/api/quic.mdβ€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
137137
large RSA intermediates. The choice of CA directly affects handshake latency.
138138

139139
Certificate compression ([RFC 8879][]) can also address this issue by
140-
compressing the certificate chain during the handshake. However, Node.js does
141-
not currently support TLS certificate compression.
140+
compressing the certificate chain during the handshake, often keeping the
141+
server's Certificate message within the amplification limit and avoiding the
142+
extra round trip. Certificate compression is opt-in via the
143+
[`certificateCompression`][] TLS option and is disabled by default. When
144+
enabled, it applies to both the server's certificate and, for mutual TLS,
145+
the client's certificate.
142146

143147
### Rate limiting
144148

@@ -2894,6 +2898,34 @@ added: v23.8.0
28942898
The TLS certificates to use for client sessions. For server sessions,
28952899
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
28962900

2901+
#### `sessionOptions.certificateCompression`
2902+
2903+
<!-- YAML
2904+
added: REPLACEME
2905+
-->
2906+
2907+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2908+
preference order.
2909+
2910+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2911+
omitted, certificate compression is disabled.
2912+
2913+
On the server side, the certificate chain is compressed using the first
2914+
listed algorithm that the client advertises support for. On the client side,
2915+
the listed algorithms are advertised to the server so that the server may
2916+
compress its certificate. When client authentication is in use, the option
2917+
also controls compression of the client's certificate.
2918+
2919+
Compressing the certificate chain is especially useful for QUIC because it
2920+
reduces the size of the server's first flight, which is bounded by the
2921+
anti-amplification limit (see [Certificate size and handshake
2922+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2923+
uses.
2924+
2925+
At most three algorithms may be specified. The option is silently ignored if
2926+
Node.js was built against a shared OpenSSL that lacks certificate compression
2927+
support.
2928+
28972929
#### `sessionOptions.ciphers`
28982930

28992931
<!-- YAML
@@ -4399,6 +4431,7 @@ throughput issues caused by flow control.
43994431
44004432
[Aborting a stream]: #aborting-a-stream
44014433
[Callback error handling]: #callback-error-handling
4434+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44024435
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44034436
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44044437
[RFC 8879]: https://www.rfc-editor.org/rfc/rfc8879
@@ -4426,6 +4459,7 @@ throughput issues caused by flow control.
44264459
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44274460
[`application.enableDatagrams`]: #sessionoptionsapplication
44284461
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4462+
[`certificateCompression`]: #sessionoptionscertificatecompression
44294463
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44304464
[`endpoint.busy`]: #endpointbusy
44314465
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost

β€Žlib/internal/quic/quic.jsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
404404
* of protocol names in preference order.
405405
* @property {string} [ciphers] The TLS ciphers
406406
* @property {string} [groups] The TLS key-exchange groups
407+
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
408+
* TLS certificate compression algorithms to enable (RFC 8879), in
409+
* preference order. Certificate compression is disabled when omitted.
407410
* @property {boolean} [keylog] Enable TLS key logging
408411
* @property {boolean} [verifyClient] Verify the client certificate (server only)
409412
* @property {boolean} [tlsTrace] Enable TLS tracing
@@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
49384941
rejectUnauthorized =true,
49394942
enableEarlyData =true,
49404943
tlsTrace =false,
4944+
certificateCompression,
49414945
sni,
49424946
// Client-only: identity options are specified directly (no sni map)
49434947
keys,
@@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
49624966
validateBoolean(enableEarlyData,'options.enableEarlyData');
49634967
validateBoolean(tlsTrace,'options.tlsTrace');
49644968

4969+
// Encode the certificate compression (RFC 8879) preference. The list of
4970+
// algorithm names is packed into a single Uint32 for a cheap JS->C++
4971+
// crossing, matching the encoding used by the node:tls implementation:
4972+
// bits 0-7 : number of algorithms (1..3)
4973+
// bits 8-15: algorithm id at position 0
4974+
// bits 16-23: algorithm id at position 1
4975+
// bits 24-31: algorithm id at position 2
4976+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
4977+
// A value of 0 (the default) leaves certificate compression disabled.
4978+
letpackedCertificateCompression=0;
4979+
if(certificateCompression!==undefined){
4980+
if(!ArrayIsArray(certificateCompression)){
4981+
thrownewERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array',certificateCompression);
4983+
}
4984+
if(certificateCompression.length>3){
4985+
thrownewERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
letpacked=certificateCompression.length;
4990+
for(leti=0;i<certificateCompression.length;i++){
4991+
constalgoName=certificateCompression[i];
4992+
letid;
4993+
if(algoName==='zlib')id=1;
4994+
elseif(algoName==='brotli')id=2;
4995+
elseif(algoName==='zstd')id=3;
4996+
else{
4997+
thrownewERR_INVALID_ARG_VALUE(
4998+
`options.certificateCompression[${i}]`,algoName,
4999+
"must be 'zlib', 'brotli', or 'zstd'");
5000+
}
5001+
packed|=id<<(8*(i+1));
5002+
}
5003+
packedCertificateCompression=packed;
5004+
}
5005+
49655006
// Encode the ALPN option to wire format (length-prefixed protocol names).
49665007
// Server: array of protocol names. Client: single protocol name.
49675008
// If not specified, the C++ default (h3) is used.
@@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
50275068
rejectUnauthorized,
50285069
enableEarlyData,
50295070
tlsTrace,
5071+
certificateCompression: packedCertificateCompression,
50305072
ca,
50315073
crl,
50325074
};

β€Žsrc/quic/bindingdata.hβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SessionManager;
7777
V(bbr, "bbr") \
7878
V(ca, "ca") \
7979
V(cc_algorithm, "cc") \
80+
V(certificate_compression, "certificateCompression") \
8081
V(certs, "certs") \
8182
V(code, "code") \
8283
V(ciphers, "ciphers") \

β€Žsrc/quic/tlscontext.ccβ€Ž

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
#include<ngtcp2/ngtcp2_crypto_ossl.h>
1313
#include<node_sockaddr-inl.h>
1414
#include<openssl/ssl.h>
15+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
16+
#include<openssl/comp.h>
17+
#endif
1518
#include<util-inl.h>
1619
#include<v8.h>
1720
#include"application.h"
@@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
594597
}
595598
}
596599

600+
// TLS certificate compression (RFC 8879). OpenSSL enables all available
601+
// algorithms by default once compression libraries are linked, so we
602+
// always clear the preference first to keep certificate compression
603+
// opt-in (matching the behavior of node:tls). When the
604+
// certificateCompression option is set, apply the requested algorithms
605+
// and pre-compress the certificate(s) loaded above. QUIC always uses
606+
// TLS 1.3, which is the minimum required for certificate compression.
607+
#ifdef NODE_OPENSSL_HAS_CERT_COMP
608+
{
609+
ClearErrorOnReturn clear_error_on_return;
610+
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);
611+
612+
// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
613+
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
614+
// _zstd (3).
615+
constuint32_t packed = options_.certificate_compression;
616+
constsize_t len = packed & 0xff;
617+
if (len > 0) {
618+
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
619+
// number of usable algorithms is one fewer.
620+
constexprsize_tkMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
returnSSLCtxPointer();
624+
}
625+
int algs[kMaxCompAlgs];
626+
for (size_t i = 0; i < len; i++) {
627+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
628+
}
629+
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
630+
validation_error_ = "Failed to set certificate compression preference";
631+
returnSSLCtxPointer();
632+
}
633+
// Pre-compress the loaded certificate(s) for the preferred algorithms.
634+
// Returns 0 when no certificate is loaded (e.g. a client context) or
635+
// when compression did not reduce the size; both are non-fatal.
636+
constexprintkCompressAllAlgs = 0;
637+
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
638+
}
639+
}
640+
#endif// NODE_OPENSSL_HAS_CERT_COMP
641+
597642
{
598643
ClearErrorOnReturn clear_error_on_return;
599644
for (constauto& key : options_.keys) {
@@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
730775
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
731776
!SET(servername) || !SET(ciphers) || !SET(groups) ||
732777
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
733-
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
734-
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
735-
!SET_VECTOR(Store, crl)) {
778+
!SET(certificate_compression) || !SET(authoritative) ||
779+
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
780+
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
736781
return Nothing<Options>();
737782
}
738783

@@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
761806
(verify_private_key ? std::string("yes") : std::string("no"));
762807
res += prefix + "ciphers: " + ciphers;
763808
res += prefix + "groups: " + groups;
809+
res += prefix +
810+
"certificate compression: " + std::to_string(certificate_compression);
764811
res += prefix + "keys: " + std::to_string(keys.size());
765812
res += prefix + "certs: " + std::to_string(certs.size());
766813
res += prefix + "ca: " + std::to_string(ca.size());

β€Žsrc/quic/tlscontext.hβ€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
192192
// The list of TLS groups to use for this session.
193193
std::string groups = DEFAULT_GROUPS;
194194

195+
// TLS certificate compression (RFC 8879) preference, packed by the JS
196+
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
197+
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
198+
// 0 means certificate compression is disabled (the default). Certificate
199+
// compression is particularly valuable for QUIC because it reduces the
200+
// size of the server's Certificate message, which is otherwise likely to
201+
// exceed the anti-amplification limit and force an extra handshake round
202+
// trip. JavaScript option name "certificateCompression".
203+
uint32_t certificate_compression = 0;
204+
195205
// When true, enables keylog output for the session.
196206
bool keylog = false;
197207

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
import{hasQuic,skip,mustCall}from'../common/index.mjs';
4+
importassertfrom'node:assert';
5+
import*asfixturesfrom'../common/fixtures.mjs';
6+
7+
if(!hasQuic){
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const{ listen, connect }=awaitimport('node:quic');
12+
const{ createPrivateKey }=awaitimport('node:crypto');
13+
consttls=awaitimport('node:tls');
14+
15+
constkey=createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
constcert=fixtures.readKey('agent1-cert.pem');
17+
18+
// Certificate compression (RFC 8879) depends on the OpenSSL build linking in
19+
// the compression libraries. Reuse the node:tls detection helper to decide
20+
// whether the feature is available.
21+
constsupported=tls.getCertificateCompressionAlgorithms();
22+
if(supported.length===0){
23+
skip('certificate compression not supported by this OpenSSL build');
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// Option validation. The certificateCompression option is parsed by
28+
// processTlsOptions during connect()/listen(), so invalid values reject the
29+
// returned promise before any network activity happens.
30+
// ---------------------------------------------------------------------------
31+
{
32+
// Non-array values are rejected.
33+
awaitassert.rejects(
34+
connect('127.0.0.1:4433',{certificateCompression: true}),
35+
{code: 'ERR_INVALID_ARG_TYPE'});
36+
37+
awaitassert.rejects(
38+
connect('127.0.0.1:4433',{certificateCompression: 'zlib'}),
39+
{code: 'ERR_INVALID_ARG_TYPE'});
40+
41+
// Unknown algorithm names are rejected.
42+
awaitassert.rejects(
43+
connect('127.0.0.1:4433',{certificateCompression: ['invalid']}),
44+
{code: 'ERR_INVALID_ARG_VALUE',
45+
message: /mustbe'zlib','brotli',or'zstd'/});
46+
47+
// Non-string entries are rejected.
48+
awaitassert.rejects(
49+
connect('127.0.0.1:4433',{certificateCompression: [1]}),
50+
{code: 'ERR_INVALID_ARG_VALUE',
51+
message: /mustbe'zlib','brotli',or'zstd'/});
52+
53+
// At most three algorithms may be specified.
54+
awaitassert.rejects(
55+
connect('127.0.0.1:4433',{
56+
certificateCompression: ['zlib','brotli','zstd','zlib'],
57+
}),
58+
{code: 'ERR_INVALID_ARG_VALUE',message: /atmost3algorithms/});
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Functional handshakes. Enabling certificate compression must not break the
63+
// TLS 1.3 handshake. The on-the-wire size reduction is exercised by the
64+
// node:tls certificate compression test; over QUIC the payload is encrypted
65+
// and carried in UDP datagrams, so here we assert the handshake completes
66+
// successfully with the option enabled on the server, the client, or both.
67+
// ---------------------------------------------------------------------------
68+
asyncfunctionhandshake({ serverAlgs, clientAlgs }){
69+
constserverOpened=Promise.withResolvers();
70+
71+
constendpoint=awaitlisten(mustCall(async(serverSession)=>{
72+
constinfo=awaitserverSession.opened;
73+
assert.strictEqual(info.protocol,'h3');
74+
serverOpened.resolve();
75+
serverSession.close();
76+
}),{
77+
sni: {'*': {keys: [key],certs: [cert]}},
78+
...(serverAlgs!==undefined ?
79+
{certificateCompression: serverAlgs} : {}),
80+
});
81+
82+
constclientSession=awaitconnect(endpoint.address,{
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs!==undefined ?
86+
{certificateCompression: clientAlgs} : {}),
87+
});
88+
89+
constinfo=awaitclientSession.opened;
90+
assert.strictEqual(info.protocol,'h3');
91+
92+
awaitserverOpened.promise;
93+
awaitclientSession.close();
94+
awaitendpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for(constalgoofsupported){
100+
awaithandshake({serverAlgs: [algo],clientAlgs: [algo]});
101+
}
102+
103+
// All supported algorithms advertised together.
104+
awaithandshake({serverAlgs: supported,clientAlgs: supported});
105+
106+
// Asymmetric configurations must also complete: a peer that does not advertise
107+
// compression simply receives an uncompressed certificate.
108+
awaithandshake({serverAlgs: [supported[0]],clientAlgs: undefined});
109+
awaithandshake({serverAlgs: undefined,clientAlgs: [supported[0]]});
110+
111+
// An empty array is equivalent to disabling compression.
112+
awaithandshake({serverAlgs: [],clientAlgs: []});

0 commit comments

Comments
Β (0)