From 9449ef69d4b894f1e5c11cb16d01b7df5a401145 Mon Sep 17 00:00:00 2001 From: Fredrik Svensson Date: Thu, 6 Aug 2026 11:25:24 +0200 Subject: [PATCH] ZOOKEEPER-4828: Honor ssl.context.supplier.class for client-server TLS ZOOKEEPER-4622 replaced the JDK SSLContext based Netty setup with SslContextBuilder, which builds key and trust managers directly from keyStore.location and trustStore.location. As a side effect the documented ssl.context.supplier.class property stopped being honored for the client-server protocol in 3.9.0: it is now only read by X509Util.createSSLContextAndOptions, which the Netty client and server no longer call. Users needing a custom SSLContext, such as a hardware key store or one obtained from a container, are forced to a file based key and trust store for any client-server TLS. Restore the property on both Netty paths by wrapping the supplied SSLContext in a Netty JdkSslContext, the same adapter ZooKeeper used before ZOOKEEPER-4622. Configured protocols, cipher suites, client auth and hostname verification are applied on top of the supplied context, so no new configuration is introduced and the quorum protocol keeps using the identical Supplier. A supplied SSLContext cannot be used by the OpenSSL providers, which build their own native context, so combining it with a non JDK sslProvider is now rejected with a clear error instead of being silently ignored. Also clear the hostname verification system properties in the X509UtilTest teardown. They leaked between test methods and made context construction depend on test execution order. --- .../zookeeper/ClientCnxnSocketNetty.java | 3 +- .../zookeeper/common/ClientX509Util.java | 64 ++++++++++++++++++- .../org/apache/zookeeper/common/X509Util.java | 59 +++++++++++------ .../apache/zookeeper/common/X509UtilTest.java | 61 ++++++++++++++++++ 4 files changed, 164 insertions(+), 23 deletions(-) diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxnSocketNetty.java b/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxnSocketNetty.java index 82364dcb55b..cc8f5a90836 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxnSocketNetty.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxnSocketNetty.java @@ -443,7 +443,8 @@ protected void initChannel(SocketChannel ch) throws Exception { // The synchronized is to prevent the race on shared variable "sslContext". // Basically we only need to create it once. private synchronized void initSSL(ChannelPipeline pipeline) - throws X509Exception.KeyManagerException, X509Exception.TrustManagerException, SSLException { + throws X509Exception.SSLContextException, X509Exception.KeyManagerException, + X509Exception.TrustManagerException, SSLException { if (sslContext == null) { try (ClientX509Util x509Util = new ClientX509Util()) { sslContext = x509Util.createNettySslContextForClient(clientConfig); diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/common/ClientX509Util.java b/zookeeper-server/src/main/java/org/apache/zookeeper/common/ClientX509Util.java index 1e50b84257c..82b1bb3fd1e 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/common/ClientX509Util.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/common/ClientX509Util.java @@ -19,6 +19,8 @@ package org.apache.zookeeper.common; import io.netty.handler.ssl.DelegatingSslContext; +import io.netty.handler.ssl.IdentityCipherSuiteFilter; +import io.netty.handler.ssl.JdkSslContext; import io.netty.handler.ssl.OpenSsl; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; @@ -26,6 +28,7 @@ import java.security.Security; import java.util.Arrays; import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; @@ -62,7 +65,13 @@ public String getSslProviderProperty() { } public SslContext createNettySslContextForClient(ZKConfig config) - throws X509Exception.KeyManagerException, X509Exception.TrustManagerException, SSLException { + throws X509Exception.SSLContextException, X509Exception.KeyManagerException, + X509Exception.TrustManagerException, SSLException { + SSLContext suppliedSSLContext = loadSuppliedSSLContext(config); + if (suppliedSSLContext != null) { + return createNettyJdkSslContext(config, suppliedSSLContext, true); + } + SslContextBuilder sslContextBuilder = SslContextBuilder.forClient(); KeyManager km = buildKeyManager(config); @@ -97,6 +106,11 @@ public SslContext createNettySslContextForClient(ZKConfig config) public SslContext createNettySslContextForServer(ZKConfig config) throws X509Exception.SSLContextException, X509Exception.KeyManagerException, X509Exception.TrustManagerException, SSLException { + SSLContext suppliedSSLContext = loadSuppliedSSLContext(config); + if (suppliedSSLContext != null) { + return createNettyJdkSslContext(config, suppliedSSLContext, false); + } + KeyManager km = buildKeyManager(config); if (km == null) { throw new X509Exception.SSLContextException( @@ -133,6 +147,54 @@ public SslContext createNettySslContextForServer(ZKConfig config, KeyManager key } } + /** + * Wraps a user supplied {@link SSLContext} in a Netty {@link SslContext}, applying the configured + * protocols, cipher suites, client auth mode and hostname verification on top of it. + * + *

A supplied SSLContext carries its own key and trust managers, so it can only be used with the + * JDK SSL provider: the OpenSSL providers build their own native context and cannot delegate to it. + * + *

Unlike the file based path, hostname verification is applied whenever it is enabled. The file + * based path relies on {@link ZKTrustManager} to verify hostnames and only falls back to endpoint + * identification when no trust manager is available, which is never the case for a supplied context. + * + * @param config the configuration to read the SSL options from. + * @param sslContext the user supplied SSLContext. + * @param isClient {@code true} to create a client side context, {@code false} for server side. + * @return the Netty SslContext. + * @throws X509Exception.SSLContextException if a non JDK SSL provider is configured. + */ + private SslContext createNettyJdkSslContext(ZKConfig config, SSLContext sslContext, boolean isClient) + throws X509Exception.SSLContextException { + SslProvider sslProvider = getSslProvider(config); + if (sslProvider != SslProvider.JDK) { + throw new X509Exception.SSLContextException("An SSLContext supplied through " + + getSslContextSupplierClassProperty() + + " can only be used with the JDK SSL provider, but " + + getSslProviderProperty() + + " is set to " + + sslProvider); + } + + SslContext nettySslContext = new JdkSslContext( + sslContext, + isClient, + getCipherSuites(config), + IdentityCipherSuiteFilter.INSTANCE, + null, + isClient ? X509Util.ClientAuth.NONE.toNettyClientAuth() : getClientAuth(config).toNettyClientAuth(), + getEnabledProtocols(config), + false); + + boolean hostnameVerificationEnabled = isClient + ? isServerHostnameVerificationEnabled(config) + : isClientHostnameVerificationEnabled(config); + if (hostnameVerificationEnabled) { + return addHostnameVerification(nettySslContext, isClient ? "Server" : "Client"); + } + return nettySslContext; + } + private SslContextBuilder handleTcnativeOcspStapling(SslContextBuilder builder, ZKConfig config) { SslProvider sslProvider = getSslProvider(config); boolean tcnative = sslProvider == SslProvider.OPENSSL || sslProvider == SslProvider.OPENSSL_REFCNT; diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/common/X509Util.java b/zookeeper-server/src/main/java/org/apache/zookeeper/common/X509Util.java index 460a6f3ac55..2a91e87d217 100644 --- a/zookeeper-server/src/main/java/org/apache/zookeeper/common/X509Util.java +++ b/zookeeper-server/src/main/java/org/apache/zookeeper/common/X509Util.java @@ -360,30 +360,47 @@ public int getSslHandshakeTimeoutMillis() { } } - @SuppressWarnings("unchecked") public SSLContextAndOptions createSSLContextAndOptions(ZKConfig config) throws SSLContextException { + final SSLContext suppliedSSLContext = loadSuppliedSSLContext(config); + if (suppliedSSLContext != null) { + return new SSLContextAndOptions(this, config, suppliedSSLContext); + } + return createSSLContextAndOptionsFromConfig(config); + } + + /** + * Loads an {@link SSLContext} from the {@link Supplier} implementation named by the + * {@link #getSslContextSupplierClassProperty()} property. This allows a user to take full control over + * the construction of the SSLContext, for example to use a hardware key store or an SSLContext obtained + * from a container, rather than having ZooKeeper load key material from files. + * + * @param config the configuration to read the supplier class name from. + * @return the supplied SSLContext, or {@code null} if the property is not set. + * @throws SSLContextException if the supplier class cannot be loaded, instantiated or invoked. + */ + @SuppressWarnings("unchecked") + protected SSLContext loadSuppliedSSLContext(ZKConfig config) throws SSLContextException { final String supplierContextClassName = config.getProperty(sslContextSupplierClassProperty); - if (supplierContextClassName != null) { - LOG.debug("Loading SSLContext supplier from property '{}'", sslContextSupplierClassProperty); + if (supplierContextClassName == null) { + return null; + } + LOG.debug("Loading SSLContext supplier from property '{}'", sslContextSupplierClassProperty); - try { - Class sslContextClass = Class.forName(supplierContextClassName); - Supplier sslContextSupplier = (Supplier) sslContextClass.getConstructor().newInstance(); - return new SSLContextAndOptions(this, config, sslContextSupplier.get()); - } catch (ClassNotFoundException - | ClassCastException - | NoSuchMethodException - | InvocationTargetException - | InstantiationException - | IllegalAccessException e) { - throw new SSLContextException("Could not retrieve the SSLContext from supplier source '" - + supplierContextClassName - + "' provided in the property '" - + sslContextSupplierClassProperty - + "'", e); - } - } else { - return createSSLContextAndOptionsFromConfig(config); + try { + Class sslContextClass = Class.forName(supplierContextClassName); + Supplier sslContextSupplier = (Supplier) sslContextClass.getConstructor().newInstance(); + return sslContextSupplier.get(); + } catch (ClassNotFoundException + | ClassCastException + | NoSuchMethodException + | InvocationTargetException + | InstantiationException + | IllegalAccessException e) { + throw new SSLContextException("Could not retrieve the SSLContext from supplier source '" + + supplierContextClassName + + "' provided in the property '" + + sslContextSupplierClassProperty + + "'", e); } } diff --git a/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509UtilTest.java b/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509UtilTest.java index 660ca64bfdb..f76b91ddf9f 100644 --- a/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509UtilTest.java +++ b/zookeeper-server/src/test/java/org/apache/zookeeper/common/X509UtilTest.java @@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.netty.buffer.UnpooledByteBufAllocator; +import io.netty.handler.ssl.JdkSslContext; import io.netty.handler.ssl.SslContext; import java.io.IOException; import java.net.InetAddress; @@ -91,6 +92,8 @@ public void cleanUp() { System.clearProperty(x509Util.getCipherSuitesProperty()); System.clearProperty(x509Util.getSslProtocolProperty()); System.clearProperty(x509Util.getSslHandshakeDetectionTimeoutMillisProperty()); + System.clearProperty(x509Util.getSslHostnameVerificationEnabledProperty()); + System.clearProperty(x509Util.getSslClientHostnameVerificationEnabledProperty()); System.clearProperty(ServerCnxnFactory.ZOOKEEPER_SERVER_CNXN_FACTORY); System.clearProperty(ZKClientConfig.ZOOKEEPER_CLIENT_CNXN_SOCKET); System.clearProperty(FIPS_MODE_PROPERTY); @@ -725,6 +728,64 @@ public void testCreateSSLContext_validCustomSSLContextClass( assertEquals(SSLContext.getDefault(), sslContext); } + @ParameterizedTest + @MethodSource("data") + public void testCreateNettySslContextForClient_customSSLContextClass( + X509KeyType caKeyType, X509KeyType certKeyType, String keyPassword, Integer paramIndex) + throws Exception { + init(caKeyType, certKeyType, keyPassword, paramIndex); + try (ClientX509Util clientX509Util = new ClientX509Util()) { + ZKConfig zkConfig = new ZKConfig(); + zkConfig.setProperty(clientX509Util.getSslContextSupplierClassProperty(), SslContextSupplier.class.getName()); + // Disable hostname verification so the JdkSslContext is not wrapped in a DelegatingSslContext. + zkConfig.setProperty(clientX509Util.getSslHostnameVerificationEnabledProperty(), "false"); + + SslContext sslContext = clientX509Util.createNettySslContextForClient(zkConfig); + + assertTrue(sslContext instanceof JdkSslContext); + assertEquals(SSLContext.getDefault(), ((JdkSslContext) sslContext).context()); + assertTrue(sslContext.isClient()); + } + } + + @ParameterizedTest + @MethodSource("data") + public void testCreateNettySslContextForServer_customSSLContextClass( + X509KeyType caKeyType, X509KeyType certKeyType, String keyPassword, Integer paramIndex) + throws Exception { + init(caKeyType, certKeyType, keyPassword, paramIndex); + try (ClientX509Util clientX509Util = new ClientX509Util()) { + ZKConfig zkConfig = new ZKConfig(); + zkConfig.setProperty(clientX509Util.getSslContextSupplierClassProperty(), SslContextSupplier.class.getName()); + // A supplied SSLContext carries its own key material, so no key store must be required. + zkConfig.setProperty(clientX509Util.getSslKeystoreLocationProperty(), ""); + // Disable hostname verification so the JdkSslContext is not wrapped in a DelegatingSslContext. + zkConfig.setProperty(clientX509Util.getSslHostnameVerificationEnabledProperty(), "false"); + + SslContext sslContext = clientX509Util.createNettySslContextForServer(zkConfig); + + assertTrue(sslContext instanceof JdkSslContext); + assertEquals(SSLContext.getDefault(), ((JdkSslContext) sslContext).context()); + assertTrue(sslContext.isServer()); + } + } + + @ParameterizedTest + @MethodSource("data") + public void testCreateNettySslContext_customSSLContextClassRejectsNonJdkProvider( + X509KeyType caKeyType, X509KeyType certKeyType, String keyPassword, Integer paramIndex) + throws Exception { + init(caKeyType, certKeyType, keyPassword, paramIndex); + try (ClientX509Util clientX509Util = new ClientX509Util()) { + ZKConfig zkConfig = new ZKConfig(); + zkConfig.setProperty(clientX509Util.getSslContextSupplierClassProperty(), SslContextSupplier.class.getName()); + zkConfig.setProperty(clientX509Util.getSslProviderProperty(), "OPENSSL"); + + assertThrows(X509Exception.SSLContextException.class, + () -> clientX509Util.createNettySslContextForClient(zkConfig)); + } + } + @ParameterizedTest @MethodSource("data") public void testCreateSSLContext_ocspWithJreProvider(