current commit
260619.7 · tls: add certificateCompression option
'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());