node/test/parallel/test-tls-certificate-compression.js
Tim Perry e52ec44229
tls: add certificateCompression option
This changes enables compression within OpenSSL *without* enabling
record compression, so this only affects compression of certificates
delivered within the TLS handshake. This certificate compression remains
disabled by default for now, but becomes available via the new
certificateCompression option in TLS context APIs.

Enabling this shrinks handshakes significantly, and also reduces
fingerprintability of Node.js client handshakes, as these are enabled in
all modern browsers by default.

Signed-off-by: Tim Perry <pimterry@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/62217
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
2026-06-19 16:07:24 +02:00

261 lines
8.6 KiB
JavaScript

'use strict';
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('assert');
const tls = require('tls');
const net = require('net');
const { once } = require('events');
const fixtures = require('../common/fixtures');
const supportedAlgs = tls.getCertificateCompressionAlgorithms();
if (supportedAlgs.length === 0)
common.skip('certificate compression not supported by this OpenSSL build');
// Use small fixture certs for input validation tests.
const fixtureKey = fixtures.readKey('agent1-key.pem');
const fixtureCert = fixtures.readKey('agent1-cert.pem');
// Test: input validation
{
// Empty array is valid (means disabled, same as undefined)
tls.createSecureContext({
key: fixtureKey, cert: fixtureCert, certificateCompression: [],
});
// Non-array should throw
assert.throws(
() => tls.createSecureContext({ certificateCompression: true }),
{ code: 'ERR_INVALID_ARG_TYPE' }
);
assert.throws(
() => tls.createSecureContext({ certificateCompression: 'zlib' }),
{ code: 'ERR_INVALID_ARG_TYPE' }
);
// Invalid algorithm name should throw
assert.throws(
() => tls.createSecureContext({ certificateCompression: ['invalid'] }),
/must be 'zlib', 'brotli', or 'zstd'/
);
// Non-string entries should throw
assert.throws(
() => tls.createSecureContext({ certificateCompression: [1] }),
/must be 'zlib', 'brotli', or 'zstd'/
);
// Valid single algorithms should not throw
for (const algo of supportedAlgs) {
tls.createSecureContext({
key: fixtureKey, cert: fixtureCert, certificateCompression: [algo],
});
}
// Valid multiple algorithms should not throw
tls.createSecureContext({
key: fixtureKey, cert: fixtureCert,
certificateCompression: supportedAlgs,
});
// Default (no certificateCompression) still works
tls.createSecureContext({ key: fixtureKey, cert: fixtureCert });
// Protocol range that excludes TLSv1.3 should throw - RFC 8879 is 1.3-only.
assert.throws(
() => tls.createSecureContext({
key: fixtureKey, cert: fixtureCert,
maxVersion: 'TLSv1.2',
certificateCompression: [supportedAlgs[0]],
}),
/TLSv1\.3/,
);
}
// Test: certificate compression must not enable record-level compression.
//
// RFC 8879 certificate compression is unrelated to TLS record compression
// (the CRIME attack vector). The latter is disabled by Node at startup via
// sk_SSL_COMP_zero, and by modern OpenSSL defaults as well. To confirm, we
// Verify a ClientHello sent by Node only advertises null record compression.
(async () => {
const { promise: clientHelloReceived, resolve: onClientHello } =
Promise.withResolvers();
const tcpServer = net.createServer((socket) => {
socket.once('data', (chunk) => {
onClientHello(chunk);
socket.destroy();
});
socket.on('error', () => {});
});
tcpServer.listen(0);
await once(tcpServer, 'listening');
const probe = tls.connect({
port: tcpServer.address().port,
rejectUnauthorized: false,
certificateCompression: supportedAlgs,
});
probe.on('error', () => {});
const clientHello = await clientHelloReceived;
tcpServer.close();
probe.destroy();
// ClientHello layout (RFC 8446 4.1.2 + 5.1 record layer):
// record header (5) | handshake header (4) | legacy_version (2)
// | random (32) | session_id <1..32> | cipher_suites <2..>
// | compression_methods <1..> | extensions <2..>
assert.strictEqual(clientHello[0], 0x16); // Handshake record
assert.strictEqual(clientHello[5], 0x01); // ClientHello
let i = 5 + 4 + 2 + 32;
i += 1 + clientHello[i]; // Skip legacy_session_id
i += 2 + clientHello.readUInt16BE(i); // Skip cipher_suites
const methods = clientHello.subarray(i + 1, i + 1 + clientHello[i]);
// Must only advertise the null compression method.
assert.deepStrictEqual([...methods], [0x00]);
})().then(common.mustCall());
// Test: a CompressedCertificate whose uncompressed length exceeds OpenSSL's
// default max_cert_list (100 KB) is rejected before decompression, protecting
// against decompression bombs in malicious server's cert chains.
(async () => {
// Cert just repeats our existing fixture many times
const bigChain = Buffer.concat(
Array(200).fill(Buffer.from(fixtureCert))
);
const server = tls.createServer({
key: fixtureKey, cert: bigChain,
minVersion: 'TLSv1.3',
certificateCompression: [supportedAlgs[0]],
}, common.mustNotCall());
// The aborted handshake surfaces as a tlsClientError on the server too.
server.on('tlsClientError', () => {});
server.listen(0);
await once(server, 'listening');
// Client starts handshake, server sends enormous cert, client rejects:
const client = tls.connect({
port: server.address().port,
rejectUnauthorized: false,
minVersion: 'TLSv1.3',
certificateCompression: [supportedAlgs[0]],
});
const [err] = await once(client, 'error');
assert.strictEqual(err.code, 'ERR_SSL_EXCESSIVE_MESSAGE_SIZE');
server.close();
})().then(common.mustCall());
// Test: TLS connection with certificate compression reduces handshake size.
(async () => {
const key = fixtureKey;
// Include a massive certificate list. Doesn't matter that they're not a valid chain,
// we'll send them all and the client uses rejectUnauthorized: false.
const cert = Buffer.concat(Array(20).fill(Buffer.from(fixtureCert)));
// Helper: perform a TLS 1.3 handshake via a TCP proxy and return the total
// raw bytes transferred. The proxy counts bytes to measure the on-the-wire
// handshake size (which reflects certificate compression savings).
async function measureHandshakeBytes(serverOpts, clientOpts) {
const { promise: serverConnected, resolve: onServerConn } = Promise.withResolvers();
const server = tls.createServer(serverOpts, common.mustCall((socket) => {
onServerConn();
socket.destroy();
}));
server.listen(0);
await once(server, 'listening');
let proxyClientConn;
const proxy = net.createServer(common.mustCall((clientConn) => {
proxyClientConn = clientConn;
const serverConn = net.connect(server.address().port);
clientConn.pipe(serverConn);
serverConn.pipe(clientConn);
serverConn.on('error', () => {});
clientConn.on('error', () => {});
}));
proxy.listen(0);
await once(proxy, 'listening');
const client = tls.connect({
port: proxy.address().port,
rejectUnauthorized: false,
minVersion: 'TLSv1.3',
...clientOpts,
});
await serverConnected;
const totalBytes = proxyClientConn.bytesRead + proxyClientConn.bytesWritten;
client.destroy();
server.close();
proxy.close();
return totalBytes;
}
const baseline = await measureHandshakeBytes(
{ key, cert, minVersion: 'TLSv1.3' },
{},
);
// Test each compression algorithm produces a measurably smaller handshake.
// Certificate compression (RFC 8879) compresses the Certificate message
// during the TLS 1.3 handshake. With the large repeated cert list above, all
// supported algorithms achieve ratios well below 0.5.
for (const algo of supportedAlgs) {
const compressed = await measureHandshakeBytes(
{ key, cert, minVersion: 'TLSv1.3', certificateCompression: [algo] },
{ certificateCompression: [algo] },
);
const ratio = compressed / baseline;
assert.ok(
ratio < 0.5,
`Expected ${algo} compressed handshake (${compressed} bytes, ` +
`ratio=${ratio.toFixed(3)}) to be <50% of baseline ` +
`(${baseline} bytes)`
);
}
// Test: certificate compression works through SNI callback.
// The server uses a default context without compression, and an SNI context
// with compression enabled. The client connects with an SNI hostname that
// triggers the SNI callback, switching to the compressed context.
{
const sniCalled = common.mustCall((hostname, cb) => {
if (hostname === 'compressed.example.com') {
const ctx = tls.createSecureContext({
key, cert,
certificateCompression: [supportedAlgs[0]],
});
cb(null, ctx);
} else {
cb(null);
}
});
const sniBytes = await measureHandshakeBytes(
{
key: fixtureKey, cert: fixtureCert,
minVersion: 'TLSv1.3',
SNICallback: sniCalled,
},
{
servername: 'compressed.example.com',
certificateCompression: [supportedAlgs[0]],
},
);
const sniRatio = sniBytes / baseline;
assert.ok(
sniRatio < 0.5,
`Expected SNI compressed handshake (${sniBytes} bytes, ` +
`ratio=${sniRatio.toFixed(3)}) to be <50% of baseline ` +
`(${baseline} bytes)`
);
}
})().then(common.mustCall());