|
| 1 | +# Copyright 2026 Redpanda Data, Inc. |
| 2 | +# |
| 3 | +# Use of this software is governed by the Business Source License |
| 4 | +# included in the file licenses/BSL.md |
| 5 | +# |
| 6 | +# As of the Change Date specified in that file, in accordance with |
| 7 | +# the Business Source License, use of this software will be governed |
| 8 | +# by the Apache License, Version 2.0 |
| 9 | + |
| 10 | +import socket |
| 11 | +import subprocess |
| 12 | + |
| 13 | +from ducktape.cluster.cluster import ClusterNode |
| 14 | +from ducktape.services.service import Service |
| 15 | +from ducktape.tests.test import TestContext |
| 16 | + |
| 17 | +from rptest.services.cluster import cluster |
| 18 | +from rptest.services.redpanda import ( |
| 19 | + SecurityConfig, |
| 20 | + TLSProvider, |
| 21 | +) |
| 22 | +from rptest.services.tls import ( |
| 23 | + Certificate, |
| 24 | + CertificateAuthority, |
| 25 | + TLSCertManager, |
| 26 | +) |
| 27 | +from rptest.tests.redpanda_test import RedpandaTest |
| 28 | + |
| 29 | + |
| 30 | +class OpenSSLConfigTestProvider(TLSProvider): |
| 31 | + """Simple TLS provider for the OpenSSL config isolation test.""" |
| 32 | + |
| 33 | + def __init__(self, tls: TLSCertManager): |
| 34 | + self._tls = tls |
| 35 | + |
| 36 | + @property |
| 37 | + def ca(self) -> CertificateAuthority: |
| 38 | + return self._tls.ca |
| 39 | + |
| 40 | + def create_broker_cert(self, service: Service, node: ClusterNode) -> Certificate: |
| 41 | + assert node in service.nodes |
| 42 | + return self._tls.create_cert(node.name) |
| 43 | + |
| 44 | + def create_service_client_cert(self, service: Service, name: str) -> Certificate: |
| 45 | + return self._tls.create_cert(socket.gethostname(), name=name) |
| 46 | + |
| 47 | + |
| 48 | +# OpenSSL config that restricts TLS to version 1.2 maximum |
| 49 | +# If this config is loaded by Redpanda, TLS 1.3 handshakes will fail |
| 50 | +RESTRICTIVE_OPENSSL_CNF = """ |
| 51 | +# OpenSSL config that blocks TLS 1.3 |
| 52 | +openssl_conf = openssl_init |
| 53 | +
|
| 54 | +[openssl_init] |
| 55 | +ssl_conf = ssl_sect |
| 56 | +
|
| 57 | +[ssl_sect] |
| 58 | +system_default = system_default_sect |
| 59 | +
|
| 60 | +[system_default_sect] |
| 61 | +# This setting would prevent TLS 1.3 connections if loaded |
| 62 | +MaxProtocol = TLSv1.2 |
| 63 | +""" |
| 64 | + |
| 65 | + |
| 66 | +class OpenSSLConfigIsolationTest(RedpandaTest): |
| 67 | + """ |
| 68 | + Test that Redpanda does not load the system's openssl.cnf file. |
| 69 | +
|
| 70 | + This test verifies the fix for the OpenSSL initialization ordering bug |
| 71 | + where Redpanda could unintentionally pick up the OS's OPENSSL_CONF-configured |
| 72 | + openssl.cnf file, causing wrong crypto behavior. |
| 73 | +
|
| 74 | + The test works by: |
| 75 | + 1. Creating a custom openssl.cnf that sets MaxProtocol = TLSv1.2 |
| 76 | + 2. Setting OPENSSL_CONF to point to this restrictive config |
| 77 | + 3. Starting Redpanda with TLS enabled |
| 78 | + 4. Attempting a TLS 1.3 handshake |
| 79 | +
|
| 80 | + If Redpanda incorrectly loads the system config, TLS 1.3 would be blocked |
| 81 | + and the handshake would fail. If Redpanda correctly ignores the system |
| 82 | + config (using OPENSSL_INIT_NO_LOAD_CONFIG), TLS 1.3 should succeed. |
| 83 | + """ |
| 84 | + |
| 85 | + # Path on the node where we'll write the restrictive OpenSSL config |
| 86 | + RESTRICTIVE_CONFIG_PATH = "/tmp/restrictive_openssl.cnf" |
| 87 | + |
| 88 | + def __init__(self, test_context: TestContext): |
| 89 | + super(OpenSSLConfigIsolationTest, self).__init__(test_context) |
| 90 | + self.security = SecurityConfig() |
| 91 | + self.tls = TLSCertManager(self.logger) |
| 92 | + |
| 93 | + def setUp(self): |
| 94 | + # Configure TLS |
| 95 | + self.security.tls_provider = OpenSSLConfigTestProvider(tls=self.tls) |
| 96 | + self.redpanda.set_security_settings(self.security) |
| 97 | + |
| 98 | + # Create a client certificate for testing TLS connections |
| 99 | + # The Kafka listener requires client certificate authentication |
| 100 | + self._client_cert = self.tls.create_cert( |
| 101 | + socket.gethostname(), name="test_client" |
| 102 | + ) |
| 103 | + |
| 104 | + # Write the restrictive OpenSSL config to each node |
| 105 | + for node in self.redpanda.nodes: |
| 106 | + node.account.create_file( |
| 107 | + self.RESTRICTIVE_CONFIG_PATH, RESTRICTIVE_OPENSSL_CNF |
| 108 | + ) |
| 109 | + |
| 110 | + # Set OPENSSL_CONF environment variable to point to the restrictive config |
| 111 | + # If Redpanda loads this config, TLS 1.3 will be blocked |
| 112 | + self.redpanda.set_environment({"OPENSSL_CONF": self.RESTRICTIVE_CONFIG_PATH}) |
| 113 | + |
| 114 | + # Start Redpanda with TLS enabled |
| 115 | + super().setUp() |
| 116 | + |
| 117 | + def _verify_tls_version_works( |
| 118 | + self, node: ClusterNode, tls_version: str, port: int |
| 119 | + ) -> bool: |
| 120 | + """ |
| 121 | + Attempt a TLS handshake with the specified version. |
| 122 | +
|
| 123 | + Returns True if the handshake succeeds, False if it fails. |
| 124 | + """ |
| 125 | + # Include client certificate since Kafka listener requires client auth |
| 126 | + cmd = ( |
| 127 | + f"openssl s_client {tls_version} " |
| 128 | + f"-CAfile {self.tls.ca.crt} " |
| 129 | + f"-cert {self._client_cert.crt} " |
| 130 | + f"-key {self._client_cert.key} " |
| 131 | + f"-connect {node.name}:{port}" |
| 132 | + ) |
| 133 | + self.logger.debug(f"Running: {cmd}") |
| 134 | + |
| 135 | + try: |
| 136 | + output = subprocess.check_output( |
| 137 | + cmd.split(), |
| 138 | + stderr=subprocess.STDOUT, |
| 139 | + stdin=subprocess.DEVNULL, |
| 140 | + timeout=10, |
| 141 | + ) |
| 142 | + output_str = output.decode() |
| 143 | + # Check for successful verification |
| 144 | + return ( |
| 145 | + "Verify return code: 0" in output_str |
| 146 | + or "Verify return code: 19" in output_str |
| 147 | + ) |
| 148 | + except subprocess.CalledProcessError as e: |
| 149 | + output_str = e.output.decode() |
| 150 | + self.logger.debug(f"TLS command output: {output_str}") |
| 151 | + |
| 152 | + # Check for TLS version-specific errors FIRST - these indicate the |
| 153 | + # protocol version was rejected. Note: "Verify return code: 0" can |
| 154 | + # be misleading when the handshake fails before certificate exchange |
| 155 | + # (nothing to verify means verification "succeeds" vacuously) |
| 156 | + tls_version_errors = [ |
| 157 | + "no protocols available", |
| 158 | + "tlsv1 alert protocol version", |
| 159 | + "wrong version number", |
| 160 | + "unsupported protocol", |
| 161 | + ] |
| 162 | + if any(err in output_str for err in tls_version_errors): |
| 163 | + self.logger.debug("TLS version negotiation failed") |
| 164 | + return False |
| 165 | + |
| 166 | + # Check that a cipher was actually negotiated (not "(NONE)") |
| 167 | + # If no cipher was negotiated, the TLS handshake failed |
| 168 | + if "Cipher is (NONE)" in output_str: |
| 169 | + self.logger.debug("TLS handshake failed - no cipher negotiated") |
| 170 | + return False |
| 171 | + |
| 172 | + # Check for successful verification - openssl s_client may return |
| 173 | + # non-zero even when TLS handshake succeeded (e.g., server closes |
| 174 | + # connection after handshake) |
| 175 | + if ( |
| 176 | + "Verify return code: 0" in output_str |
| 177 | + or "Verify return code: 19" in output_str |
| 178 | + ): |
| 179 | + self.logger.debug("TLS verification succeeded") |
| 180 | + return True |
| 181 | + |
| 182 | + # For other errors (like generic handshake failures), log and fail |
| 183 | + self.logger.debug("TLS handshake failed for non-version reason") |
| 184 | + return False |
| 185 | + except subprocess.TimeoutExpired: |
| 186 | + self.logger.error("TLS handshake timed out") |
| 187 | + return False |
| 188 | + |
| 189 | + @cluster(num_nodes=1) |
| 190 | + def test_system_openssl_config_not_loaded(self): |
| 191 | + """ |
| 192 | + Verify that Redpanda ignores the system's OPENSSL_CONF setting. |
| 193 | +
|
| 194 | + This test sets OPENSSL_CONF to a config that blocks TLS 1.3, then |
| 195 | + verifies that TLS 1.3 handshakes still succeed. This proves Redpanda |
| 196 | + is using OPENSSL_INIT_NO_LOAD_CONFIG to prevent loading the system |
| 197 | + config. |
| 198 | + """ |
| 199 | + node = self.redpanda.nodes[0] |
| 200 | + kafka_port = 9092 |
| 201 | + |
| 202 | + # First verify TLS 1.2 works (baseline - should work regardless) |
| 203 | + self.logger.info("Verifying TLS 1.2 works (baseline check)") |
| 204 | + tls12_works = self._verify_tls_version_works(node, "-tls1_2", kafka_port) |
| 205 | + assert tls12_works, "TLS 1.2 should work - this is a baseline check" |
| 206 | + |
| 207 | + # Now verify TLS 1.3 works - this is the actual test |
| 208 | + # If OPENSSL_CONF was loaded, TLS 1.3 would be blocked by MaxProtocol=TLSv1.2 |
| 209 | + self.logger.info("Verifying TLS 1.3 works (proves system config not loaded)") |
| 210 | + tls13_works = self._verify_tls_version_works(node, "-tls1_3", kafka_port) |
| 211 | + |
| 212 | + assert tls13_works, ( |
| 213 | + "TLS 1.3 handshake failed! This suggests Redpanda loaded the " |
| 214 | + "system's OPENSSL_CONF which has MaxProtocol=TLSv1.2. " |
| 215 | + "Redpanda should use OPENSSL_INIT_NO_LOAD_CONFIG to prevent this." |
| 216 | + ) |
| 217 | + |
| 218 | + self.logger.info( |
| 219 | + "SUCCESS: TLS 1.3 works despite OPENSSL_CONF setting MaxProtocol=TLSv1.2. " |
| 220 | + "This proves Redpanda correctly ignores the system OpenSSL config." |
| 221 | + ) |
0 commit comments