diff --git a/lib/base/connection.js b/lib/base/connection.js index ca156f0b32..9f7f80d38d 100644 --- a/lib/base/connection.js +++ b/lib/base/connection.js @@ -27,6 +27,7 @@ const PacketParser = require('../packet_parser.js'); const Packets = require('../packets/index.js'); const Commands = require('../commands/index.js'); const ConnectionConfig = require('../connection_config.js'); +const TlsSessionSlot = require('../tls_session_cache.js'); const CharsetToEncoding = require('../constants/charset_encodings.js'); const { traceCallback, @@ -396,7 +397,16 @@ class BaseConnection extends EventEmitter { ? undefined : this.config.host; + const sessionSlot = new TlsSessionSlot( + this.config.ssl, + this.config.host, + this.config.port, + rejectUnauthorized, + verifyIdentity + ); let secureEstablished = false; + let peerAccepted = false; + let issuedSession = null; this.stream.removeAllListeners('data'); const secureSocket = Tls.connect( { @@ -408,13 +418,14 @@ class BaseConnection extends EventEmitter { return undefined; }, secureContext, + session: sessionSlot.get(), isServer: false, socket: this.stream, servername, }, () => { secureEstablished = true; - if (rejectUnauthorized) { + if (rejectUnauthorized && !secureSocket.isSessionReused()) { if (typeof servername === 'string' && verifyIdentity) { const cert = secureSocket.getPeerCertificate(true); const serverIdentityCheckError = Tls.checkServerIdentity( @@ -422,19 +433,32 @@ class BaseConnection extends EventEmitter { cert ); if (serverIdentityCheckError) { + sessionSlot.delete(); onSecure(serverIdentityCheckError); return; } } } + peerAccepted = true; + if (issuedSession !== null) { + sessionSlot.set(issuedSession); + } onSecure(); } ); + secureSocket.on('session', (session) => { + if (peerAccepted) { + sessionSlot.set(session); + } else { + issuedSession = session; + } + }); // error handler for secure socket secureSocket.on('error', (err) => { if (secureEstablished) { this._handleNetworkError(err); } else { + sessionSlot.delete(); onSecure(err); } }); diff --git a/lib/tls_session_cache.js b/lib/tls_session_cache.js new file mode 100644 index 0000000000..6e9b4fdefd --- /dev/null +++ b/lib/tls_session_cache.js @@ -0,0 +1,73 @@ +'use strict'; + +const { createLRU } = require('lru.min'); + +const MAX_PEERS_PER_SSL_CONFIG = 100; + +const caches = new WeakMap(); + +function snapshotMaterial(ssl) { + return { + ca: ssl.ca, + cert: ssl.cert, + ciphers: ssl.ciphers, + key: ssl.key, + passphrase: ssl.passphrase, + minVersion: ssl.minVersion, + maxVersion: ssl.maxVersion, + }; +} + +function sameMaterial(a, b) { + return ( + a.ca === b.ca && + a.cert === b.cert && + a.ciphers === b.ciphers && + a.key === b.key && + a.passphrase === b.passphrase && + a.minVersion === b.minVersion && + a.maxVersion === b.maxVersion + ); +} + +class TlsSessionSlot { + constructor(ssl, host, port, rejectUnauthorized, verifyIdentity) { + this.ssl = ssl; + this.material = snapshotMaterial(ssl); + this.key = `${host}:${port}:${Boolean(rejectUnauthorized)}:${Boolean(verifyIdentity)}`; + } + + _sessions() { + const cache = caches.get(this.ssl); + if (cache !== undefined && sameMaterial(cache.material, this.material)) { + return cache.sessions; + } + return undefined; + } + + get() { + const sessions = this._sessions(); + return sessions === undefined ? undefined : sessions.get(this.key); + } + + set(session) { + let sessions = this._sessions(); + if (sessions === undefined) { + if (!sameMaterial(this.material, snapshotMaterial(this.ssl))) { + return; + } + sessions = createLRU({ max: MAX_PEERS_PER_SSL_CONFIG }); + caches.set(this.ssl, { material: this.material, sessions }); + } + sessions.set(this.key, session); + } + + delete() { + const sessions = this._sessions(); + if (sessions !== undefined) { + sessions.delete(this.key); + } + } +} + +module.exports = TlsSessionSlot; diff --git a/test/fixtures/ssl/chain/ca-key.pem b/test/fixtures/ssl/chain/ca-key.pem new file mode 100644 index 0000000000..021c5b569f --- /dev/null +++ b/test/fixtures/ssl/chain/ca-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDDzL+33x8dUvvG +dUki+t8rSFEKesDrG5sh9aJF2ofB0DLZUmtFNOaBm/axPii26By9T2tI4Scj1G2+ +7pBuRB3K9i4l8X7HLdlPSkRTpoRnElYnucjECI/wwWN6A4ZIuyqaaaaoMDZF3uqh +x7Ea7XpLl82rlEr7iZuuBAGfIVulk6chOZEpvr8cUqrxrkHjhRoxePGiwx48sBBk +o+GhSDfiCg2hsP9ahYafFukRHJiA02FXVITnVfOYs2r1+XyumXuHHDD6BytfL7EP +SDMTzRoPP4yu9BWGmeH2Wp2zHCF0UY8BX+FuF/Paa1oPX+Hbu2C2ZnYOvDcKWqkB +1l1fq01/AgMBAAECggEAU+oy+oNSKvevN0l57Fd3gO3P6b1dZAXjUUUcZHK5fxuT +R2eW2MTIy6CDtEfPHEDTXGEmukfgxe7bkBpfZRqlaCBk+9cJviSMv3o1WWKC2UfX +C9SIUlfXFNhVIWxUR2SL6DzCc8+h2wUkKuzSHxfLM42eVFxMXIyH36cQ6NnblNS4 +Je+9xeU3QB2UKJUh0XttGVXY5qxL2AJJtHqFZdT0irYz/GcuZvNhcRdIzUYkqC8h +RFjdkYsk+gAqoJCyQN9zqej+BWmu3ZJEFBOlIWPh94UE3H/ZKl8ZEbF+BxJoM1/T +nZ6ONlclS0Y/AakmWSmUnYoDsZICalL+RMX/2Tr0CQKBgQDygBDSx/lFP6HrRzIB +EGzt0Dt6pT1T8VT3nBVJ7Xj7pkEA69/t0JdLXWNET7rxCWYG+MakhRRWXawgo3gc +ofuupPCh8exWwGY1sLclo4RJyGEIouqx/DLROQwmJMmcH4fu5Gu1IczjZa3K3ypL +unCqIWAom9u0PUEocGVv4D4fqQKBgQDOsySQFY6dgoqs6o1dyf3SrafPJiAXhIZJ +sqYG2oBZ/Y4CbaEZ8OgiixRaRa6B2AzzYbYMgwsXgM6MMJQnB3SvX1T2Hbjk4MPJ +4vlBWDtB20PYkH+foTRRLOqAyvOmwETD6jQSJlseNwNTynD8GCHu1upyZvZwtMKx +Bt5H59xc5wKBgFtatXvCi/xzcVtCMetGtdKNPKsCbu0doEW7jiWQiaA7zIc9VNmx +WB2zK79DTgiZ4s7Dj4nQ/I1gsoSFMK2QtFDhoClSCxfPWXHmhchvJlnpW+y1+zZb +7QT2ucXw2+at1ja/5HeTKS2NU4B2u+dVcDYXZrNFq+nlnZNPbFdOIfkpAoGBAIMg +BBbfhgyH7r9JQIr6A+ptvaHc1sj0ilggW9DHNltgTYFe2K9jM3AOeMfXop++VmDj +YKDJVPUBAobOnn2v7ib+vCAxtMSFjc76DROWDGl31q+A42V5132Tl+RCW5UyLa13 +WrcU3ALr9VFngIt8J7KjZjFS/g17rnKtH7/P5+yLAoGBAJNVY+mhv8sL+da9sSz0 +D/Ck5Gt7M9mzrOCFILgPbehdV9pwlMan6pMffTuESlUSEBWFoeWdSQri9rI8hyRz +oME0Xs4BsPHaA7ytRR8Dy0q+hkZJjf8h8yWEbOZzT3GJsLKMZpyEyracHGPGpkLG +ckZ0LXOkKvCMiBZL7NIxwbrZ +-----END PRIVATE KEY----- diff --git a/test/fixtures/ssl/chain/ca.pem b/test/fixtures/ssl/chain/ca.pem new file mode 100644 index 0000000000..c708b923a3 --- /dev/null +++ b/test/fixtures/ssl/chain/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUITBXG1QoNXTMMVA0So/ANwMKEJ4wDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOTXlTUUwyIHRlc3QgQ0EwIBcNMjYwOTA1MDgzNzA3WhgP +MjEyNjA4MTIwODM3MDdaMBkxFzAVBgNVBAMMDk15U1FMMiB0ZXN0IENBMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw8y/t98fHVL7xnVJIvrfK0hRCnrA +6xubIfWiRdqHwdAy2VJrRTTmgZv2sT4otugcvU9rSOEnI9Rtvu6QbkQdyvYuJfF+ +xy3ZT0pEU6aEZxJWJ7nIxAiP8MFjegOGSLsqmmmmqDA2Rd7qocexGu16S5fNq5RK ++4mbrgQBnyFbpZOnITmRKb6/HFKq8a5B44UaMXjxosMePLAQZKPhoUg34goNobD/ +WoWGnxbpERyYgNNhV1SE51XzmLNq9fl8rpl7hxww+gcrXy+xD0gzE80aDz+MrvQV +hpnh9lqdsxwhdFGPAV/hbhfz2mtaD1/h27tgtmZ2Drw3ClqpAdZdX6tNfwIDAQAB +o1MwUTAdBgNVHQ4EFgQUut4Z0pY00oj8SCul9yb8m05K7ZwwHwYDVR0jBBgwFoAU +ut4Z0pY00oj8SCul9yb8m05K7ZwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B +AQsFAAOCAQEAbobP6GMQjg8Mfn0IdqEKNv0etOGoJyLSb7kcCD6sec4nrKaofjnz +z2l56vxShs1bzb99eyv6pmYLJEDNeflVIc+Q8mOpa/wio80U1/RPpzQxY8b0FtrV +6HRjusIKqwm3pSfr5xxiJhmyO2H++fw73nOc9GyA9HbydfiTNmsbDm2fm9gqqJCJ +fLtboXlj+brg/tc84OOUO0o7DTKRx2uswdNZ1l8cml3+32RdnwqCZNsioKk45478 +k/ZFI48LH3DDqT9bNFj72hRFV78hHfTWiyNYOIe/z9cNgfINfHjtlSHMtTTKK6Nh +jA6C0GVfwxdotuJApuJIVgVLIYsm1qhiAQ== +-----END CERTIFICATE----- diff --git a/test/fixtures/ssl/chain/mkcerts.sh b/test/fixtures/ssl/chain/mkcerts.sh new file mode 100644 index 0000000000..2533deece6 --- /dev/null +++ b/test/fixtures/ssl/chain/mkcerts.sh @@ -0,0 +1,16 @@ +# A CA and a server certificate that verify as a chain, for unit tests that +# run their own TLS server. The server certificate is valid for the DNS name +# `resumption.test` only. + +openssl req -x509 -newkey rsa:2048 -nodes -days 36500 \ + -subj "/CN=MySQL2 test CA" -keyout ca-key.pem -out ca.pem + +openssl req -newkey rsa:2048 -nodes -subj "/CN=resumption.test" \ + -keyout server-key.pem -out server-req.pem + +openssl x509 -req -in server-req.pem -days 36500 \ + -CA ca.pem -CAkey ca-key.pem -set_serial 01 \ + -extfile <(printf "subjectAltName=DNS:resumption.test") \ + -out server-cert.pem + +rm server-req.pem diff --git a/test/fixtures/ssl/chain/server-cert.pem b/test/fixtures/ssl/chain/server-cert.pem new file mode 100644 index 0000000000..9bec70ec62 --- /dev/null +++ b/test/fixtures/ssl/chain/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIBATANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5NeVNR +TDIgdGVzdCBDQTAgFw0yNjA5MDUwODM3MDdaGA8yMTI2MDgxMjA4MzcwN1owGjEY +MBYGA1UEAwwPcmVzdW1wdGlvbi50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA1Duxok5UeTVaL237ciWR5Dor1aPG7ry4xIcL5MwXhp372P9R38KO +PyPDrd/Q+mjxJNCdokd3eaY9983BDACSj4Xvfq1U8+drWkZIMaPgAqSpHbbTGfor +TEk8UFZh2hcVFsmHA8bowDLMuM0b2VX8gOE1YIG2PPNhJqYeUjcFXPjj/HTxDTlp +GK/fT3VKHNqiERBjV7fJfWaHBhb0JKjzpVZ42MbfpT/P8x37Pe6m3W7/dYybJMoU +Sgb7ZHhGOkAu6WpVOMc5qQkHbqvlwVcVhQ8aRr7V33dl4DS80SU72pCeiWtxPQmW +sKauqxVOBTwTE81pZvBp9AgT8H8Cwz1BpwIDAQABo14wXDAaBgNVHREEEzARgg9y +ZXN1bXB0aW9uLnRlc3QwHQYDVR0OBBYEFCzrsvWzuKC6Fm7dNKffaI4dtJciMB8G +A1UdIwQYMBaAFLreGdKWNNKI/Egrpfcm/JtOSu2cMA0GCSqGSIb3DQEBCwUAA4IB +AQBZeZe0lYrVbvEo8ODm4K2Spjo1uHDUvRYfLU2VFzOTwWYTLKnVkI7Nf2XOa0hZ +B8BidNNFrKIQh/zOi3hKPcaRmwdY/AvIIpD6HsEwapLPM+JY/msUCkdNyPN8w3ye +ZWBNgauouNcUDgITGhLn/YU2PYwPepBIb9DXOxQA4ugmDc+HNDkfjm6BVQWbZFzV +eP3O7I/aADqT36vCpWmxvg2yEuqwxsldEdoGRq7T4SFuhrjGW/XNIT9qeFeLpLmS +OrXYIiI3lH7KZKfH4u6rOWByl/sQFF1mwkbV7+a52rHma7M+3XKwTfGj72WMg8uc +hg372sydZvHNZL3XsV0kEUme +-----END CERTIFICATE----- diff --git a/test/fixtures/ssl/chain/server-key.pem b/test/fixtures/ssl/chain/server-key.pem new file mode 100644 index 0000000000..249e70216b --- /dev/null +++ b/test/fixtures/ssl/chain/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDUO7GiTlR5NVov +bftyJZHkOivVo8buvLjEhwvkzBeGnfvY/1Hfwo4/I8Ot39D6aPEk0J2iR3d5pj33 +zcEMAJKPhe9+rVTz52taRkgxo+ACpKkdttMZ+itMSTxQVmHaFxUWyYcDxujAMsy4 +zRvZVfyA4TVggbY882Emph5SNwVc+OP8dPENOWkYr99PdUoc2qIREGNXt8l9ZocG +FvQkqPOlVnjYxt+lP8/zHfs97qbdbv91jJskyhRKBvtkeEY6QC7palU4xzmpCQdu +q+XBVxWFDxpGvtXfd2XgNLzRJTvakJ6Ja3E9CZawpq6rFU4FPBMTzWlm8Gn0CBPw +fwLDPUGnAgMBAAECggEAMmHxa3x45UVbIYNUe+abWbjNiywUH7zBiDYfBrPEqfp7 +PSpvf1Uqe6a7310QuUGMbZbwtQOOQ1zJvTglKfpA0L2U+1eeoUBA5e9lWBN1LGt2 +kk1ClBoliTihjtjS5fkw0nYJDUZ9HpNwsjZO9CtLhDTyfPSOFK4aVBh/fjj9lvI4 +H7o+E+vPmBWY+iECODhz6LiQAR/0me6FsWa4lUu63e5YOuqg2dr4ldoSnD01s5fy +0e++vnrf3YVHvTCV9Aa0uosxPHKtCb2a5aD8NU2gUvXhRaVPvgYMR2wkiYB+t93+ +5X1y3AomtnFmmYPfIdka9MavUf5NXWhde1VIFIoI8QKBgQD3W3VO4vGqil0wDDri +bDSk/oy+nXtLlT6yNHJt41qpT2gYWd2xaivNYrhs6ikbwQ394lesFOqvgaR5ryCw +SW7aXjeyY+G5NDOaVSYMSxaSPCb0zwXlRREPRar/FqG6937+0Q/HoQCL/ggFxVdJ +gpoPiI+9x0wQvjm5/UxynsLP5QKBgQDbpg9/tOUwhZ2JD0PPsg1rdozYmO7UsKb/ +kbGGFxx2mqAdzRa7CnltvElgzw1aIP3xYrjrzOA+Xw6I6GD6mX+8cz3DUZ+cfPOg +UaKEF0duDHt6BNwppUNiQx/fiDYp1h+yShLtef2cJXATggUXNQ/qw+qSxByLChME +nyKUMXm6mwKBgAOjcMJMTEm5500BoQwbk1qp8+AJ6Ppfo6BzbQt4wKik5l8VU1F8 +wlAvF49ikmA6Ir6NScTaOemC+qxfixeMvPixUhhpp7RVtDyc7AZaRGa6Q8huEyQg +M/QJjk5aQt7BLTkKUjUZhSyJ/Aw5ruMB7Mo1tAeSmufUJH074Jf/BdaRAoGBAJ78 +cyl/+YkGngHQP8g9XANV+xroj6758tb4USP65Ipg39bSVUqriTtJ118wX7zGNqmb +SLi9Qe9DhNTKRy/9HX19eM5A7aQquXlovZaY9wYMGPA4RlgKnFyqs45sHLBpoeW+ +QjcMXurAGoC0UxT6PnwytT7onrRxZVSqDzBvI2pTAoGAZuaEWM5ZgWhz7wDQPCxq +SMebg++vGLDSehXbbge1j6VNoQc4/jnBHpJ/NeVK3QhWjxnbLFUc5WeY4KVpEEMx +NE/HyoBirn6LjbxT1qrV2aergrMbdHJy8G8jVH8aX6JjE6gEoeA7sMhwxlMWxLbe +8waNGUhP6aoy3J7lxVS6VVk= +-----END PRIVATE KEY----- diff --git a/test/integration/connection/test-tls-session-resumption.test.mts b/test/integration/connection/test-tls-session-resumption.test.mts new file mode 100644 index 0000000000..ffab45d610 --- /dev/null +++ b/test/integration/connection/test-tls-session-resumption.test.mts @@ -0,0 +1,44 @@ +import type { RowDataPacket } from '../../../index.js'; +import process from 'node:process'; +import { describe, it, skip, strict } from 'poku'; +import driver from '../../../index.js'; +import { config, getConfig, getMysqlVersion } from '../../common.test.mjs'; + +if (process.env.MYSQL_USE_TLS !== '1' || config.ssl === undefined) { + skip('TLS session resumption needs MYSQL_USE_TLS=1'); +} + +type StatusRow = RowDataPacket & { Variable_name: string; Value: string }; + +const ssl = config.ssl; + +await describe('TLS session resumption across pooled connections', async () => { + const pool = driver.createPool({ ...getConfig(), ssl }).promise(); + + const first = await pool.getConnection(); + await first.query('SELECT 1'); + const second = await pool.getConnection(); + const { isMariaDB } = await getMysqlVersion(second); + const [status] = await second.query( + "SHOW SESSION STATUS LIKE 'Ssl_sessions_reused'" + ); + + it('should resume the session of the first pooled connection', () => { + // @ts-expect-error: internal access + strict.equal(first.connection.stream.isSessionReused(), false); + // @ts-expect-error: internal access + strict.equal(second.connection.stream.isSessionReused(), true); + }); + + it('should be confirmed by the server', () => { + if (isMariaDB) { + return; + } + + strict.equal(status[0].Value, '1'); + }); + + first.release(); + second.release(); + await pool.end(); +}); diff --git a/test/unit/connection/test-tls-session-cache.test.mts b/test/unit/connection/test-tls-session-cache.test.mts new file mode 100644 index 0000000000..464736ef0e --- /dev/null +++ b/test/unit/connection/test-tls-session-cache.test.mts @@ -0,0 +1,169 @@ +import { describe, it, strict } from 'poku'; +import TlsSessionSlot from '../../../lib/tls_session_cache.js'; + +type Ssl = { + ca?: string; + cert?: string; + key?: string; + minVersion?: string; +}; + +type Policy = { + host?: string; + port?: number; + rejectUnauthorized?: boolean; + verifyIdentity?: boolean; +}; + +const session = Buffer.from('ticket-1'); +const newerSession = Buffer.from('ticket-2'); + +const slot = (ssl: Ssl, policy: Policy = {}) => + new TlsSessionSlot( + ssl, + policy.host ?? 'db.example', + policy.port ?? 3306, + policy.rejectUnauthorized ?? true, + policy.verifyIdentity ?? true + ); + +describe('TLS session cache', () => { + it('should return nothing before a session is stored', () => { + strict.equal(slot({ ca: 'CA' }).get(), undefined); + }); + + it('should return the stored session for the same ssl object, peer and policy', () => { + const ssl: Ssl = { ca: 'CA' }; + + slot(ssl).set(session); + + strict.equal(slot(ssl).get(), session); + }); + + it('should keep the newest session', () => { + const ssl: Ssl = { ca: 'CA' }; + + slot(ssl).set(session); + slot(ssl).set(newerSession); + + strict.equal(slot(ssl).get(), newerSession); + }); + + it('should separate peers by host and port', () => { + const ssl: Ssl = { ca: 'CA' }; + + slot(ssl).set(session); + + strict.equal(slot(ssl, { host: 'other.example' }).get(), undefined); + strict.equal(slot(ssl, { port: 3307 }).get(), undefined); + }); + + it('should never hand a session established under a lax policy to a strict one', () => { + const ssl: Ssl = { ca: 'CA' }; + + slot(ssl, { rejectUnauthorized: false, verifyIdentity: false }).set( + session + ); + + strict.equal( + slot(ssl, { rejectUnauthorized: true, verifyIdentity: false }).get(), + undefined + ); + strict.equal( + slot(ssl, { rejectUnauthorized: true, verifyIdentity: true }).get(), + undefined + ); + + slot(ssl, { rejectUnauthorized: true, verifyIdentity: false }).set( + newerSession + ); + + strict.equal( + slot(ssl, { rejectUnauthorized: true, verifyIdentity: true }).get(), + undefined + ); + strict.equal( + slot(ssl, { rejectUnauthorized: true, verifyIdentity: false }).get(), + newerSession + ); + }); + + it('should treat a missing verifyIdentity like false', () => { + const ssl: Ssl = { ca: 'CA' }; + + new TlsSessionSlot(ssl, 'db.example', 3306, true, undefined).set(session); + + strict.equal(slot(ssl, { verifyIdentity: false }).get(), session); + }); + + it('should key on the ssl object identity, not its content', () => { + slot({ ca: 'CA' }).set(session); + + strict.equal(slot({ ca: 'CA' }).get(), undefined); + }); + + it('should forget every session when the certificate material changes', () => { + const ssl: Ssl = { ca: 'CA', minVersion: 'TLSv1.2' }; + + slot(ssl).set(session); + slot(ssl, { host: 'other.example' }).set(session); + ssl.ca = 'OTHER CA'; + + strict.equal(slot(ssl).get(), undefined); + strict.equal(slot(ssl, { host: 'other.example' }).get(), undefined); + + slot(ssl).set(newerSession); + ssl.ca = 'CA'; + + strict.equal(slot(ssl).get(), undefined); + + slot(ssl).set(session); + ssl.minVersion = 'TLSv1.3'; + + strict.equal(slot(ssl).get(), undefined); + }); + + it('should bind a session to the material and policy of its own handshake', () => { + const ssl: Ssl = { ca: 'CA' }; + const handshake = slot(ssl, { rejectUnauthorized: false }); + + ssl.ca = 'OTHER CA'; + handshake.set(session); + + strict.equal(slot(ssl, { rejectUnauthorized: false }).get(), undefined); + strict.equal(slot(ssl, { rejectUnauthorized: true }).get(), undefined); + + ssl.ca = 'CA'; + handshake.set(session); + + strict.equal(slot(ssl, { rejectUnauthorized: true }).get(), undefined); + strict.equal(slot(ssl, { rejectUnauthorized: false }).get(), session); + }); + + it('should drop a single peer', () => { + const ssl: Ssl = { ca: 'CA' }; + + slot(ssl).set(session); + slot(ssl, { host: 'other.example' }).set(session); + slot(ssl).delete(); + + strict.equal(slot(ssl).get(), undefined); + strict.equal(slot(ssl, { host: 'other.example' }).get(), session); + }); + + it('should tolerate dropping a peer that was never stored', () => { + slot({ ca: 'CA' }).delete(); + }); + + it('should keep at most 100 peers per ssl object', () => { + const ssl: Ssl = { ca: 'CA' }; + + for (let port = 1; port <= 101; port++) { + slot(ssl, { port }).set(session); + } + + strict.equal(slot(ssl, { port: 1 }).get(), undefined); + strict.equal(slot(ssl, { port: 2 }).get(), session); + strict.equal(slot(ssl, { port: 101 }).get(), session); + }); +}); diff --git a/test/unit/connection/test-tls-session-resumption.test.mts b/test/unit/connection/test-tls-session-resumption.test.mts new file mode 100644 index 0000000000..689a5ebfe9 --- /dev/null +++ b/test/unit/connection/test-tls-session-resumption.test.mts @@ -0,0 +1,163 @@ +import type { TLSSocket } from 'node:tls'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import tls from 'node:tls'; +import { fileURLToPath } from 'node:url'; +import { describe, it, skip, strict } from 'poku'; +import BaseConnection from '../../../lib/base/connection.js'; +import ConnectionConfig from '../../../lib/connection_config.js'; +import TlsSessionSlot from '../../../lib/tls_session_cache.js'; + +if (typeof Deno !== 'undefined') { + skip('Deno: node:tls does not resume sessions'); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const certs = path.join(__dirname, '../../fixtures/ssl/chain'); +const ca = fs.readFileSync(path.join(certs, 'ca.pem'), 'utf8'); + +type Ssl = { + ca: string; + maxVersion?: string; + minVersion?: string; + rejectUnauthorized?: boolean; + verifyIdentity?: boolean; +}; + +const server = tls.createServer({ + key: fs.readFileSync(path.join(certs, 'server-key.pem')), + cert: fs.readFileSync(path.join(certs, 'server-cert.pem')), +}); + +let dropNextHandshake = false; +server.on('connection', (socket: net.Socket) => { + if (dropNextHandshake) { + dropNextHandshake = false; + socket.destroy(); + } +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +const port = typeof address === 'object' && address !== null ? address.port : 0; + +const sockets: TLSSocket[] = []; + +type Upgrade = { error: NodeJS.ErrnoException | undefined; socket: TLSSocket }; + +const upgrade = async ( + ssl: Ssl, + host = 'resumption.test' +): Promise => { + const stream = net.connect(port, '127.0.0.1'); + await once(stream, 'connect'); + + const config = new ConnectionConfig({ + host, + port, + ssl, + stream, + isServer: true, + connectTimeout: 0, + }); + const connection = new BaseConnection({ config }); + connection.on('error', () => {}); + + const secure = new Promise((resolve) => { + connection.startTLS(resolve); + }); + const socket: TLSSocket = connection.stream; + sockets.push(socket); + const issued = once(socket, 'session').catch(() => {}); + const error = await secure; + + if (error === undefined && !socket.isSessionReused()) { + await issued; + } + + return { error, socket }; +}; + +await describe('TLS session resumption', async () => { + const ssl: Ssl = { ca, rejectUnauthorized: true, verifyIdentity: true }; + + await it('should resume the session issued by a previous handshake', async () => { + const first = await upgrade(ssl); + const second = await upgrade(ssl); + + strict.equal(first.error, undefined); + strict.equal(first.socket.isSessionReused(), false); + strict.equal(second.error, undefined); + strict.equal(second.socket.isSessionReused(), true); + }); + + await it('should keep failing identity checks instead of resuming past them', async () => { + const first = await upgrade(ssl, 'localhost'); + const second = await upgrade(ssl, 'localhost'); + + strict.equal(first.error?.code, 'ERR_TLS_CERT_ALTNAME_INVALID'); + strict.equal(second.error?.code, 'ERR_TLS_CERT_ALTNAME_INVALID'); + strict.equal( + new TlsSessionSlot(ssl, 'localhost', port, true, true).get(), + undefined + ); + }); + + await it('should not resume with a session established under a laxer config', async () => { + ssl.rejectUnauthorized = false; + ssl.verifyIdentity = false; + const lax = await upgrade(ssl); + ssl.rejectUnauthorized = true; + const strictAgain = await upgrade(ssl); + ssl.verifyIdentity = true; + const strictest = await upgrade(ssl); + + strict.equal(lax.error, undefined); + strict.equal(lax.socket.isSessionReused(), false); + strict.equal(strictAgain.error, undefined); + strict.equal(strictAgain.socket.isSessionReused(), false); + strict.equal(strictest.error, undefined); + strict.equal(strictest.socket.isSessionReused(), true); + }); + + await it('should drop the cached session when a handshake fails', async () => { + dropNextHandshake = true; + const failed = await upgrade(ssl); + const full = await upgrade(ssl); + const resumed = await upgrade(ssl); + + strict.ok(failed.error instanceof Error); + strict.equal(full.error, undefined); + strict.equal(full.socket.isSessionReused(), false); + strict.equal(resumed.socket.isSessionReused(), true); + }); + + await it('should not resume after the certificate material changes', async () => { + ssl.minVersion = 'TLSv1.2'; + const full = await upgrade(ssl); + const resumed = await upgrade(ssl); + + strict.equal(full.error, undefined); + strict.equal(full.socket.isSessionReused(), false); + strict.equal(resumed.socket.isSessionReused(), true); + }); + + await it('should resume a TLS 1.2 session issued during the handshake', async () => { + ssl.maxVersion = 'TLSv1.2'; + const full = await upgrade(ssl); + const resumed = await upgrade(ssl); + + strict.equal(full.error, undefined); + strict.equal(full.socket.getProtocol(), 'TLSv1.2'); + strict.equal(full.socket.isSessionReused(), false); + strict.equal(resumed.socket.getProtocol(), 'TLSv1.2'); + strict.equal(resumed.socket.isSessionReused(), true); + }); + + for (const socket of sockets) { + socket.destroy(); + } + server.close(); +}); diff --git a/website/docs/documentation/ssl.mdx b/website/docs/documentation/ssl.mdx index 59f6b07fa9..de054a96ea 100644 --- a/website/docs/documentation/ssl.mdx +++ b/website/docs/documentation/ssl.mdx @@ -73,6 +73,21 @@ Following profiles are included in the package: - `Amazon RDS` - in this case https://s3.amazonaws.com/rds-downloads/mysql-ssl-ca-cert.pem CA cert is used +## TLS Session Resumption + +Every new TLS connection normally runs a full handshake, in which the server sends its certificate chain and the client verifies it. **MySQL2** keeps the session ticket issued at the end of a successful handshake in memory and presents it on later connections to the same server, which lets the server skip the certificate exchange and, on TLS 1.2, one round trip. This happens automatically and is never persisted to disk. + +A session is only reused when the new connection shares the **same `ssl` object**, host and port as the one that established it, together with the same `rejectUnauthorized` and `verifyIdentity` settings, so a session established under a lax configuration is never resumed by a strict one. Connections created by a pool or a pool cluster already share their `ssl` object. When you create connections one by one, reuse a single `ssl` object to get the same benefit: + +```ts +const ssl = { ca: fs.readFileSync(__dirname + '/mysql-ca.crt') }; + +const first = await mysql.createConnection({ host: 'localhost', ssl }); +const second = await mysql.createConnection({ host: 'localhost', ssl }); +``` + +A server that rejects or no longer knows the ticket silently falls back to a full handshake, and a failed handshake drops the cached session. + ## Ignoring Unauthorized SSL Errors You can also connect to a MySQL server without providing an appropriate CA to trust. **This is highly discouraged** as being insecure.