bryancall commented on code in PR #13736:
URL: https://github.com/apache/trafficserver/pull/13736#discussion_r4108827072
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -115,6 +185,20 @@ TLSSessionResumptionSupport::processSessionTicket(SSL
*ssl, unsigned char *keyna
}
ink_release_assert(keyblock != nullptr && keyblock->num_keys > 0);
+ // A context selected by destination address is chosen for clients that send
no SNI, so the
Review Comment:
A small precision point on this comment and the PR description.
`lookup->find(ip)` only returns a context for an address with an explicit
`dest_ip` entry; `*` is stored under the name key and never matched here. So
this path runs for every connection to such an address, SNI connections
included, and those are bound to the address's certificate rather than the
certificate SNI selected. The code can't tell whether SNI was sent at this
point.
That's reasonable behavior, but the comment reads as if it only applies to
no-SNI clients. Maybe something like: "The context is looked up by destination
address for every connection. A client that sends no SNI has nothing else to
keep its tickets apart, so bind the global keys to that context's certificate."
The PR description is fine on the `cc == nullptr` side ("Contexts not
selected by address use the global keys as before").
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -54,6 +58,72 @@ namespace
{
DbgCtl dbg_ctl_ssl_session_ticket{"ssl_session_ticket"};
+using unique_ssl_ticket_key_block = std::unique_ptr<ssl_ticket_key_block, void
(*)(void *)>;
+
+/** Derive one ticket secret from a global secret and a certificate digest.
+
+ @return Whether the derivation succeeded.
+ */
+bool
+derive_ticket_secret(std::string_view label, const unsigned char *secret,
size_t secret_len, const unsigned char *cert_digest,
+ unsigned cert_digest_len, unsigned char *out, size_t
out_len)
+{
+ unsigned char md[EVP_MAX_MD_SIZE];
+ unsigned md_len = 0;
+ EVP_MD_CTX *md_ctx = EVP_MD_CTX_new();
+ bool const ok = md_ctx != nullptr && EVP_DigestInit_ex(md_ctx,
EVP_sha256(), nullptr) == 1 &&
+ EVP_DigestUpdate(md_ctx, label.data(), label.size()) == 1 &&
EVP_DigestUpdate(md_ctx, secret, secret_len) == 1 &&
+ EVP_DigestUpdate(md_ctx, cert_digest, cert_digest_len) == 1
&& EVP_DigestFinal_ex(md_ctx, md, &md_len) == 1 &&
+ md_len >= out_len;
+
+ EVP_MD_CTX_free(md_ctx);
+ if (ok) {
+ memcpy(out, md, out_len);
+ }
+ OPENSSL_cleanse(md, sizeof(md));
+ return ok;
+}
+
+/** Derive the keys protecting the tickets of one certificate context from the
global keys.
+
+ The global keys are shared by every certificate context, so on their own a
ticket issued under
+ one certificate would resume under any other. Mixing in a digest of the
context's certificate
+ confines each ticket to contexts serving that certificate, while servers
that share both the
+ ticket keys and the certificate still derive the same keys and resume each
other's tickets.
+ The key names are kept so that key rotation works as it does for the
global keys.
+
+ @return The derived keys, or null if the context has no certificate to
bind them to.
+ */
+unique_ssl_ticket_key_block
+ticket_keyblock_for_certificate(const ssl_ticket_key_block &global,
SSLCertContext &cc)
+{
+ unique_ssl_ticket_key_block derived{nullptr, ticket_block_free};
+ shared_SSL_CTX ctx = cc.getCtx();
+ X509 *cert = ctx ? SSL_CTX_get0_certificate(ctx.get())
: nullptr;
+ unsigned char cert_digest[EVP_MAX_MD_SIZE];
+ unsigned cert_digest_len = 0;
+
+ if (cert == nullptr || X509_digest(cert, EVP_sha256(), cert_digest,
&cert_digest_len) != 1) {
+ return derived;
+ }
+
+ derived.reset(ticket_block_alloc(global.num_keys));
+ for (unsigned i = 0; i < global.num_keys; ++i) {
+ ssl_ticket_key_t const &from = global.keys[i];
+ ssl_ticket_key_t &to = derived->keys[i];
+
+ memcpy(to.key_name, from.key_name, sizeof(to.key_name));
Review Comment:
Because the derived block keeps the global key names, a ticket issued under
a different certificate still matches by name in `_getSessionInformation()`.
That function increments `total_tickets_verified`, calls
`_setResumptionType(RESUMED_FROM_SESSION_TICKET)` and returns 2. OpenSSL only
checks the HMAC after the callback returns; the check fails and it does a full
handshake, but the resumption type is already set.
I ran the new test with `%<cqssr> %<cqssrt>` added to the log format. On the
cross-cert leg, `s_client` reports `New, TLSv1.3`, but ATS logs `1 2`. So
`cqssr`/`cqssrt`, `TSVConnIsSslReused()` and
`proxy.process.ssl.total_tickets_verified` all over-report whenever a ticket
from another certificate is offered.
Could the key name be derived as well? For example, the first 16 bytes of
`SHA256("ATS session ticket name" || key_name || cert_digest)`. That stays
deterministic across servers and keeps the rotation order at the same index. A
ticket from another certificate would then take the `keyname is not consistent`
path and count as `total_tickets_not_found`. It would also be worth asserting
in the test that `cqssr` is 0 on the cross-cert leg.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -115,6 +185,20 @@ TLSSessionResumptionSupport::processSessionTicket(SSL
*ssl, unsigned char *keyna
}
ink_release_assert(keyblock != nullptr && keyblock->num_keys > 0);
+ // A context selected by destination address is chosen for clients that send
no SNI, so the
+ // server name cannot keep its tickets apart from another address's. Bind
the global keys to
+ // the context's certificate so a ticket only resumes where that certificate
is served.
+ unique_ssl_ticket_key_block cert_keyblock{nullptr, ticket_block_free};
+ if (cc != nullptr && cc->keyblock == nullptr) {
+ cert_keyblock = ticket_keyblock_for_certificate(*keyblock, *cc);
Review Comment:
Not blocking: this recomputes on every ticket operation. That's an
`X509_digest`, two SHA-256 passes per key with a fresh `EVP_MD_CTX` each, and
an alloc/free of the block. A standalone benchmark of the same steps measured
about 1.4 µs per derivation with one key. A resumed TLS 1.3 handshake does
about three of them (one decrypt, two new tickets), which comes to roughly 10%
on top of the ECDHE cost of a resumed handshake.
Caching in `cc->keyblock` wouldn't work directly: the ticket keys reload
independently of `ssl_multicert` (file change, `TSSslTicketKeyUpdate`), and a
non-null `cc->keyblock` means "explicit keys" at line 180. One option is to
compute the certificate digest once per context at load time and cache derived
blocks in `SSLTicketParams`, keyed by digest and rebuilt on ticket reload. A
cheaper step is to reuse one `EVP_MD_CTX` across the keys. Either way, a note
that recomputing per call is what keeps rotation correct would stop someone
caching it naively later.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -115,6 +185,20 @@ TLSSessionResumptionSupport::processSessionTicket(SSL
*ssl, unsigned char *keyna
}
ink_release_assert(keyblock != nullptr && keyblock->num_keys > 0);
+ // A context selected by destination address is chosen for clients that send
no SNI, so the
+ // server name cannot keep its tickets apart from another address's. Bind
the global keys to
+ // the context's certificate so a ticket only resumes where that certificate
is served.
+ unique_ssl_ticket_key_block cert_keyblock{nullptr, ticket_block_free};
+ if (cc != nullptr && cc->keyblock == nullptr) {
Review Comment:
For a connection on a proxy-protocol port,
`SSLNetVConnection::_lookupContextByIP()` chooses the certificate by the PROXY
destination address (`get_proxy_protocol_dst_addr()`). Here, `cc` comes from
`_getLocalEndpoint()`, which is the socket's `local_addr`. The two can disagree.
Consider a load balancer sending two VIPs, each with its own `dest_ip`
certificate, to one ATS listener address:
- If the listener address has no `dest_ip` entry, `cc` is null and both VIPs
use the global keys.
- If it has one, both VIPs bind to that entry's certificate.
Either way, the separation this PR adds doesn't follow the certificate
actually served. Could the lookup here use the same address selection as
`_lookupContextByIP()`? A run on the proxy-protocol SSL port would cover it.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -115,6 +185,20 @@ TLSSessionResumptionSupport::processSessionTicket(SSL
*ssl, unsigned char *keyna
}
ink_release_assert(keyblock != nullptr && keyblock->num_keys > 0);
+ // A context selected by destination address is chosen for clients that send
no SNI, so the
+ // server name cannot keep its tickets apart from another address's. Bind
the global keys to
+ // the context's certificate so a ticket only resumes where that certificate
is served.
+ unique_ssl_ticket_key_block cert_keyblock{nullptr, ticket_block_free};
+ if (cc != nullptr && cc->keyblock == nullptr) {
+ cert_keyblock = ticket_keyblock_for_certificate(*keyblock, *cc);
+ if (cert_keyblock == nullptr) {
+ // Neither issue nor accept a ticket that is not bound to a certificate.
+ Dbg(dbg_ctl_ssl_session_ticket, "no certificate to bind the ticket keys
to, not using a ticket");
Review Comment:
This condition is per-context and permanent: every handshake to that address
loses tickets for the life of the process, and the only signal is a `Dbg` line
that's off by default. It can happen with a `dest_ip` entry that has no
certificate on the `SSL_CTX`, e.g. `action: tunnel`. Every other outcome in
this file increments an `ssl_rsb.*` counter (`total_tickets_not_found`,
`total_tickets_verified`, ...). Could this get one as well, plus possibly a
one-time `Warning()` naming the address? That way an operator can see tickets
being turned off.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -54,6 +58,72 @@ namespace
{
DbgCtl dbg_ctl_ssl_session_ticket{"ssl_session_ticket"};
+using unique_ssl_ticket_key_block = std::unique_ptr<ssl_ticket_key_block, void
(*)(void *)>;
+
+/** Derive one ticket secret from a global secret and a certificate digest.
+
+ @return Whether the derivation succeeded.
+ */
+bool
+derive_ticket_secret(std::string_view label, const unsigned char *secret,
size_t secret_len, const unsigned char *cert_digest,
+ unsigned cert_digest_len, unsigned char *out, size_t
out_len)
+{
+ unsigned char md[EVP_MAX_MD_SIZE];
+ unsigned md_len = 0;
+ EVP_MD_CTX *md_ctx = EVP_MD_CTX_new();
+ bool const ok = md_ctx != nullptr && EVP_DigestInit_ex(md_ctx,
EVP_sha256(), nullptr) == 1 &&
+ EVP_DigestUpdate(md_ctx, label.data(), label.size()) == 1 &&
EVP_DigestUpdate(md_ctx, secret, secret_len) == 1 &&
+ EVP_DigestUpdate(md_ctx, cert_digest, cert_digest_len) == 1
&& EVP_DigestFinal_ex(md_ctx, md, &md_len) == 1 &&
+ md_len >= out_len;
+
+ EVP_MD_CTX_free(md_ctx);
+ if (ok) {
+ memcpy(out, md, out_len);
+ }
+ OPENSSL_cleanse(md, sizeof(md));
+ return ok;
+}
+
+/** Derive the keys protecting the tickets of one certificate context from the
global keys.
+
+ The global keys are shared by every certificate context, so on their own a
ticket issued under
+ one certificate would resume under any other. Mixing in a digest of the
context's certificate
+ confines each ticket to contexts serving that certificate, while servers
that share both the
+ ticket keys and the certificate still derive the same keys and resume each
other's tickets.
+ The key names are kept so that key rotation works as it does for the
global keys.
+
+ @return The derived keys, or null if the context has no certificate to
bind them to.
Review Comment:
Null is also returned when `X509_digest()` or either
`derive_ticket_secret()` call fails, not only when there is no certificate. The
caller treats all of these the same way, so it would help to list them here.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -115,6 +185,20 @@ TLSSessionResumptionSupport::processSessionTicket(SSL
*ssl, unsigned char *keyna
}
ink_release_assert(keyblock != nullptr && keyblock->num_keys > 0);
+ // A context selected by destination address is chosen for clients that send
no SNI, so the
+ // server name cannot keep its tickets apart from another address's. Bind
the global keys to
+ // the context's certificate so a ticket only resumes where that certificate
is served.
+ unique_ssl_ticket_key_block cert_keyblock{nullptr, ticket_block_free};
+ if (cc != nullptr && cc->keyblock == nullptr) {
Review Comment:
Nothing sets `SSLCertContext::keyblock` any more. Since #13677, only the
three-argument constructor is used (`SSLUtils.cc` lines 1780/1789/1803), so the
four-argument constructor has no callers. `cc->keyblock == nullptr` is
therefore always true here, and the `else keyblock = cc->keyblock.get()` branch
above is unreachable. Either drop the dead branch and field, or simplify this
condition to `cc != nullptr` with a note. As written, it suggests there is
still a configured per-context key path.
##########
tests/gold_tests/tls/tls_resume_cert_partition.test.py:
##########
@@ -0,0 +1,188 @@
+'''
+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.ts = self._configure_ts('ts')
+ # A second instance sharing the ticket key file stands in for another
server in a fleet.
+ self.ts2 = self._configure_ts('ts2')
+ self._add_same_cert_run()
+ self._add_cross_cert_run()
+ self._add_shared_key_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, name: str) -> 'Process':
+ '''
+ Configure an ATS process with a different certificate per destination
address.
+
+ :param name: Name of the ATS process.
+ :return: The configured ATS process.
+ '''
+ ts = Test.MakeATSProcess(name, 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,
+ })
+ return ts
+
+ def _client_command(self, ip: str, session_arg: str, ts: 'Process' = None)
-> 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.
+ :param ts: The ATS process to connect to, by default the first one.
+ :return: Shell command performing the connection.
+ '''
+ ts = ts or self.ts
+ # The IPv6 loopback is on lo0 everywhere, whereas 127.0.0.2 needs an
alias on macOS.
+ port = ts.Variables.ssl_portv6 if ip.startswith('[') else
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')
Review Comment:
Two small things here:
- Only TLS 1.3 is exercised, but `processSessionTicket()` also handles TLS
1.2 tickets. A `-tls1_2` variant of the same-cert and cross-cert runs would
cover that; `tls_sni_ticket.test.py` already matches `Reused, TLSv1.2`.
- `-noservername` depends on the `openssl` binary on `PATH`, while
`Condition.HasOpenSSLVersion` checks the library ATS links. On macOS,
`/usr/bin/openssl` is LibreSSL, which prints usage, so the test fails instead
of skipping unless Homebrew OpenSSL comes first on `PATH`.
##########
src/iocore/net/TLSSessionResumptionSupport.cc:
##########
@@ -54,6 +58,72 @@ namespace
{
DbgCtl dbg_ctl_ssl_session_ticket{"ssl_session_ticket"};
+using unique_ssl_ticket_key_block = std::unique_ptr<ssl_ticket_key_block, void
(*)(void *)>;
+
+/** Derive one ticket secret from a global secret and a certificate digest.
+
+ @return Whether the derivation succeeded.
+ */
+bool
+derive_ticket_secret(std::string_view label, const unsigned char *secret,
size_t secret_len, const unsigned char *cert_digest,
+ unsigned cert_digest_len, unsigned char *out, size_t
out_len)
+{
+ unsigned char md[EVP_MAX_MD_SIZE];
+ unsigned md_len = 0;
+ EVP_MD_CTX *md_ctx = EVP_MD_CTX_new();
+ bool const ok = md_ctx != nullptr && EVP_DigestInit_ex(md_ctx,
EVP_sha256(), nullptr) == 1 &&
+ EVP_DigestUpdate(md_ctx, label.data(), label.size()) == 1 &&
EVP_DigestUpdate(md_ctx, secret, secret_len) == 1 &&
+ EVP_DigestUpdate(md_ctx, cert_digest, cert_digest_len) == 1
&& EVP_DigestFinal_ex(md_ctx, md, &md_len) == 1 &&
+ md_len >= out_len;
+
+ EVP_MD_CTX_free(md_ctx);
+ if (ok) {
+ memcpy(out, md, out_len);
+ }
+ OPENSSL_cleanse(md, sizeof(md));
+ return ok;
+}
+
+/** Derive the keys protecting the tickets of one certificate context from the
global keys.
+
+ The global keys are shared by every certificate context, so on their own a
ticket issued under
+ one certificate would resume under any other. Mixing in a digest of the
context's certificate
+ confines each ticket to contexts serving that certificate, while servers
that share both the
+ ticket keys and the certificate still derive the same keys and resume each
other's tickets.
+ The key names are kept so that key rotation works as it does for the
global keys.
+
+ @return The derived keys, or null if the context has no certificate to
bind them to.
+ */
+unique_ssl_ticket_key_block
+ticket_keyblock_for_certificate(const ssl_ticket_key_block &global,
SSLCertContext &cc)
+{
+ unique_ssl_ticket_key_block derived{nullptr, ticket_block_free};
+ shared_SSL_CTX ctx = cc.getCtx();
+ X509 *cert = ctx ? SSL_CTX_get0_certificate(ctx.get())
: nullptr;
Review Comment:
Two operational effects that might be worth a line in the docs or release
notes:
- With dual RSA and ECDSA certificates on one line,
`SSL_CTX_get0_certificate()` returns the last certificate loaded. Servers only
derive the same keys if they list the certificates in the same order.
- Renewing the certificate changes the digest. That invalidates outstanding
tickets for that address, and servers can't resume each other's tickets while a
certificate rollout is only partly done.
--
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]