Skip to content

Commit 7b7c2de

Browse files
committed
fixup! tls: add certificateCompression option
Address various review comments: * Pack compression args into a uint32 instead of transferring as a full string[]. * Reject compression settings if TLS 1.3 is excluded. * Use a constant for 'compress with all algorithms' arg * Use RAII for compressed cert data * Add tests to verify we reject large certs and that *record* compression is not enabled. Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent 78f8486 commit 7b7c2de

3 files changed

Lines changed: 140 additions & 32 deletions

File tree

lib/internal/tls/secure-context.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,33 @@ function configSecureContext(context, options = kEmptyObject, name = 'options')
218218
}
219219

220220
if (certificateCompression.length > 0) {
221-
context.setCertificateCompression(certificateCompression);
221+
// Pack length + algorithm IDs into a single Uint32 for a cheap
222+
// JS->C++ crossing. Layout:
223+
// bits 0-7 : length (1..3)
224+
// bits 8-15: alg id at position 0
225+
// bits 16-23: alg id at position 1
226+
// bits 24-31: alg id at position 2
227+
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3)
228+
if (certificateCompression.length > 3) {
229+
throw new ERR_INVALID_ARG_VALUE(
230+
`${name}.certificateCompression`, certificateCompression,
231+
'can specify at most 3 algorithms');
232+
}
233+
let packed = certificateCompression.length;
234+
for (let i = 0; i < certificateCompression.length; i++) {
235+
const algoName = certificateCompression[i];
236+
let id;
237+
if (algoName === 'zlib') id = 1;
238+
else if (algoName === 'brotli') id = 2;
239+
else if (algoName === 'zstd') id = 3;
240+
else {
241+
throw new ERR_INVALID_ARG_VALUE(
242+
`${name}.certificateCompression[${i}]`, algoName,
243+
"must be 'zlib', 'brotli', or 'zstd'");
244+
}
245+
packed |= id << (8 * (i + 1));
246+
}
247+
context.setCertificateCompression(packed);
222248
}
223249
}
224250

src/crypto/crypto_context.cc

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2149,54 +2149,49 @@ void SecureContext::SetCertificateCompression(
21492149
Environment* env = sc->env();
21502150

21512151
CHECK_GE(args.Length(), 1);
2152-
CHECK(args[0]->IsArray());
2152+
CHECK(args[0]->IsUint32());
2153+
2154+
// Cert compression requires TLS 1.3:
2155+
long max_proto = // NOLINT(runtime/int)
2156+
SSL_CTX_get_max_proto_version(sc->ctx_.get());
2157+
if (max_proto != 0 && max_proto < TLS1_3_VERSION) {
2158+
return THROW_ERR_INVALID_ARG_VALUE(
2159+
env,
2160+
"certificateCompression requires a TLS protocol range that includes "
2161+
"TLSv1.3");
2162+
}
21532163

2154-
Local<Array> arr = args[0].As<Array>();
2155-
uint32_t len = arr->Length();
2164+
// JS packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a single Uint32.
2165+
// IDs match TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
2166+
uint32_t packed = args[0].As<v8::Uint32>()->Value();
2167+
size_t len = packed & 0xff;
21562168

21572169
// TLSEXT_comp_cert_limit is the limit for a zero-terminated algs array,
21582170
// total number of available algs is one fewer.
2159-
constexpr uint32_t kMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
2171+
constexpr size_t kMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
21602172
if (len == 0 || len > kMaxCompAlgs) {
21612173
return THROW_ERR_INVALID_ARG_VALUE(
21622174
env,
21632175
"certificateCompression must specify fewer than %d algorithms",
2164-
kMaxCompAlgs);
2176+
static_cast<int>(kMaxCompAlgs));
21652177
}
21662178

21672179
#ifndef OPENSSL_NO_COMP_ALG
21682180
int algs[kMaxCompAlgs];
2169-
for (uint32_t i = 0; i < len; i++) {
2170-
Local<Value> val;
2171-
if (!arr->Get(env->context(), i).ToLocal(&val) || !val->IsString()) {
2172-
return THROW_ERR_INVALID_ARG_VALUE(
2173-
env, "certificateCompression entries must be strings");
2174-
}
2175-
Utf8Value name(env->isolate(), val);
2176-
if (name.ToStringView() == "zlib") {
2177-
algs[i] = TLSEXT_comp_cert_zlib;
2178-
} else if (name.ToStringView() == "brotli") {
2179-
algs[i] = TLSEXT_comp_cert_brotli;
2180-
} else if (name.ToStringView() == "zstd") {
2181-
algs[i] = TLSEXT_comp_cert_zstd;
2182-
} else {
2183-
return THROW_ERR_INVALID_ARG_VALUE(
2184-
env,
2185-
"certificateCompression algorithm must be 'zlib', 'brotli', or "
2186-
"'zstd'");
2187-
}
2181+
for (size_t i = 0; i < len; i++) {
2182+
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
21882183
}
21892184
if (!SSL_CTX_set1_cert_comp_preference(
21902185
sc->ctx_.get(), algs, static_cast<size_t>(len))) {
21912186
return THROW_ERR_CRYPTO_OPERATION_FAILED(
21922187
env, "Failed to set certificate compression preference");
21932188
}
21942189

2195-
// Pre-compress the loaded certificate(s) for all supported algorithms, where
2196-
// 0 arg means 'compress with all algorithms in the preference list'.
2190+
// Pre-compress the loaded certificate(s) for all supported algorithms.
21972191
// Returns 0 when no certificate is loaded (e.g. client-only context) or
21982192
// when compression did not reduce size - both are non-fatal.
2199-
SSL_CTX_compress_certs(sc->ctx_.get(), 0);
2193+
constexpr int kCompressAllAlgs = 0;
2194+
SSL_CTX_compress_certs(sc->ctx_.get(), kCompressAllAlgs);
22002195

22012196
// Store preferences for propagation during SNI context switches.
22022197
memcpy(sc->cert_comp_prefs_, algs, sizeof(int) * len);
@@ -2206,17 +2201,17 @@ void SecureContext::SetCertificateCompression(
22062201
// setSniContext uses SSL_use_certificate which doesn't carry comp_cert data,
22072202
// so we extract it here and re-apply via SSL_set1_compressed_cert later.
22082203
sc->compressed_certs_.clear();
2209-
for (uint32_t i = 0; i < len; i++) {
2204+
for (size_t i = 0; i < len; i++) {
22102205
unsigned char* data = nullptr;
22112206
size_t orig_len = 0;
22122207
size_t comp_len =
22132208
SSL_CTX_get1_compressed_cert(sc->ctx_.get(), algs[i], &data, &orig_len);
2209+
ncrypto::DataPointer comp(data, comp_len);
22142210
if (comp_len > 0 && data != nullptr) {
22152211
sc->compressed_certs_.push_back(
22162212
{algs[i],
22172213
std::vector<unsigned char>(data, data + comp_len),
22182214
orig_len});
2219-
OPENSSL_free(data);
22202215
}
22212216
}
22222217
#else

test/parallel/test-tls-certificate-compression.js

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ const fixtureCert = fixtures.readKey('agent1-cert.pem');
3737
// Invalid algorithm name should throw
3838
assert.throws(
3939
() => tls.createSecureContext({ certificateCompression: ['invalid'] }),
40-
/certificateCompression algorithm must be/
40+
/must be 'zlib', 'brotli', or 'zstd'/
4141
);
4242

4343
// Non-string entries should throw
4444
assert.throws(
4545
() => tls.createSecureContext({ certificateCompression: [1] }),
46-
/certificateCompression entries must be strings/
46+
/must be 'zlib', 'brotli', or 'zstd'/
4747
);
4848

4949
// Valid single algorithms should not throw
@@ -65,8 +65,95 @@ const fixtureCert = fixtures.readKey('agent1-cert.pem');
6565

6666
// Default (no certificateCompression) still works
6767
tls.createSecureContext({ key: fixtureKey, cert: fixtureCert });
68+
69+
// Protocol range that excludes TLSv1.3 should throw - RFC 8879 is 1.3-only.
70+
assert.throws(
71+
() => tls.createSecureContext({
72+
key: fixtureKey, cert: fixtureCert,
73+
maxVersion: 'TLSv1.2',
74+
certificateCompression: ['zlib'],
75+
}),
76+
/TLSv1\.3/,
77+
);
6878
}
6979

80+
// Test: certificate compression must not enable record-level compression.
81+
//
82+
// RFC 8879 certificate compression is unrelated to TLS record compression
83+
// (the CRIME attack vector). The latter is disabled by Node at startup via
84+
// sk_SSL_COMP_zero, and by modern OpenSSL defaults as well. To confirm, we
85+
// Verify a ClientHello sent by Node only advertises null record compression.
86+
(async () => {
87+
const { promise: clientHelloReceived, resolve: onClientHello } =
88+
Promise.withResolvers();
89+
const tcpServer = net.createServer((socket) => {
90+
socket.once('data', (chunk) => {
91+
onClientHello(chunk);
92+
socket.destroy();
93+
});
94+
socket.on('error', () => {});
95+
});
96+
tcpServer.listen(0);
97+
await once(tcpServer, 'listening');
98+
99+
const probe = tls.connect({
100+
port: tcpServer.address().port,
101+
rejectUnauthorized: false,
102+
certificateCompression: ['zlib', 'brotli', 'zstd'],
103+
});
104+
probe.on('error', () => {});
105+
106+
const clientHello = await clientHelloReceived;
107+
tcpServer.close();
108+
probe.destroy();
109+
110+
// ClientHello layout (RFC 8446 4.1.2 + 5.1 record layer):
111+
// record header (5) | handshake header (4) | legacy_version (2)
112+
// | random (32) | session_id <1..32> | cipher_suites <2..>
113+
// | compression_methods <1..> | extensions <2..>
114+
assert.strictEqual(clientHello[0], 0x16); // Handshake record
115+
assert.strictEqual(clientHello[5], 0x01); // ClientHello
116+
let i = 5 + 4 + 2 + 32;
117+
i += 1 + clientHello[i]; // Skip legacy_session_id
118+
i += 2 + clientHello.readUInt16BE(i); // Skip cipher_suites
119+
const methods = clientHello.subarray(i + 1, i + 1 + clientHello[i]);
120+
// Must only advertise the null compression method.
121+
assert.deepStrictEqual([...methods], [0x00]);
122+
})().then(common.mustCall());
123+
124+
// Test: a CompressedCertificate whose uncompressed length exceeds OpenSSL's
125+
// default max_cert_list (100 KB) is rejected before decompression, protecting
126+
// against decompression bombs in malicious server's cert chains.
127+
(async () => {
128+
// Cert just repeats our existing fixture many times
129+
const bigChain = Buffer.concat(
130+
Array(200).fill(Buffer.from(fixtureCert))
131+
);
132+
133+
const server = tls.createServer({
134+
key: fixtureKey, cert: bigChain,
135+
minVersion: 'TLSv1.3',
136+
certificateCompression: ['zlib'],
137+
}, common.mustNotCall());
138+
139+
// The aborted handshake surfaces as a tlsClientError on the server too.
140+
server.on('tlsClientError', () => {});
141+
server.listen(0);
142+
await once(server, 'listening');
143+
144+
// Client starts handshake, server sends enormous cert, client rejects:
145+
const client = tls.connect({
146+
port: server.address().port,
147+
rejectUnauthorized: false,
148+
minVersion: 'TLSv1.3',
149+
certificateCompression: ['zlib'],
150+
});
151+
const [err] = await once(client, 'error');
152+
assert.strictEqual(err.code, 'ERR_SSL_EXCESSIVE_MESSAGE_SIZE');
153+
154+
server.close();
155+
})().then(common.mustCall());
156+
70157
// Test: TLS connection with certificate compression reduces handshake size.
71158
//
72159
// To see meaningful compression, we generate a certificate with many SANs to

0 commit comments

Comments
 (0)