bneradt opened a new issue, #13735:
URL: https://github.com/apache/trafficserver/issues/13735

   ## 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
   ```
   
   <details>
   <summary><code>tls_resume_cert_partition.test.py</code></summary>
   
   ```python
   '''
   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()
   ```
   
   </details>
   
   ## 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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to