Skip to content

Commit 51cd687

Browse files
committed
quic: add support for TLS certificate compression
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com>
1 parent 8a3b11c commit 51cd687

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

@@ -2918,6 +2922,34 @@ added: v23.8.0
29182922
The TLS certificates to use for client sessions. For server sessions,
29192923
certificates are specified per-identity in the [`sessionOptions.sni`][] map.
29202924

2925+
#### `sessionOptions.certificateCompression`
2926+
2927+
<!-- YAML
2928+
added: REPLACEME
2929+
-->
2930+
2931+
* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
2932+
preference order.
2933+
2934+
Enables TLS certificate compression ([RFC 8879][]) for this session. When
2935+
omitted, certificate compression is disabled.
2936+
2937+
On the server side, the certificate chain is compressed using the first
2938+
listed algorithm that the client advertises support for. On the client side,
2939+
the listed algorithms are advertised to the server so that the server may
2940+
compress its certificate. When client authentication is in use, the option
2941+
also controls compression of the client's certificate.
2942+
2943+
Compressing the certificate chain is especially useful for QUIC because it
2944+
reduces the size of the server's first flight, which is bounded by the
2945+
anti-amplification limit (see [Certificate size and handshake
2946+
performance][]). Certificate compression requires TLS 1.3, which QUIC always
2947+
uses.
2948+
2949+
At most three algorithms may be specified. The option is silently ignored if
2950+
Node.js was built against a shared OpenSSL that lacks certificate compression
2951+
support.
2952+
29212953
#### `sessionOptions.ciphers`
29222954

29232955
<!-- YAML
@@ -4427,6 +4459,7 @@ throughput issues caused by flow control.
44274459
44284460
[Aborting a stream]: #aborting-a-stream
44294461
[Callback error handling]: #callback-error-handling
4462+
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
44304463
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
44314464
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
44324465
[Permission Model]: permissions.md#permission-model
@@ -4456,6 +4489,7 @@ throughput issues caused by flow control.
44564489
[`application.enableConnectProtocol`]: #sessionoptionsapplication
44574490
[`application.enableDatagrams`]: #sessionoptionsapplication
44584491
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
4492+
[`certificateCompression`]: #sessionoptionscertificatecompression
44594493
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
44604494
[`endpoint.busy`]: #endpointbusy
44614495
[`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+
let packedCertificateCompression = 0;
4979+
if (certificateCompression !== undefined) {
4980+
if (!ArrayIsArray(certificateCompression)) {
4981+
throw new ERR_INVALID_ARG_TYPE('options.certificateCompression',
4982+
'Array', certificateCompression);
4983+
}
4984+
if (certificateCompression.length > 3) {
4985+
throw new ERR_INVALID_ARG_VALUE('options.certificateCompression',
4986+
certificateCompression,
4987+
'can specify at most 3 algorithms');
4988+
}
4989+
let packed = certificateCompression.length;
4990+
for (let i = 0; i < certificateCompression.length; i++) {
4991+
const algoName = certificateCompression[i];
4992+
let id;
4993+
if (algoName === 'zlib') id = 1;
4994+
else if (algoName === 'brotli') id = 2;
4995+
else if (algoName === 'zstd') id = 3;
4996+
else {
4997+
throw new ERR_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+
const uint32_t packed = options_.certificate_compression;
616+
const size_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+
constexpr size_t kMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
621+
if (len > kMaxCompAlgs) {
622+
validation_error_ = "Invalid certificate compression preference";
623+
return SSLCtxPointer();
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+
return SSLCtxPointer();
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+
constexpr int kCompressAllAlgs = 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 (const auto& 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+
import assert from 'node:assert';
5+
import * as fixtures from '../common/fixtures.mjs';
6+
7+
if (!hasQuic) {
8+
skip('QUIC is not enabled');
9+
}
10+
11+
const { listen, connect } = await import('node:quic');
12+
const { createPrivateKey } = await import('node:crypto');
13+
const tls = await import('node:tls');
14+
15+
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
16+
const cert = 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+
const supported = 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+
await assert.rejects(
34+
connect('127.0.0.1:4433', { certificateCompression: true }),
35+
{ code: 'ERR_INVALID_ARG_TYPE' });
36+
37+
await assert.rejects(
38+
connect('127.0.0.1:4433', { certificateCompression: 'zlib' }),
39+
{ code: 'ERR_INVALID_ARG_TYPE' });
40+
41+
// Unknown algorithm names are rejected.
42+
await assert.rejects(
43+
connect('127.0.0.1:4433', { certificateCompression: ['invalid'] }),
44+
{ code: 'ERR_INVALID_ARG_VALUE',
45+
message: /must be 'zlib', 'brotli', or 'zstd'/ });
46+
47+
// Non-string entries are rejected.
48+
await assert.rejects(
49+
connect('127.0.0.1:4433', { certificateCompression: [1] }),
50+
{ code: 'ERR_INVALID_ARG_VALUE',
51+
message: /must be 'zlib', 'brotli', or 'zstd'/ });
52+
53+
// At most three algorithms may be specified.
54+
await assert.rejects(
55+
connect('127.0.0.1:4433', {
56+
certificateCompression: ['zlib', 'brotli', 'zstd', 'zlib'],
57+
}),
58+
{ code: 'ERR_INVALID_ARG_VALUE', message: /at most 3 algorithms/ });
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+
async function handshake({ serverAlgs, clientAlgs }) {
69+
const serverOpened = Promise.withResolvers();
70+
71+
const endpoint = await listen(mustCall(async (serverSession) => {
72+
const info = await serverSession.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+
const clientSession = await connect(endpoint.address, {
83+
servername: 'localhost',
84+
verifyPeer: 'manual',
85+
...(clientAlgs !== undefined ?
86+
{ certificateCompression: clientAlgs } : {}),
87+
});
88+
89+
const info = await clientSession.opened;
90+
assert.strictEqual(info.protocol, 'h3');
91+
92+
await serverOpened.promise;
93+
await clientSession.close();
94+
await endpoint.close();
95+
}
96+
97+
// Each supported algorithm negotiates a successful handshake when enabled on
98+
// both peers.
99+
for (const algo of supported) {
100+
await handshake({ serverAlgs: [algo], clientAlgs: [algo] });
101+
}
102+
103+
// All supported algorithms advertised together.
104+
await handshake({ 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+
await handshake({ serverAlgs: [supported[0]], clientAlgs: undefined });
109+
await handshake({ serverAlgs: undefined, clientAlgs: [supported[0]] });
110+
111+
// An empty array is equivalent to disabling compression.
112+
await handshake({ serverAlgs: [], clientAlgs: [] });

0 commit comments

Comments
 (0)