Skip to content

Reuse TLS sessions across pooled connections #4521

Description

@sidorares

Summary

Every new TLS connection runs a full handshake: the server sends its certificate chain, the client verifies it against the CA list and derives fresh keys. A pool refilling after idle timeouts or scaling up, a pool cluster, or an application opening short-lived connections repeats that against the same server over and over. TLS session resumption lets a client that already completed a handshake present a ticket on later connections and skip most of the work. Node's own HTTPS agent does this by default (maxCachedSessions, 100 per host and port); the driver does not.

With the secure-context cache from the third perf round in place, a new plain TLS connection still costs about 1.9 ms of client CPU, most of it the handshake and chain verification, and the chain is several kilobytes on the wire per connection.

What it saves

  • TLS 1.2 (MySQL 5.7 and older builds): the abbreviated handshake needs one round trip instead of two, and no certificate exchange or verification. Over a 10 ms link that is 10 ms less per new connection.
  • TLS 1.3 (MySQL 8.0.16+, MariaDB): the round-trip count stays at one, but the certificate chain is neither re-sent nor re-verified. Forward secrecy is kept because the default resumption mode (psk_dhe_ke) still runs an ephemeral key exchange.

Verified locally before starting (Docker servers)

A throwaway probe (below) wrapped tls.connect so the second and third mysql2 connections presented the session captured from the first, then checked both sides:

Server Protocol Client isSessionReused() Server side
MySQL 8.3.0 TLS 1.3 true on connections 2 and 3 Ssl_session_cache_hits 2 → 4, Ssl_sessions_reused 1, ssl_session_cache_mode=ON, ssl_session_cache_timeout=300
MySQL 9.7.2 TLS 1.3 true same counters and settings
MariaDB 12.3.2 TLS 1.3 true counters not exposed (stay 0), client confirms reuse
MySQL 5.7.44 TLS 1.2 DHE-RSA-AES128-GCM-SHA256 true Ssl_session_cache_hits 1 → 2, Ssl_sessions_reused 1

Two details that matter for the implementation: TLS 1.3 servers send two tickets per connection, and they arrive right after the handshake, before the first query result, so a pool's second connection can already reuse the first one's session.

Proposed implementation

In lib/base/connection.js, next to the secure-context cache:

  1. Keep the latest ticket per ssl config object + host + port, taken from the socket's 'session' event (it can fire more than once; keep the newest).
  2. Pass it as the session option of tls.connect in startTLS. A rejected or expired ticket is not an error: the server silently performs a full handshake, and the next 'session' event replaces the stale ticket.
  3. Memory only, never persisted; drop the entry when a handshake fails.

Security requirements:

  • Node skips checkServerIdentity on a resumed session because the identity was verified when the ticket was issued, so the cache key must include everything that shaped the original verification: the ssl object (CA, cert, key, ciphers, versions, and its identity as the secure-context cache already relies on), host, port, rejectUnauthorized and verifyIdentity. A session established under a lax config must never be resumed by a strict one.
  • rejectUnauthorized stays on by default and is not touched by this change.

Tests (MYSQL_USE_TLS=1):

  • the second connection of a pool reports socket.isSessionReused() === true (and on MySQL, SHOW SESSION STATUS LIKE 'Ssl_sessions_reused' is 1);
  • a connection with different ssl material or a different rejectUnauthorized does not reuse;
  • a failed handshake drops the cached session;
  • the existing TLS suite still passes on MySQL 5.7 (TLS 1.2), 8.x, 9 and MariaDB.

Expected gain: roughly half to one millisecond of client CPU and a few kilobytes per new TLS connection, plus one round trip per connection on TLS 1.2 servers.

Probe script

Run from the repository root against a TLS-enabled server (MYSQL_PORT=3308 node probe.js); it monkey-patches tls.connect for the probe only, the driver is unchanged.

'use strict';
const tls = require('node:tls');
const mysql = require('./promise.js');
const PORT = Number(process.env.MYSQL_PORT || 3306);

const originalConnect = tls.connect;
let capturedSession = null;
const sockets = [];
tls.connect = function (options, callback) {
  if (capturedSession) {
    options = { ...options, session: capturedSession };
  }
  const socket = originalConnect.call(tls, options, callback);
  socket.on('session', (session) => {
    capturedSession = session;
  });
  sockets.push(socket);
  return socket;
};

async function connectAndReport(label) {
  const conn = await mysql.createConnection({
    host: '127.0.0.1',
    port: PORT,
    user: 'root',
    database: 'test',
    ssl: { rejectUnauthorized: false },
  });
  const socket = sockets[sockets.length - 1];
  const [status] = await conn.query(
    "SHOW SESSION STATUS WHERE Variable_name IN ('Ssl_version','Ssl_sessions_reused','Ssl_session_cache_hits')"
  );
  console.log(
    label,
    socket.getProtocol(),
    socket.getCipher().name,
    'isSessionReused =',
    socket.isSessionReused(),
    Object.fromEntries(status.map((r) => [r.Variable_name, r.Value]))
  );
  await new Promise((r) => setTimeout(r, 300));
  await conn.end();
}

(async () => {
  await connectAndReport('connection 1');
  await connectAndReport('connection 2 (presenting captured session)');
  await connectAndReport('connection 3 (presenting captured session)');
})().catch((e) => {
  console.error(e);
  process.exit(1);
});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions