From: Remi Gacogne Date: Fri, 27 Sep 2019 14:10:36 +0000 (+0200) Subject: dnsdist: Document DoH TLS Session Ticket keys management. Add tests. X-Git-Tag: dnsdist-1.4.0-rc3~1^2~2 X-Git-Url: https://granicus.if.org/sourcecode?a=commitdiff_plain;h=4ecc56037a0cac9c114ca5aa948e8fe1f0fa09ec;p=pdns dnsdist: Document DoH TLS Session Ticket keys management. Add tests. --- diff --git a/pdns/dnsdistdist/docs/reference/config.rst b/pdns/dnsdistdist/docs/reference/config.rst index 68ce3fcae..247d4407d 100644 --- a/pdns/dnsdistdist/docs/reference/config.rst +++ b/pdns/dnsdistdist/docs/reference/config.rst @@ -130,6 +130,11 @@ Listen Sockets * ``customResponseHeaders={}``: table - Set custom HTTP header(s) returned by dnsdist. * ``ocspResponses``: list - List of files containing OCSP responses, in the same order than the certificates and keys, that will be used to provide OCSP stapling responses. * ``minTLSVersion``: str - Minimum version of the TLS protocol to support. Possible values are 'tls1.0', 'tls1.1', 'tls1.2' and 'tls1.3'. Default is to require at least TLS 1.0. + * ``numberOfTicketsKeys``: int - The maximum number of tickets keys to keep in memory at the same time, if the provider supports it (GnuTLS doesn't, OpenSSL does). Only one key is marked as active and used to encrypt new tickets while the remaining ones can still be used to decrypt existing tickets after a rotation. Default to 5. + * ``ticketKeyFile``: str - The path to a file from where TLS tickets keys should be loaded, to support RFC 5077. These keys should be rotated often and never written to persistent storage to preserve forward secrecy. The default is to generate a random key. The OpenSSL provider supports several tickets keys to be able to decrypt existing sessions after the rotation, while the GnuTLS provider only supports one key. + * ``ticketsKeysRotationDelay``: int - Set the delay before the TLS tickets key is rotated, in seconds. Default is 43200 (12h). + * ``sessionTickets``: bool - Whether session resumption via session tickets is enabled. Default is true, meaning tickets are enabled. + * ``numberOfStoredSessions``: int - The maximum number of sessions kept in memory at the same time. At this time this is only supported by the OpenSSL provider, as stored sessions are not supported with the GnuTLS one. Default is 20480. Setting this value to 0 disables stored session entirely. .. function:: addTLSLocal(address, certFile(s), keyFile(s) [, options]) diff --git a/pdns/dnsdistdist/doh.cc b/pdns/dnsdistdist/doh.cc index f5add032b..41449b689 100644 --- a/pdns/dnsdistdist/doh.cc +++ b/pdns/dnsdistdist/doh.cc @@ -923,7 +923,7 @@ static std::unique_ptr getTLSContext(DOHFrontend& df SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE; - if (!df.d_enableTickets) { + if (!df.d_enableTickets || df.d_numberOfTicketsKeys == 0) { sslOptions |= SSL_OP_NO_TICKET; } else { diff --git a/pdns/dnsdistdist/tcpiohandler.cc b/pdns/dnsdistdist/tcpiohandler.cc index bb2edad53..29ca93536 100644 --- a/pdns/dnsdistdist/tcpiohandler.cc +++ b/pdns/dnsdistdist/tcpiohandler.cc @@ -226,10 +226,6 @@ public: SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE; - if (!fe.d_enableTickets) { - sslOptions |= SSL_OP_NO_TICKET; - } - registerOpenSSLUser(); d_tlsCtx = std::unique_ptr(SSL_CTX_new(SSLv23_server_method()), SSL_CTX_free); @@ -238,9 +234,14 @@ public: throw std::runtime_error("Error creating TLS context on " + fe.d_addr.toStringWithPort()); } - /* use our own ticket keys handler so we can rotate them */ - SSL_CTX_set_tlsext_ticket_key_cb(d_tlsCtx.get(), &OpenSSLTLSIOCtx::ticketKeyCb); - libssl_set_ticket_key_callback_data(d_tlsCtx.get(), this); + if (!fe.d_enableTickets || fe.d_numberOfTicketsKeys == 0) { + sslOptions |= SSL_OP_NO_TICKET; + } + else { + /* use our own ticket keys handler so we can rotate them */ + SSL_CTX_set_tlsext_ticket_key_cb(d_tlsCtx.get(), &OpenSSLTLSIOCtx::ticketKeyCb); + libssl_set_ticket_key_callback_data(d_tlsCtx.get(), this); + } SSL_CTX_set_options(d_tlsCtx.get(), sslOptions); if (!libssl_set_min_tls_version(d_tlsCtx, fe.d_minTLSVersion)) { diff --git a/regression-tests.dnsdist/test_TLSSessionResumption.py b/regression-tests.dnsdist/test_TLSSessionResumption.py new file mode 100644 index 000000000..2c5b615fb --- /dev/null +++ b/regression-tests.dnsdist/test_TLSSessionResumption.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python +import base64 +import dns +import os +import subprocess +import time +from dnsdisttests import DNSDistTest +try: + range = xrange +except NameError: + pass + +class DNSDistTLSSessionResumptionTest(DNSDistTest): + + _consoleKey = DNSDistTest.generateConsoleKey() + _consoleKeyB64 = base64.b64encode(_consoleKey).decode('ascii') + + @classmethod + def checkSessionResumed(cls, addr, port, serverName, caFile, ticketFileOut, ticketFileIn): + # we force TLS 1.3 because the session file gets updated when an existing ticket encrypted with an older key gets re-encrypted with the active key + # whereas in TLS 1.2 the existing ticket is written instead.. + testcmd = ['openssl', 's_client', '-tls1_3', '-CAfile', caFile, '-connect', '%s:%d' % (addr, port), '-servername', serverName, '-sess_out', ticketFileOut] + if ticketFileIn and os.path.exists(ticketFileIn): + testcmd = testcmd + ['-sess_in', ticketFileIn] + + output = None + try: + process = subprocess.Popen(testcmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True) + # we need to wait just a bit so that the Post-Handshake New Session Ticket has the time to arrive.. + time.sleep(0.1) + output = process.communicate(input=b'') + except subprocess.CalledProcessError as exc: + raise AssertionError('dnsdist --check-config failed (%d): %s' % (exc.returncode, exc.output)) + + for line in output[0].decode().splitlines(): + if line.startswith('Reused, TLSv1.'): + return True + + return False + + @staticmethod + def generateTicketKeysFile(numberOfTickets, outputFile): + with open(outputFile, 'wb') as fp: + fp.write(os.urandom(numberOfTickets * 80)) + +class TestNoTLSSessionResumptionDOH(DNSDistTLSSessionResumptionTest): + + _serverKey = 'server.key' + _serverCert = 'server.chain' + _serverName = 'tls.tests.dnsdist.org' + _caCert = 'ca.pem' + _dohServerPort = 8443 + _numberOfKeys = 0 + _config_template = """ + newServer{address="127.0.0.1:%s"} + + addDOHLocal("127.0.0.1:%s", "%s", "%s", { "/" }, { numberOfTicketsKeys=%d, numberOfStoredSessions=0, sessionTickets=false }) + """ + _config_params = ['_testServerPort', '_dohServerPort', '_serverCert', '_serverKey', '_numberOfKeys'] + + def testNoSessionResumption(self): + """ + Session Resumption: DoH (disabled) + """ + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.out', None)) + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.out', '/tmp/session.out')) + +class TestTLSSessionResumptionDOH(DNSDistTLSSessionResumptionTest): + + _serverKey = 'server.key' + _serverCert = 'server.chain' + _serverName = 'tls.tests.dnsdist.org' + _caCert = 'ca.pem' + _dohServerPort = 8443 + _numberOfKeys = 5 + _config_template = """ + setKey("%s") + controlSocket("127.0.0.1:%s") + newServer{address="127.0.0.1:%s"} + + addDOHLocal("127.0.0.1:%s", "%s", "%s", { "/" }, { numberOfTicketsKeys=%d }) + """ + _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_dohServerPort', '_serverCert', '_serverKey', '_numberOfKeys'] + + def testSessionResumption(self): + """ + Session Resumption: DoH + """ + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', None)) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getDOHFrontend(0):rotateTicketsKey()") + + # the session should be resumed and a new ticket, encrypted with the newly active key, should be stored + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getDOHFrontend(0):rotateTicketsKey()") + + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, not keeping any key around this time! + for _ in range(self._numberOfKeys): + self.sendConsoleCommand("getDOHFrontend(0):rotateTicketsKey()") + + # we should not be able to resume + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # generate a file containing _numberOfKeys ticket keys + self.generateTicketKeysFile(self._numberOfKeys, '/tmp/ticketKeys.1') + self.generateTicketKeysFile(self._numberOfKeys - 1, '/tmp/ticketKeys.2') + # load all ticket keys from the file + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.1')") + + # create a new session, resume it + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', None)) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload the same keys + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.1')") + + # should still be able to resume + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getDOHFrontend(0):rotateTicketsKey()") + # should still be able to resume + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload the same keys + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.1')") + # since the last key was only present in memory, we should not be able to resume + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # but now we can + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # generate a file with only _numberOfKeys - 1 keys, so the last active one should still be around after loading that one + self.generateTicketKeysFile(self._numberOfKeys - 1, '/tmp/ticketKeys.2') + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.2')") + # we should be able to resume, and the ticket should be re-encrypted with the new key (NOTE THAT we store into a new file!!) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session')) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session.2')) + + # rotate all keys, we should not be able to resume + for _ in range(self._numberOfKeys): + self.sendConsoleCommand("getDOHFrontend(0):rotateTicketsKey()") + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.3', '/tmp/session.2')) + + # reload from file 1, the old session should resume + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.1')") + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload from file 2, the latest session should resume + self.sendConsoleCommand("getDOHFrontend(0):loadTicketsKeys('/tmp/ticketKeys.2')") + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._dohServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session.2')) + +class TestNoTLSSessionResumptionDOT(DNSDistTLSSessionResumptionTest): + + _serverKey = 'server.key' + _serverCert = 'server.chain' + _serverName = 'tls.tests.dnsdist.org' + _caCert = 'ca.pem' + _tlsServerPort = 8443 + _numberOfKeys = 0 + _config_template = """ + newServer{address="127.0.0.1:%s"} + + addTLSLocal("127.0.0.1:%s", "%s", "%s", { numberOfTicketsKeys=%d, numberOfStoredSessions=0, sessionTickets=false }) + """ + _config_params = ['_testServerPort', '_tlsServerPort', '_serverCert', '_serverKey', '_numberOfKeys'] + + def testNoSessionResumption(self): + """ + Session Resumption: DoT (disabled) + """ + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.out', None)) + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.out', '/tmp/session.out')) + +class TestTLSSessionResumptionDOT(DNSDistTLSSessionResumptionTest): + + _serverKey = 'server.key' + _serverCert = 'server.chain' + _serverName = 'tls.tests.dnsdist.org' + _caCert = 'ca.pem' + _tlsServerPort = 8443 + _numberOfKeys = 5 + _config_template = """ + setKey("%s") + controlSocket("127.0.0.1:%s") + newServer{address="127.0.0.1:%s"} + + addTLSLocal("127.0.0.1:%s", "%s", "%s", { provider="openssl", numberOfTicketsKeys=%d }) + """ + _config_params = ['_consoleKeyB64', '_consolePort', '_testServerPort', '_tlsServerPort', '_serverCert', '_serverKey', '_numberOfKeys'] + + def testSessionResumption(self): + """ + Session Resumption: DoT + """ + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', None)) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getTLSContext(0):rotateTicketsKey()") + + # the session should be resumed and a new ticket, encrypted with the newly active key, should be stored + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getTLSContext(0):rotateTicketsKey()") + + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, not keeping any key around this time! + for _ in range(self._numberOfKeys): + self.sendConsoleCommand("getTLSContext(0):rotateTicketsKey()") + + # we should not be able to resume + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # generate a file containing _numberOfKeys ticket keys + self.generateTicketKeysFile(self._numberOfKeys, '/tmp/ticketKeys.1') + self.generateTicketKeysFile(self._numberOfKeys - 1, '/tmp/ticketKeys.2') + # load all ticket keys from the file + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.1')") + + # create a new session, resume it + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', None)) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload the same keys + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.1')") + + # should still be able to resume + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # rotate the TLS session ticket keys several times, but keep the previously active one around so we can resume + for _ in range(self._numberOfKeys - 1): + self.sendConsoleCommand("getTLSContext(0):rotateTicketsKey()") + # should still be able to resume + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload the same keys + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.1')") + # since the last key was only present in memory, we should not be able to resume + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # but now we can + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # generate a file with only _numberOfKeys - 1 keys, so the last active one should still be around after loading that one + self.generateTicketKeysFile(self._numberOfKeys - 1, '/tmp/ticketKeys.2') + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.2')") + # we should be able to resume, and the ticket should be re-encrypted with the new key (NOTE THAT we store into a new file!!) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session')) + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session.2')) + + # rotate all keys, we should not be able to resume + for _ in range(self._numberOfKeys): + self.sendConsoleCommand("getTLSContext(0):rotateTicketsKey()") + self.assertFalse(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.3', '/tmp/session.2')) + + # reload from file 1, the old session should resume + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.1')") + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session', '/tmp/session')) + + # reload from file 2, the latest session should resume + self.sendConsoleCommand("getTLSContext(0):loadTicketsKeys('/tmp/ticketKeys.2')") + self.assertTrue(self.checkSessionResumed('127.0.0.1', self._tlsServerPort, self._serverName, self._caCert, '/tmp/session.2', '/tmp/session.2'))