Skip to content

TLS tickets resume across dest_ip certificates since #13677 #13735

Description

@bneradt

Summary

Since #13677, a TLS session ticket issued on one dest_ip certificate context resumes on a different dest_ip context that serves a different certificate. A client that sends no SNI and connects first to address A, then to address B with A's ticket, resumes the session and is never shown B's certificate.

Background

ssl_multicert.yaml can select a certificate by destination address (dest_ip), which is how a certificate is chosen for a client that sends no SNI. Until #13677, SSLMultiCertConfigLoader::_store_single_ssl_ctx() gave every certificate context its own random ticket keyblock, and TLSSessionResumptionSupport::processSessionTicket() used the keyblock of the context matching the local address. A ticket issued under one address's certificate therefore failed to decrypt under another's, and the client fell back to a full handshake.

#13677 (40253538ba, "Fix global TLS ticket key use for certificate contexts") removed those per-context keyblocks so that deployments sharing session ticket encryption keys (STEKs) resume consistently across servers. That goal is correct. But the per-context keys were also the only thing keeping tickets from crossing certificates, and nothing replaced them. Every context now falls through to default_global_keyblock, so any context accepts any other context's tickets.

TLS 1.3 resumption does not check the session id context, so it cannot provide this partition either. OpenSSL copies the current sid_ctx into a session resumed from a PSK rather than comparing it.

Reproduction

The autest below fails on master (f5174f6bb8) and passes on 5fb35f4d27, the parent of 40253538ba. It also passes on master with only #13677's SSLUtils.cc change reverted. The ATS config has two dest_ip entries with different certificates (127.0.0.1 serves server.pem, [::1] serves signed-foo.pem) and a global ticket key file. The test then:

  1. connects to 127.0.0.1 with no SNI, saving the session (openssl s_client -noservername -sess_out);
  2. reconnects to 127.0.0.1 with it: resumption is expected (the control);
  3. connects to 127.0.0.1 again, saving a new session, then connects to [::1] offering it (-sess_in).

On master, step 3 prints Reused, TLSv1.3 and the second leg never presents CN=foo.com:

Run: A no-SNI session resumes on the certificate that issued it: Passed
Run: A no-SNI session does not resume on a different certificate: Failed
  A session must not resume against a certificate other than the one that issued it - Failed
  The second leg must be served the second certificate - Failed

It needs only files already in tests/gold_tests/tls/ (file.ticket, ssl/server.pem, ssl/signed-foo.pem and their keys). Save it as tests/gold_tests/tls/tls_resume_cert_partition.test.py and run:

cd build/tests && ./autest.sh --sandbox /tmp/sb --clean=none -f tls_resume_cert_partition
tls_resume_cert_partition.test.py
'''
Test that a TLS session is not resumed against a different server certificate when no SNI is sent.
'''
#  Licensed to the Apache Software Foundation (ASF) under one
#  or more contributor license agreements.  See the NOTICE file
#  distributed with this work for additional information
#  regarding copyright ownership.  The ASF licenses this file
#  to you under the Apache License, Version 2.0 (the
#  "License"); you may not use this file except in compliance
#  with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import os

Test.Summary = '''
Test that a session issued on one dest_ip-selected certificate is not resumed
on a different one. Such connections send no SNI, so the server name in the
session id context cannot distinguish them.
'''

Test.SkipUnless(Condition.HasOpenSSLVersion('1.1.1'))


class TlsResumeCertPartition:
    '''
    Test that resumption is partitioned by server certificate for no-SNI connections.

    A client that sends no SNI has its certificate chosen by destination
    address, so the server name cannot tell two such connections apart.
    Session tickets for every certificate are protected by the same globally
    configured ticket keys, so a ticket carries nothing tying it to the
    certificate that issued it unless the keys used for it depend on that
    certificate. Without that, a ticket issued on one address resumes on another
    address serving a different certificate, and the client skips the
    certificate it would otherwise have been shown.

    The second address is the IPv6 loopback because it needs no interface
    alias on any platform, unlike 127.0.0.2.
    '''

    _first_ip = '127.0.0.1'
    _second_ip = '[::1]'

    def __init__(self) -> None:
        '''Configure the origin server, ATS process, and test runs.'''
        Test.Setup.Copy('file.ticket')
        self.ticket_file = os.path.join(Test.RunDirectory, 'file.ticket')
        self._configure_server()
        self._configure_ts()
        self._add_same_cert_run()
        self._add_cross_cert_run()

    def _configure_server(self) -> None:
        '''Configure the origin server with a simple response.'''
        server = Test.MakeOriginServer('server')
        request_header = {
            'headers': 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n',
            'timestamp': '1469733493.993',
            'body': ''
        }
        response_header = {
            'headers': 'HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n',
            'timestamp': '1469733493.993',
            'body': 'hello'
        }
        server.addResponse('sessionlog.json', request_header, response_header)
        self.server = server

    def _configure_ts(self) -> None:
        '''Configure the ATS process with a different certificate per destination address.'''
        ts = Test.MakeATSProcess('ts', enable_tls=True)
        ts.addSSLfile('ssl/server.pem')
        ts.addSSLfile('ssl/server.key')
        ts.addSSLfile('ssl/signed-foo.pem')
        ts.addSSLfile('ssl/signed-foo.key')

        # Two certificates on one listener, chosen by destination address rather
        # than by SNI. Neither entry sets ssl_ca_name, so the only thing that
        # differs between the two connections is which certificate is served.
        ts.Disk.ssl_multicert_yaml.AddLines(
            f"""
ssl_multicert:
  - dest_ip: "{self._first_ip}"
    ssl_cert_name: server.pem
    ssl_key_name: server.key
  - dest_ip: "{self._second_ip}"
    ssl_cert_name: signed-foo.pem
    ssl_key_name: signed-foo.key
  - dest_ip: "*"
    ssl_cert_name: server.pem
    ssl_key_name: server.key
""".split("\n"))

        ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self.server.Variables.Port}')

        ts.Disk.records_config.update(
            {
                'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}',
                'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}',
                'proxy.config.exec_thread.autoconfig.scale': 1.0,
                'proxy.config.ssl.server.session_ticket.enable': 1,
                'proxy.config.ssl.server.ticket_key.filename': self.ticket_file,
            })
        self.ts = ts

    def _client_command(self, ip: str, session_arg: str) -> str:
        '''
        Build a shell command that connects to the given address without sending an SNI.

        :param ip: Destination address to connect to, which selects the certificate.
        :param session_arg: The s_client argument saving or offering a session.
        :return: Shell command performing the connection.
        '''
        # The IPv6 loopback is on lo0 everywhere, whereas 127.0.0.2 needs an alias on macOS.
        port = self.ts.Variables.ssl_portv6 if ip.startswith('[') else self.ts.Variables.ssl_port
        request = 'printf "GET / HTTP/1.1\\r\\nHost: example.com\\r\\nConnection: close\\r\\n\\r\\n"'
        # No -servername, so no SNI extension is sent and dest_ip selects the cert.
        return (f'{request} | openssl s_client -connect {ip}:{port} -noservername '
                f'{session_arg} -tls1_3 -ign_eof')

    def _add_same_cert_run(self) -> None:
        '''Add a control run proving a no-SNI session resumes against its own certificate.'''
        path = os.path.join(Test.RunDirectory, 'session-same')
        tr = Test.AddTestRun('A no-SNI session resumes on the certificate that issued it')
        tr.Processes.Default.StartBefore(self.server)
        tr.Processes.Default.StartBefore(self.ts)
        tr.StillRunningAfter += self.server
        tr.StillRunningAfter += self.ts
        tr.Command = (
            f'{self._client_command(self._first_ip, f"-sess_out {path}")} && '
            f'{self._client_command(self._first_ip, f"-sess_in {path}")}')
        tr.ReturnCode = 0
        tr.Processes.Default.Streams.All = Testers.ContainsExpression(
            'Reused, TLSv1.3', 'A no-SNI session should resume against the same certificate')

    def _add_cross_cert_run(self) -> None:
        '''Add a run offering a no-SNI session against a different certificate.'''
        path = os.path.join(Test.RunDirectory, 'session-cross')
        tr = Test.AddTestRun('A no-SNI session does not resume on a different certificate')
        tr.StillRunningAfter += self.server
        tr.StillRunningAfter += self.ts
        tr.Command = (
            f'{self._client_command(self._first_ip, f"-sess_out {path}")} && '
            f'{self._client_command(self._second_ip, f"-sess_in {path}")}')
        tr.ReturnCode = 0
        tr.Processes.Default.Streams.All = Testers.ExcludesExpression(
            'Reused', 'A session must not resume against a certificate other than the one that issued it')
        # Without these the run also passes when the second entry never matches and both legs are
        # served the same certificate from the wildcard entry, which proves nothing.
        tr.Processes.Default.Streams.All += Testers.ContainsExpression(
            'CN=random.server.com', 'The first leg must be served the first certificate')
        tr.Processes.Default.Streams.All += Testers.ContainsExpression(
            'CN=foo.com', 'The second leg must be served the second certificate')


TlsResumeCertPartition()

Expected

A ticket issued under one certificate context resumes only on a context serving the same certificate. Servers that share STEKs and serve the same certificate keep resuming each other's tickets, which is what #13677 was for.

Suggested fix

Keep the global keys, as #13677 intended, but make the keys used for a ticket depend on the certificate. In processSessionTicket(), when the local address matches a dest_ip context, derive that context's HMAC and AES keys from the global keys and a digest of the context's certificate. Encryption and decryption both use the derived keys, and the key names stay the same so rotation still works.

A ticket from another certificate then fails its HMAC check, and OpenSSL and BoringSSL both treat that as an unusable ticket and do a full handshake. Two servers with the same STEK file and the same certificate derive the same keys, so fleet-wide resumption keeps working. Contexts not selected by address continue to use the global keys unchanged, as before #13677.

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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions