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]