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>
261 lines
8.6 KiB
JavaScript
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());
|