diff --git a/configs/sni.yaml.default b/configs/sni.yaml.default index c39363a6ef2..57a1ed24ed9 100644 --- a/configs/sni.yaml.default +++ b/configs/sni.yaml.default @@ -28,6 +28,12 @@ # The location of the certificate file is relative to proxy.config.ssl.server.cert.path directory. # client_key - sets the file containing the client private key that corresponds to the certificate for the outbound connection. # client_sni_policy - policy of SNI on outbound connection. +# ssl_ticket_enabled - enables or disables session tickets for matched inbound TLS connections; parameters = 1 or 0. +# This overrides proxy.config.ssl.server.session_ticket.enable. +# ssl_ticket_number - sets the number of TLSv1.3 session tickets issued for matched inbound TLS connections; +# parameters = INTEGER. This overrides proxy.config.ssl.server.session_ticket.number. +# BoringSSL does not support setting the ticket number on a per-SNI basis, +# so this configuration is ignored when ATS is linked against BoringSSL. # http2 - adds or removes HTTP/2 (H2) from the protocol list advertised by ATS; parameter required = None, parameters = on or off # tunnel_route - sets the e2e tunnel route # forward_route - destination as an FQDN and port, separated by a colon :. diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 43bf2ab7bba..b3877eff3ff 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -4190,6 +4190,11 @@ SSL Termination Increasing the number of tickets could be potentially beneficial for clients performing multiple requests over concurrent TLS connections as per RFC 8446 clients SHOULDN'T reuse TLS Tickets. + This setting is applied at the SSL context level. BoringSSL does not support setting the + ticket number on a per-SNI basis, so the :file:`sni.yaml` :code:`ssl_ticket_number` + configuration does not apply when ATS is linked against BoringSSL and this context-level + value remains in effect. + For more information see https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_set_num_tickets.html .. ts:cv:: CONFIG proxy.config.ssl.hsts_max_age INT -1 diff --git a/doc/admin-guide/files/sni.yaml.en.rst b/doc/admin-guide/files/sni.yaml.en.rst index 95958943630..404f7ea62b1 100644 --- a/doc/admin-guide/files/sni.yaml.en.rst +++ b/doc/admin-guide/files/sni.yaml.en.rst @@ -172,6 +172,21 @@ server_groups_list Inbound Specifies an override to the `OpenSSL SSL_CTX_set_groups_list `_ documentation. +ssl_ticket_enabled Inbound Specifies an override to the global + :ts:cv:`proxy.config.ssl.server.session_ticket.enable` + :file:`records.yaml` configuration. Set this to :code:`1` to enable + session tickets or :code:`0` to disable them for matching inbound TLS + connections. + +ssl_ticket_number Inbound Specifies an override to the global + :ts:cv:`proxy.config.ssl.server.session_ticket.number` + :file:`records.yaml` configuration. This controls how many TLSv1.3 + session tickets are issued for matching inbound TLS connections. + BoringSSL does not support setting the ticket number on a + per-SNI basis, so this configuration does not apply when ATS is + linked against BoringSSL. The configured ticket count from the + selected SSL context remains in effect. + host_sni_policy Inbound One of the values :code:`DISABLED`, :code:`PERMISSIVE`, or :code:`ENFORCED`. If not specified, the value of :ts:cv:`proxy.config.http.host_sni_policy` is used. diff --git a/include/iocore/net/TLSSNISupport.h b/include/iocore/net/TLSSNISupport.h index 22f3d751a8b..346c2c1c0af 100644 --- a/include/iocore/net/TLSSNISupport.h +++ b/include/iocore/net/TLSSNISupport.h @@ -140,6 +140,8 @@ class TLSSNISupport std::optional http2_max_priority_frames_per_minute; std::optional http2_max_rst_stream_frames_per_minute; std::optional http2_max_continuation_frames_per_minute; + std::optional ssl_ticket_enabled; + std::optional ssl_ticket_number; std::optional outbound_sni_policy; } hints_from_sni; diff --git a/include/iocore/net/YamlSNIConfig.h b/include/iocore/net/YamlSNIConfig.h index c8396a76e74..91d0683f8f3 100644 --- a/include/iocore/net/YamlSNIConfig.h +++ b/include/iocore/net/YamlSNIConfig.h @@ -59,6 +59,8 @@ TSDECL(client_sni_policy); TSDECL(server_cipher_suite); TSDECL(server_TLSv1_3_cipher_suites); TSDECL(server_groups_list); +TSDECL(ssl_ticket_enabled); +TSDECL(ssl_ticket_number); TSDECL(ip_allow); TSDECL(valid_tls_versions_in); TSDECL(valid_tls_version_min_in); @@ -107,6 +109,8 @@ struct YamlSNIConfig { std::string server_cipher_suite; std::string server_TLSv1_3_cipher_suites; std::string server_groups_list; + std::optional ssl_ticket_enabled; + std::optional ssl_ticket_number; std::string ip_allow; bool protocol_unset = true; unsigned long protocol_mask; diff --git a/src/iocore/net/SNIActionPerformer.cc b/src/iocore/net/SNIActionPerformer.cc index d0ea144f18a..a5c04710eb1 100644 --- a/src/iocore/net/SNIActionPerformer.cc +++ b/src/iocore/net/SNIActionPerformer.cc @@ -475,6 +475,40 @@ ServerMaxEarlyData::SNIAction([[maybe_unused]] SSL &ssl, const Context & /* ctx return SSL_TLSEXT_ERR_OK; } +int +ServerSessionTicketEnabled::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const +{ +#if TS_HAS_TLS_SESSION_TICKET + if (auto snis = TLSSNISupport::getInstance(&ssl)) { + const char *servername = snis->get_sni_server_name(); + Dbg(dbg_ctl_ssl_sni, "Setting session ticket support from sni.yaml to %d for fqdn [%s]", session_ticket_enabled, servername); + snis->hints_from_sni.ssl_ticket_enabled = session_ticket_enabled; + } + + // Apply the ticket enable/disable flag immediately so the current handshake + // sees the per-SNI override before TLS session ticket processing kicks in. + if (session_ticket_enabled != 0) { + SSL_clear_options(&ssl, SSL_OP_NO_TICKET); + } else { + SSL_set_options(&ssl, SSL_OP_NO_TICKET); + } +#endif + return SSL_TLSEXT_ERR_OK; +} + +int +ServerSessionTicketNumber::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const +{ +#if TS_HAS_TLS_SESSION_TICKET + if (auto snis = TLSSNISupport::getInstance(&ssl)) { + const char *servername = snis->get_sni_server_name(); + Dbg(dbg_ctl_ssl_sni, "Setting session ticket count from sni.yaml to %d for fqdn [%s]", session_ticket_number, servername); + snis->hints_from_sni.ssl_ticket_number = session_ticket_number; + } +#endif + return SSL_TLSEXT_ERR_OK; +} + int ServerCipherSuite::SNIAction(SSL &ssl, const Context & /* ctx ATS_UNUSED */) const { diff --git a/src/iocore/net/SNIActionPerformer.h b/src/iocore/net/SNIActionPerformer.h index c173caacaad..6f0e328333c 100644 --- a/src/iocore/net/SNIActionPerformer.h +++ b/src/iocore/net/SNIActionPerformer.h @@ -313,6 +313,36 @@ class ServerMaxEarlyData : public ActionItem #endif }; +/** + Override session ticket support by ssl_ticket_enabled in sni.yaml + */ +class ServerSessionTicketEnabled : public ActionItem +{ +public: + ServerSessionTicketEnabled(int value) : session_ticket_enabled(value) {} + ~ServerSessionTicketEnabled() override {} + + int SNIAction(SSL &ssl, const Context &ctx) const override; + +private: + int session_ticket_enabled = 0; +}; + +/** + Override the number of issued TLSv1.3 session tickets by ssl_ticket_number in sni.yaml + */ +class ServerSessionTicketNumber : public ActionItem +{ +public: + ServerSessionTicketNumber(int value) : session_ticket_number(value) {} + ~ServerSessionTicketNumber() override {} + + int SNIAction(SSL &ssl, const Context &ctx) const override; + +private: + int session_ticket_number = 0; +}; + /** Override proxy.config.ssl.server.cipher_suite by server_cipher_suite in sni.yaml */ diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index 16b984cd737..9bca4c30869 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -97,6 +97,12 @@ static DbgCtl dbg_ctl_ssl_session_cache{"ssl.session_cache"}; static DbgCtl dbg_ctl_ssl_error{"ssl.error"}; static DbgCtl dbg_ctl_ssl_verify{"ssl_verify"}; +#if TS_HAS_TLS_SESSION_TICKET +static bool ssl_context_enable_ticket_callback(SSL_CTX *ctx); +static bool ssl_apply_sni_session_ticket_properties(SSL *ssl); +static bool ssl_set_session_ticket_number(SSL *ssl, size_t num_tickets); +#endif + /* Using pthread thread ID and mutex functions directly, instead of * ATS this_ethread / ProxyMutex, so that other linked libraries * may use pthreads and openssl without confusing us here. (TS-2271). @@ -304,15 +310,8 @@ ssl_cert_callback(SSL *ssl, [[maybe_unused]] void *arg) setClientCertCACerts(ssl, sslnetvc->get_ca_cert_file(), sslnetvc->get_ca_cert_dir()); } - // Reset the ticket callback if needed - SSL_CTX *ctx = SSL_get_SSL_CTX(ssl); - shared_SSLMultiCertConfigParams sslMultiCertSettings = std::make_shared(); - if (sslMultiCertSettings->session_ticket_enabled != 0) { -#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB - SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket); -#else - SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket); -#endif + if (!ssl_apply_sni_session_ticket_properties(ssl)) { + retval = 0; } } #endif @@ -493,6 +492,77 @@ ssl_context_enable_dhe(const char *dhparams_file, SSL_CTX *ctx) return ctx; } +#if TS_HAS_TLS_SESSION_TICKET +static bool +ssl_context_enable_ticket_callback(SSL_CTX *ctx) +{ +#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB + if (SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket) == 0) { +#else + if (SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket) == 0) { +#endif + Error("failed to set session ticket callback"); + return false; + } + return true; +} + +static bool +ssl_set_session_ticket_number(SSL *ssl, size_t num_tickets) +{ +#if defined(OPENSSL_IS_BORINGSSL) + // BoringSSL only exposes SSL_CTX_set_num_tickets(), so the per-connection + // sni.yaml override is not available here. + (void)ssl; + (void)num_tickets; + return true; +#else + return SSL_set_num_tickets(ssl, num_tickets) == 1; +#endif +} + +static bool +ssl_apply_sni_session_ticket_properties(SSL *ssl) +{ + auto snis = TLSSNISupport::getInstance(ssl); + if (snis == nullptr) { + return true; + } + + auto const &hints = snis->hints_from_sni; + if (!hints.ssl_ticket_enabled.has_value() && !hints.ssl_ticket_number.has_value()) { + return true; + } + + std::optional num_tickets; + + if (hints.ssl_ticket_enabled.has_value()) { + if (hints.ssl_ticket_enabled.value() != 0) { + SSL_clear_options(ssl, SSL_OP_NO_TICKET); + Dbg(dbg_ctl_ssl_load, "Enabled session tickets due to sni.yaml override"); + } else { + SSL_set_options(ssl, SSL_OP_NO_TICKET); + num_tickets = 0; + Dbg(dbg_ctl_ssl_load, "Disabled session tickets due to sni.yaml override"); + } + } + + if ((!hints.ssl_ticket_enabled.has_value() || hints.ssl_ticket_enabled.value() != 0) && hints.ssl_ticket_number.has_value()) { + num_tickets = hints.ssl_ticket_number.value() > 0 ? static_cast(hints.ssl_ticket_number.value()) : 0; + } + + if (num_tickets.has_value()) { + if (!ssl_set_session_ticket_number(ssl, num_tickets.value())) { + Error("failed to set session ticket number from sni.yaml"); + return false; + } + Dbg(dbg_ctl_ssl_load, "Set session ticket number from sni.yaml to %zu", num_tickets.value()); + } + + return true; +} +#endif + static ssl_ticket_key_block * ssl_context_enable_tickets(SSL_CTX *ctx, const char *ticket_key_path) { @@ -509,12 +579,7 @@ ssl_context_enable_tickets(SSL_CTX *ctx, const char *ticket_key_path) // Setting the callback can only fail if OpenSSL does not recognize the // SSL_CTRL_SET_TLSEXT_TICKET_KEY_CB constant. we set the callback first // so that we don't leave a ticket_key pointer attached if it fails. -#ifdef HAVE_SSL_CTX_SET_TLSEXT_TICKET_KEY_EVP_CB - if (SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ssl_callback_session_ticket) == 0) { -#else - if (SSL_CTX_set_tlsext_ticket_key_cb(ctx, ssl_callback_session_ticket) == 0) { -#endif - Error("failed to set session ticket callback"); + if (!ssl_context_enable_ticket_callback(ctx)) { ticket_block_free(keyblock); return nullptr; } @@ -1179,6 +1244,12 @@ SSLMultiCertConfigLoader::init_server_ssl_ctx(CertLoadData const &data, const SS } } +#if TS_HAS_TLS_SESSION_TICKET + if (!ssl_context_enable_ticket_callback(ctx)) { + goto fail; + } +#endif + if (!this->_setup_client_cert_verification(ctx)) { goto fail; } diff --git a/src/iocore/net/TLSSNISupport.cc b/src/iocore/net/TLSSNISupport.cc index 9b56c9129be..b6acd202b2b 100644 --- a/src/iocore/net/TLSSNISupport.cc +++ b/src/iocore/net/TLSSNISupport.cc @@ -165,6 +165,7 @@ TLSSNISupport::set_sni_server_name(SSL *ssl, char const *name) void TLSSNISupport::_clear() { + hints_from_sni = {}; _sni_server_name.reset(); } diff --git a/src/iocore/net/YamlSNIConfig.cc b/src/iocore/net/YamlSNIConfig.cc index bbc0eb4ace0..ed1cd229162 100644 --- a/src/iocore/net/YamlSNIConfig.cc +++ b/src/iocore/net/YamlSNIConfig.cc @@ -164,6 +164,18 @@ YamlSNIConfig::Item::populate_sni_actions(action_vector_t &actions) if (!server_groups_list.empty()) { actions.push_back(std::make_unique(server_groups_list)); } + if (ssl_ticket_enabled.has_value()) { + actions.push_back(std::make_unique(ssl_ticket_enabled.value())); + } + if (ssl_ticket_number.has_value()) { +#if defined(OPENSSL_IS_BORINGSSL) + const char *servername = fqdn.empty() ? "*" : fqdn.c_str(); + Warning( + "sni.yaml: BoringSSL does not support setting the session ticket number, so ssl_ticket_number does not apply for fqdn '%s'", + servername); +#endif + actions.push_back(std::make_unique(ssl_ticket_number.value())); + } if (http2_buffer_water_mark.has_value()) { actions.push_back(std::make_unique(http2_buffer_water_mark.value())); } @@ -230,6 +242,8 @@ std::set valid_sni_config_keys = {TS_fqdn, TS_server_TLSv1_3_cipher_suites, #endif TS_server_groups_list, + TS_ssl_ticket_enabled, + TS_ssl_ticket_number, TS_http2, TS_http2_buffer_water_mark, TS_http2_initial_window_size_in, @@ -465,6 +479,12 @@ template <> struct convert { if (node[TS_server_groups_list]) { item.server_groups_list = node[TS_server_groups_list].as(); } + if (node[TS_ssl_ticket_enabled]) { + item.ssl_ticket_enabled = node[TS_ssl_ticket_enabled].as(); + } + if (node[TS_ssl_ticket_number]) { + item.ssl_ticket_number = node[TS_ssl_ticket_number].as(); + } if (node[TS_ip_allow]) { item.ip_allow = node[TS_ip_allow].as(); } diff --git a/src/iocore/net/unit_tests/sni_conf_test.yaml b/src/iocore/net/unit_tests/sni_conf_test.yaml index 2f7a1dedc39..487daf8070c 100644 --- a/src/iocore/net/unit_tests/sni_conf_test.yaml +++ b/src/iocore/net/unit_tests/sni_conf_test.yaml @@ -49,3 +49,8 @@ sni: # test glob in the middle, this will be an exact match - fqdn: "cat.*.com" + +# test session ticket overrides +- fqdn: tickets.com + ssl_ticket_enabled: 1 + ssl_ticket_number: 3 diff --git a/src/iocore/net/unit_tests/test_SSLSNIConfig.cc b/src/iocore/net/unit_tests/test_SSLSNIConfig.cc index 19fbb31c3b9..382c6aab7f7 100644 --- a/src/iocore/net/unit_tests/test_SSLSNIConfig.cc +++ b/src/iocore/net/unit_tests/test_SSLSNIConfig.cc @@ -107,6 +107,13 @@ TEST_CASE("Test SSLSNIConfig") REQUIRE(actions.first->size() == 3); } + SECTION("The config matches an SNI for tickets.com") + { + auto const &actions{params.get("tickets.com", 443)}; + REQUIRE(actions.first); + REQUIRE(actions.first->size() == 4); ///< ticket enabled + ticket number + early data + fqdn + } + SECTION("Matching order") { auto const &actions{params.get("foo.bar.com", 443)}; diff --git a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc index 14327bb484b..7f22739e6b6 100644 --- a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc +++ b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc @@ -56,7 +56,7 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") FAIL(errorstream.str()); } REQUIRE(zret.is_ok()); - REQUIRE(conf.items.size() == 10); + REQUIRE(conf.items.size() == 11); SECTION("If no ports were specified, port range should contain all ports.") { @@ -103,6 +103,15 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") { CHECK(conf.items[2].inbound_port_ranges.size() == 1); } + + SECTION("Session ticket overrides are parsed.") + { + auto const &item{conf.items[10]}; + REQUIRE(item.ssl_ticket_enabled.has_value()); + CHECK(item.ssl_ticket_enabled.value() == 1); + REQUIRE(item.ssl_ticket_number.has_value()); + CHECK(item.ssl_ticket_number.value() == 3); + } } TEST_CASE("YamlConfig handles bad ports appropriately.") diff --git a/tests/gold_tests/tls/tls_sni_ticket.test.py b/tests/gold_tests/tls/tls_sni_ticket.test.py new file mode 100644 index 00000000000..25f7473dc33 --- /dev/null +++ b/tests/gold_tests/tls/tls_sni_ticket.test.py @@ -0,0 +1,264 @@ +''' +Test sni.yaml session ticket overrides. +''' +# 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 +import re +from typing import Any + +Test.Summary = ''' +Test sni.yaml session ticket overrides +''' + +Test.SkipUnless(Condition.HasOpenSSLVersion('1.1.1')) +Test.Setup.Copy('file.ticket') + + +class TlsSniTicketTest: + _server_is_started = False + _ts_on_started = False + _ts_off_started = False + + def __init__(self) -> None: + """ + Initialize shared test state and configure the ATS processes. + """ + self.ticket_file = os.path.join(Test.RunDirectory, 'file.ticket') + self.setupOriginServer() + self.setupEnabledTS() + self.setupDisabledTS() + + def setupOriginServer(self) -> None: + """ + Configure the origin server with a simple response for all requests. + """ + request_header = { + 'headers': 'GET / HTTP/1.1\r\nHost: tickets.example.com\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': 'ticket test' + } + self.server = Test.MakeOriginServer('server') + self.server.addResponse('sessionlog.json', request_header, response_header) + + def setupTS( + self, + name: str, + sni_name: str, + global_ticket_enabled: int, + global_ticket_number: int, + sni_ticket_enabled: int, + sni_ticket_number: int | None = None) -> Any: + """ + Configure an ATS process for one SNI ticket override scenario. + + :param name: ATS process name. + :param sni_name: SNI hostname matched in sni.yaml. + :param global_ticket_enabled: Process-wide session ticket enable setting. + :param global_ticket_number: Process-wide TLSv1.3 ticket count. + :param sni_ticket_enabled: Per-SNI session ticket enable override. + :param sni_ticket_number: Per-SNI TLSv1.3 ticket count override. + :return: Configured ATS process. + """ + ts = Test.MakeATSProcess(name, enable_tls=True) + + ts.addSSLfile('ssl/server.pem') + ts.addSSLfile('ssl/server.key') + ts.Disk.remap_config.AddLine(f'map / http://127.0.0.1:{self.server.Variables.Port}') + ts.Disk.ssl_multicert_yaml.AddLines( + """ +ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key +""".split("\n")) + + 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': global_ticket_enabled, + 'proxy.config.ssl.server.session_ticket.number': global_ticket_number, + 'proxy.config.ssl.server.ticket_key.filename': self.ticket_file, + }) + + sni_lines = [ + 'sni:', + f'- fqdn: {sni_name}', + f' ssl_ticket_enabled: {sni_ticket_enabled}', + ] + if sni_ticket_number is not None: + sni_lines.append(f' ssl_ticket_number: {sni_ticket_number}') + ts.Disk.sni_yaml.AddLines(sni_lines) + + return ts + + def setupEnabledTS(self) -> None: + """ + Create the ATS process whose SNI rule enables tickets. + """ + self.ts_on = self.setupTS('ts_on', 'tickets-on.com', 0, 0, 1, 3) + + def setupDisabledTS(self) -> None: + """ + Create the ATS process whose SNI rule disables tickets. + """ + self.ts_off = self.setupTS('ts_off', 'tickets-off.com', 1, 2, 0) + + def start_processes_if_needed( + self, tr: Any, start_server: bool = False, start_ts_on: bool = False, start_ts_off: bool = False) -> None: + """ + Register one-time StartBefore hooks for the processes needed by a test run. + + :param tr: The AuTest run definition being configured. + :param start_server: Whether the origin server should be started for this run. + :param start_ts_on: Whether the tickets-enabled ATS process should be started for this run. + :param start_ts_off: Whether the tickets-disabled ATS process should be started for this run. + """ + if start_server and not TlsSniTicketTest._server_is_started: + tr.Processes.Default.StartBefore(self.server) + TlsSniTicketTest._server_is_started = True + + if start_ts_on and not TlsSniTicketTest._ts_on_started: + tr.Processes.Default.StartBefore(self.ts_on) + TlsSniTicketTest._ts_on_started = True + + if start_ts_off and not TlsSniTicketTest._ts_off_started: + tr.Processes.Default.StartBefore(self.ts_off) + TlsSniTicketTest._ts_off_started = True + + @staticmethod + def check_regex_count(output_path: str, pattern: str, expected_count: int, description: str) -> tuple[bool, str, str]: + """ + Count regex matches in a process output file. + + :param output_path: Path to the output file to inspect. + :param pattern: Regex pattern to count. + :param expected_count: Expected number of matches. + :param description: Description reported by the tester. + :return: AuTest lambda result tuple. + """ + with open(output_path, 'r') as f: + content = f.read() + + matches = re.findall(pattern, content) + if len(matches) == expected_count: + return (True, description, f'Found {len(matches)} matches for {pattern}') + return (False, description, f'Expected {expected_count} matches for {pattern}, found {len(matches)}') + + @staticmethod + def session_reuse_command(port: int, servername: str) -> str: + """ + Build a TLSv1.2 resumption command for a specific SNI name. + + :param port: ATS TLS listening port. + :param servername: SNI hostname to send with the connection. + :return: Shell command for repeated TLSv1.2 session reuse attempts. + """ + return ( + f'session_path=`mktemp` && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_out "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2 && ' + f'echo -e "GET / HTTP/1.1\\r\\nHost: {servername}\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{port} -servername {servername} -sess_in "$$session_path" -tls1_2') + + def add_tls12_enabled_run(self) -> None: + """ + Register the TLSv1.2 resumption test for the enabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml enables TLSv1.2 ticket resumption') + tr.Command = TlsSniTicketTest.session_reuse_command(self.ts_on.Variables.ssl_port, 'tickets-on.com') + tr.ReturnCode = 0 + self.start_processes_if_needed(tr, start_server=True, start_ts_on=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'Reused, TLSv1\.2', 5, + 'Check that tickets-on.com reuses TLSv1.2 sessions')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_on + + def add_tls13_enabled_run(self) -> None: + """ + Register the TLSv1.3 ticket count test for the enabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml sets TLSv1.3 ticket count') + tr.Command = ( + f'echo -e "GET / HTTP/1.1\\r\\nHost: tickets-on.com\\r\\nConnection: close\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{self.ts_on.Variables.ssl_port} -servername tickets-on.com -tls1_3 -msg -ign_eof') + tr.ReturnCode = 0 + self.start_processes_if_needed(tr, start_server=True, start_ts_on=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'NewSessionTicket', 3, + 'Check that tickets-on.com receives three TLSv1.3 tickets')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_on + + def add_tls12_disabled_run(self) -> None: + """ + Register the TLSv1.2 non-resumption test for the disabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml disables TLSv1.2 ticket resumption') + tr.Command = TlsSniTicketTest.session_reuse_command(self.ts_off.Variables.ssl_port, 'tickets-off.com') + self.start_processes_if_needed(tr, start_server=True, start_ts_off=True) + tr.Processes.Default.Streams.All = Testers.ExcludesExpression('Reused', 'tickets-off.com should not reuse TLSv1.2 sessions') + tr.Processes.Default.Streams.All += Testers.ContainsExpression('TLSv1.2', 'tickets-off.com should still negotiate TLSv1.2') + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_off + + def add_tls13_disabled_run(self) -> None: + """ + Register the TLSv1.3 no-ticket test for the disabled SNI case. + """ + tr = Test.AddTestRun('sni.yaml disables TLSv1.3 ticket issuance') + tr.Command = ( + f'echo -e "GET / HTTP/1.1\\r\\nHost: tickets-off.com\\r\\nConnection: close\\r\\n\\r\\n" | ' + f'openssl s_client -connect 127.0.0.1:{self.ts_off.Variables.ssl_port} -servername tickets-off.com -tls1_3 -msg -ign_eof' + ) + self.start_processes_if_needed(tr, start_server=True, start_ts_off=True) + tr.Processes.Default.Streams.All.Content = Testers.Lambda( + lambda info, tester: TlsSniTicketTest.check_regex_count( + tr.Processes.Default.Streams.All.AbsPath, r'NewSessionTicket', 0, + 'Check that tickets-off.com receives no TLSv1.3 tickets')) + tr.StillRunningAfter += self.server + tr.StillRunningAfter += self.ts_off + + def run(self) -> None: + """ + Register all AuTest runs for the SNI ticket override coverage. + """ + self.add_tls12_enabled_run() + self.add_tls13_enabled_run() + self.add_tls12_disabled_run() + self.add_tls13_disabled_run() + + +TlsSniTicketTest().run()