From cb49fc28b0e2fc2594e9857900feb181731fe678 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:34:25 +0300 Subject: [PATCH 001/242] IGNITE-29209 Add fresh SSL contexts with certificate metadata --- .../ignite/ssl/AbstractSslContextFactory.java | 141 ++++++++- .../ignite/ssl/DelegatingSSLContextSpi.java | 5 +- .../apache/ignite/ssl/SSLContextWrapper.java | 51 ++- .../apache/ignite/ssl/SslContextFactory.java | 7 +- .../ignite/ssl/SslContextMetadataTest.java | 299 ++++++++++++++++++ .../ignite/testsuites/SecurityTestSuite.java | 2 + 6 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 77dedf84eba78..2cf140ba2e2b7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -18,7 +18,12 @@ package org.apache.ignite.ssl; import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Enumeration; import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; @@ -28,11 +33,11 @@ import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; /** - * Represents abstract implementation of SSL Context Factory that caches the result of the first successful - * attempt to create an {@link SSLContext} and always returns it as a result of further invocations of the - * {@link AbstractSslContextFactory#create()}} method. + * Base SSL context factory that caches the result of the first successful {@link #create()} call. + * Use {@link #createNew()} to build another context without changing the cached one. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -118,26 +123,29 @@ public void setProtocols(String... protocols) { private SSLContext createSslContext() throws SSLException { checkParameters(); - KeyManager[] keyMgrs = createKeyManagers(); + KeyMaterial material = createKeyMaterial(); TrustManager[] trustMgrs = createTrustManagers(); try { SSLContext ctx = SSLContext.getInstance(proto); + SSLParameters sslParameters = null; + if (cipherSuites != null || protocols != null) { - SSLParameters sslParameters = new SSLParameters(); + sslParameters = new SSLParameters(); if (cipherSuites != null) sslParameters.setCipherSuites(cipherSuites); if (protocols != null) sslParameters.setProtocols(protocols); - - ctx = new SSLContextWrapper(ctx, sslParameters); } - ctx.init(keyMgrs, trustMgrs, null); + if (sslParameters != null || material.chain != null || material.privateKeyMissing) + ctx = new SSLContextWrapper(ctx, sslParameters, material.chain, material.privateKeyMissing); + + ctx.init(material.keyMgrs, trustMgrs, null); return ctx; } @@ -172,6 +180,18 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ protected abstract KeyManager[] createKeyManagers() throws SSLException; + /** + * Creates key managers and their certificate chain from the same load of the key material. The default calls + * {@link #createKeyManagers()} and leaves the chain unknown. Subclasses that can provide certificate metadata should + * override this method and supply the chain without loading the material again. + * + * @return Key managers and their chain, if known. + * @throws SSLException If the key material could not be loaded. + */ + protected KeyMaterial createKeyMaterial() throws SSLException { + return new KeyMaterial(createKeyManagers(), null); + } + /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. @@ -196,4 +216,109 @@ protected void checkNullParameter(Object param, String name) throws SSLException return ctx; } + + /** + * Builds a new SSL context from the current factory settings without reading or replacing the context cached by + * {@link #create()}. + *

+ * A subclass that builds its context in its own {@link #create()} must override this method too and build the context from + * the same sources. This implementation calls {@link #checkParameters()}, {@link #createKeyMaterial()} and + * {@link #createTrustManagers()}; it does not call {@link #create()}. + *

+ * A subclass that only changes the key source can override {@link #createKeyMaterial()} instead. When building a context + * through another mechanism, attach its loaded certificate chain, if known, using + * {@link SSLContextWrapper#SSLContextWrapper(SSLContext, SSLParameters, X509Certificate[])}. + * + * @return New initialized SSL context. + * @throws IgniteException If the context could not be created. + */ + public SSLContext createNew() { + try { + return createSslContext(); + } + catch (SSLException e) { + throw new IgniteException(e); + } + } + + /** Key managers and the certificate chain loaded with them. */ + protected static final class KeyMaterial { + /** */ + private final KeyManager[] keyMgrs; + + /** */ + private final X509Certificate[] chain; + + /** Whether the loaded store is known to contain no private key. */ + private final boolean privateKeyMissing; + + /** + * @param keyMgrs Key managers, or {@code null} to use the provider's defaults. + * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. + */ + public KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] chain) { + this(keyMgrs, chain, false); + } + + /** + * @param keyMgrs Key managers. + * @param chain Certificate chain, if known. + * @param privateKeyMissing Whether the loaded store contains no private key. + */ + private KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] chain, boolean privateKeyMissing) { + this.keyMgrs = keyMgrs == null ? null : keyMgrs.clone(); + this.chain = chain == null || chain.length == 0 ? null : chain.clone(); + this.privateKeyMissing = privateKeyMissing; + } + + /** @return Key managers, or {@code null} to use the provider's defaults. */ + public @Nullable KeyManager[] keyManagers() { + return keyMgrs == null ? null : keyMgrs.clone(); + } + + /** + * Takes the chain from the store used to create the key managers. A store with one private key and an X.509 chain + * supplies that chain. More than one private key, or a chain containing a non-X.509 certificate, leaves the chain + * unknown. A store without a private key records that fact separately from an unknown chain. + * + * @param keyMgrs Key managers initialized from the store. + * @param store Already loaded key store. + * @return Key managers and the chain of the single private key, if known. + * @throws KeyStoreException If the store cannot be read. + */ + public static KeyMaterial fromKeyStore(KeyManager[] keyMgrs, KeyStore store) throws KeyStoreException { + String keyAlias = null; + + for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { + String alias = aliases.nextElement(); + + if (!store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + continue; + + if (keyAlias != null) + return new KeyMaterial(keyMgrs, null); + + keyAlias = alias; + } + + if (keyAlias == null) + return new KeyMaterial(keyMgrs, null, true); + + Certificate[] certs = store.getCertificateChain(keyAlias); + + if (certs == null || certs.length == 0) + return new KeyMaterial(keyMgrs, null); + + X509Certificate[] chain = new X509Certificate[certs.length]; + + for (int i = 0; i < certs.length; i++) { + if (!(certs[i] instanceof X509Certificate)) + return new KeyMaterial(keyMgrs, null); + + chain[i] = (X509Certificate)certs[i]; + } + + return new KeyMaterial(keyMgrs, chain); + } + } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 360e92239c6bb..170cd5280561a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -51,12 +51,13 @@ class DelegatingSSLContextSpi extends SSLContextSpi { /** {@inheritDoc} */ @Override protected SSLSocketFactory engineGetSocketFactory() { - return new SSLSocketFactoryWrapper(delegate.getSocketFactory(), parameters); + return parameters == null ? delegate.getSocketFactory() : new SSLSocketFactoryWrapper(delegate.getSocketFactory(), parameters); } /** {@inheritDoc} */ @Override protected SSLServerSocketFactory engineGetServerSocketFactory() { - return new SSLServerSocketFactoryWrapper(delegate.getServerSocketFactory(), parameters); + return parameters == null ? delegate.getServerSocketFactory() + : new SSLServerSocketFactoryWrapper(delegate.getServerSocketFactory(), parameters); } /** {@inheritDoc} */ diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java index 67bc834d0886c..22b44b85db234 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java @@ -17,20 +17,69 @@ package org.apache.ignite.ssl; +import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; +import org.jetbrains.annotations.Nullable; /** - * Wrapper for {@link SSLContext} that extend source context with custom SSL parameters. + * Wrapper for {@link SSLContext} with custom SSL parameters and the certificate chain loaded with the context. */ public class SSLContextWrapper extends SSLContext { + /** Chain loaded with the delegate, or {@code null} if unknown. */ + private final X509Certificate[] chain; + + /** Whether the loaded key material is known to contain no private key. */ + private final boolean privateKeyMissing; + /** * @param delegate Wrapped SSL context. * @param sslParameters Extended SSL parameters. */ public SSLContextWrapper(SSLContext delegate, SSLParameters sslParameters) { + this(delegate, sslParameters, delegate instanceof SSLContextWrapper + ? ((SSLContextWrapper)delegate).getCertificateChain() : null, + delegate instanceof SSLContextWrapper && ((SSLContextWrapper)delegate).isPrivateKeyMissing()); + } + + /** + * Wraps a context with its loaded certificate chain. The chain describes the key material, not the outcome of a TLS + * handshake. The delegate must not be reinitialized with different key material after wrapping. + * + * @param delegate Wrapped SSL context. + * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. + * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. + */ + public SSLContextWrapper(SSLContext delegate, @Nullable SSLParameters sslParameters, @Nullable X509Certificate[] chain) { + this(delegate, sslParameters, chain, false); + } + + /** + * Wraps a context with its loaded certificate chain and records whether that key material has no private key. + * + * @param delegate Wrapped SSL context. + * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. + * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. + * @param privateKeyMissing {@code true} only if the loaded material is known to contain no private key. Do not pass + * {@code true} just because the chain is unknown: the material may contain several keys or use custom key managers. + */ + public SSLContextWrapper(SSLContext delegate, @Nullable SSLParameters sslParameters, @Nullable X509Certificate[] chain, + boolean privateKeyMissing) { super(new DelegatingSSLContextSpi(delegate, sslParameters), delegate.getProvider(), delegate.getProtocol()); + + this.chain = chain == null || chain.length == 0 ? null : chain.clone(); + this.privateKeyMissing = privateKeyMissing; + } + + /** @return Chain loaded with the context, own certificate first, or {@code null} if unknown. */ + public @Nullable X509Certificate[] getCertificateChain() { + return chain == null ? null : chain.clone(); + } + + /** @return {@code true} only if the loaded material is known to contain no private key. */ + public boolean isPrivateKeyMissing() { + return privateKeyMissing; } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index af7d140b01f2f..e8525771f6250 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -260,6 +260,11 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { + return createKeyMaterial().keyManagers(); + } + + /** {@inheritDoc} */ + @Override protected final KeyMaterial createKeyMaterial() throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); @@ -267,7 +272,7 @@ public static TrustManager getDisabledTrustManager() { keyMgrFactory.init(keyStore, keyStorePwd); - return keyMgrFactory.getKeyManagers(); + return KeyMaterial.fromKeyStore(keyMgrFactory.getKeyManagers(), keyStore); } catch (NoSuchAlgorithmException e) { throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java new file mode 100644 index 0000000000000..cc26636f98ae4 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -0,0 +1,299 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.InputStream; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; + +/** Tests that certificate metadata belongs to the key material used to create a context. */ +public class SslContextMetadataTest extends GridCommonAbstractTest { + /** Store and key password of the test fixtures. */ + private static final char[] PASSWORD = "123456".toCharArray(); + + /** {@code createNew()} loads the store once, preserves the cached context and attaches the new chain to the new context. */ + @Test + public void testMetadataUsesOneLoadAndStaysWithContext() throws Exception { + KeyStore firstStore = loadStore("node01"); + StoreFactory factory = new StoreFactory(firstStore); + + SSLContext first = factory.create(); + + assertArrayEquals(chain(firstStore, "node01"), chain(first)); + assertEquals(1, factory.loads); + + factory.store = loadStore("node02"); + + SSLContext next = factory.createNew(); + + assertNotSame(first, next); + assertSame(first, factory.create()); + assertEquals(2, factory.loads); + assertArrayEquals(chain(firstStore, "node01"), chain(first)); + assertArrayEquals(chain(factory.store, "node02"), chain(next)); + } + + /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ + @Test + public void testMetadataWithTlsParameters() throws Exception { + KeyStore store = loadStore("node01"); + StoreFactory factory = new StoreFactory(store); + String[] protocols = {"TLSv1.2"}; + String[] ciphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; + + factory.setProtocols(protocols); + factory.setCipherSuites(ciphers); + + for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { + assertArrayEquals(chain(store, "node01"), chain(ctx)); + + SSLEngine engine = ctx.createSSLEngine(); + + assertArrayEquals(protocols, engine.getEnabledProtocols()); + assertArrayEquals(ciphers, engine.getEnabledCipherSuites()); + } + } + + /** More than one private key leaves the chain unknown without preventing context creation. */ + @Test + public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { + assertNull(chain(new StoreFactory(loadStore("node0102")).create())); + } + + /** Trusted certificates and secret keys do not count as private keys. */ + @Test + public void testOnlyPrivateKeyEntriesCount() throws Exception { + KeyStore keys = loadStore("node01"); + KeyStore store = KeyStore.getInstance("JCEKS"); + + store.load(null, PASSWORD); + store.setCertificateEntry("trusted", keys.getCertificate("node01")); + store.setEntry("secret", new KeyStore.SecretKeyEntry(new SecretKeySpec(new byte[16], "AES")), + new KeyStore.PasswordProtection(PASSWORD)); + + StoreFactory factory = new StoreFactory(store); + + assertNull(chain(factory.create())); + + store.setKeyEntry("key", keys.getKey("node01", PASSWORD), PASSWORD, keys.getCertificateChain("node01")); + + assertArrayEquals(chain(keys, "node01"), chain(factory.createNew())); + } + + /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ + @Test + public void testTrustOnlyContextCreation() throws Exception { + StoreFactory factory = new StoreFactory(loadStore("trustboth")); + + for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { + assertNull(chain(ctx)); + assertTrue(((SSLContextWrapper)ctx).isPrivateKeyMissing()); + + SSLParameters params = new SSLParameters(); + + params.setProtocols(new String[] {"TLSv1.2"}); + + SSLContextWrapper outer = new SSLContextWrapper(ctx, params); + + assertTrue(outer.isPrivateKeyMissing()); + assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); + } + } + + /** A subclass that does not override createKeyMaterial() still supplies validation and managers on every build. */ + @Test + public void testLegacyOverridesAreUsedWithoutMetadata() { + LegacyFactory factory = new LegacyFactory(); + + SSLContext first = factory.create(); + + assertSame(first, factory.create()); + assertNotSame(first, factory.createNew()); + assertNull(chain(first)); + assertEquals(2, factory.checks); + assertEquals(2, factory.keys); + assertEquals(2, factory.trust); + } + + /** Neither the source arrays nor arrays returned to callers can replace the recorded key managers and chain. */ + @Test + public void testKeyMaterialDefensiveCopies() throws Exception { + KeyManager mgr = new KeyManager() { }; + KeyManager[] mgrs = {mgr}; + X509Certificate[] expected = chain(loadStore("node01"), "node01"); + X509Certificate[] input = expected.clone(); + AbstractSslContextFactory.KeyMaterial material = new AbstractSslContextFactory.KeyMaterial(mgrs, input); + + mgrs[0] = null; + input[0] = null; + material.keyManagers()[0] = null; + + assertSame(mgr, material.keyManagers()[0]); + + LegacyFactory factory = new LegacyFactory() { + /** {@inheritDoc} */ + @Override protected KeyMaterial createKeyMaterial() { + return material; + } + }; + + assertArrayEquals(expected, chain(factory.create())); + } + + /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ + @Test + public void testWrapperPreservesAndCopiesChain() throws Exception { + X509Certificate[] expected = chain(loadStore("node01"), "node01"); + X509Certificate[] input = expected.clone(); + SSLContext delegate = SSLContext.getInstance("TLS"); + + delegate.init(null, null, null); + + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, input); + + input[0] = null; + wrapper.getCertificateChain()[0] = null; + + assertArrayEquals(expected, wrapper.getCertificateChain()); + + SSLParameters params = new SSLParameters(); + + params.setProtocols(new String[] {"TLSv1.2"}); + + SSLContextWrapper outer = new SSLContextWrapper(wrapper, params); + + assertArrayEquals(expected, outer.getCertificateChain()); + assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); + } + + /** Adding metadata without TLS parameters preserves the default engine settings and socket factory classes. */ + @Test + public void testMetadataWrapperPreservesContextBehavior() throws Exception { + SSLContext delegate = SSLContext.getInstance("TLS"); + + delegate.init(null, null, null); + + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(loadStore("node01"), "node01")); + SSLEngine engine = wrapper.createSSLEngine(); + + assertArrayEquals(delegate.createSSLEngine().getEnabledProtocols(), engine.getEnabledProtocols()); + assertArrayEquals(delegate.createSSLEngine().getEnabledCipherSuites(), engine.getEnabledCipherSuites()); + assertEquals(delegate.getSocketFactory().getClass(), wrapper.getSocketFactory().getClass()); + assertEquals(delegate.getServerSocketFactory().getClass(), wrapper.getServerSocketFactory().getClass()); + + try (ServerSocket sock = wrapper.getServerSocketFactory().createServerSocket()) { + assertFalse(sock.isBound()); + } + } + + /** @return Loaded test key store. */ + private static KeyStore loadStore(String name) throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); + + try (InputStream in = Files.newInputStream(Path.of(GridTestUtils.keyStorePath(name)))) { + store.load(in, PASSWORD); + } + + return store; + } + + /** @return Chain stored for the alias. */ + private static X509Certificate[] chain(KeyStore store, String alias) throws Exception { + Certificate[] chain = store.getCertificateChain(alias); + + return Arrays.copyOf(chain, chain.length, X509Certificate[].class); + } + + /** @return Chain attached to the context, if known. */ + private static X509Certificate[] chain(SSLContext ctx) { + return ctx instanceof SSLContextWrapper ? ((SSLContextWrapper)ctx).getCertificateChain() : null; + } + + /** A file factory whose existing store-loading hook supplies the test material. */ + private static class StoreFactory extends SslContextFactory { + /** */ + private KeyStore store; + + /** */ + private int loads; + + /** */ + StoreFactory(KeyStore store) { + this.store = store; + + setKeyStoreType(store.getType()); + setKeyStoreFilePath("test-store"); + setKeyStorePassword(PASSWORD); + setTrustManagers(getDisabledTrustManager()); + } + + /** {@inheritDoc} */ + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { + loads++; + + return store; + } + } + + /** A subclass that implements the manager hooks without overriding createKeyMaterial(). */ + private static class LegacyFactory extends AbstractSslContextFactory { + /** */ + private int checks; + + /** */ + private int keys; + + /** */ + private int trust; + + /** {@inheritDoc} */ + @Override protected void checkParameters() { + checks++; + } + + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers() { + keys++; + + return null; + } + + /** {@inheritDoc} */ + @Override protected TrustManager[] createTrustManagers() { + trust++; + + return null; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index da768a22290fb..3df66ce6f6b2f 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -79,6 +79,7 @@ import org.apache.ignite.internal.thread.context.OperationContextAttributesTest; import org.apache.ignite.plugin.security.SecurityBasicPermissionSetSerializationTest; import org.apache.ignite.ssl.MultipleSSLContextsTest; +import org.apache.ignite.ssl.SslContextMetadataTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -141,6 +142,7 @@ IgniteSecurityProcessorTest.class, MultipleSSLContextsTest.class, + SslContextMetadataTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, From 28dc8c16d2e5d5bee50a3e41877f75d76ae8ee9f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:39:07 +0300 Subject: [PATCH 002/242] IGNITE-29210 Reload TLS certificates on running nodes --- docs/_docs/security/ssl-tls.adoc | 111 +++- docs/_docs/tools/control-script.adoc | 77 +++ .../jdbc/thin/JdbcThinConnectionSSLTest.java | 39 +- .../ignite/ssl/AbstractSslContextFactory.java | 4 + .../CommandHandlerParsingTest.java | 4 +- ...SecurityCommandHandlerPermissionsTest.java | 13 + ...GridCommandHandlerSslWithSecurityTest.java | 35 +- .../IgniteControlUtilityTestSuite.java | 2 + .../util/GridCommandHandlerSslReloadTest.java | 121 ++++ modules/core/pom.xml | 7 + .../ignite/internal/GridKernalContext.java | 6 + .../internal/GridKernalContextImpl.java | 11 + .../apache/ignite/internal/IgniteKernal.java | 3 + .../management/IgniteCommandRegistry.java | 4 +- .../management/ssl/AbstractSslCommand.java | 41 ++ .../management/ssl/AbstractSslTask.java | 73 ++ .../internal/management/ssl/SslCommand.java | 29 + .../management/ssl/SslReloadCommand.java | 38 ++ .../management/ssl/SslReloadTask.java | 126 ++++ .../management/ssl/SslStatusCommand.java | 31 + .../management/ssl/SslStatusTask.java | 107 +++ .../reader/StandaloneGridKernalContext.java | 6 + .../odbc/ClientListenerProcessor.java | 3 +- .../protocols/tcp/GridTcpRestProtocol.java | 5 +- .../internal/ssl/CurrentSslContext.java | 93 +++ .../ignite/internal/ssl/SslCertificates.java | 91 +++ .../internal/ssl/SslContextProvider.java | 197 ++++++ .../internal/ssl/SslContextRegistry.java | 90 +++ .../tcp/internal/ClusterStateProvider.java | 17 +- .../tcp/internal/GridNioServerWrapper.java | 2 +- .../ignite/spi/discovery/tcp/ServerImpl.java | 24 +- .../spi/discovery/tcp/TcpDiscoverySpi.java | 40 +- .../internal/ssl/SslContextProviderTest.java | 393 +++++++++++ .../ssl/TestCertificateAuthority.java | 240 +++++++ .../tcp/TcpDiscoverySslParametersTest.java | 8 + .../ignite/ssl/SslContextReloadTest.java | 621 ++++++++++++++++++ .../org/apache/ignite/ssl/SslTestUtils.java | 289 ++++++++ .../testframework/junits/IgniteMock.java | 8 + .../ignite/testsuites/SecurityTestSuite.java | 4 + ...mmandHandlerClusterByClassTest_help.output | 6 + ...ndlerClusterByClassWithSSLTest_help.output | 6 + 41 files changed, 2950 insertions(+), 75 deletions(-) create mode 100644 modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b56b2094d586c..6c6a24acabdfb 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -30,7 +30,7 @@ This certificate must be trusted by all other server nodes. To enable SSL/TLS for cluster nodes, configure an `SSLContext` factory in the node configuration. You can use the `org.apache.ignite.ssl.SslContextFactory`, which is the default factory that uses a configurable keystore to initialize the SSL context. -//You can also implement your own `SSLContext` factory. +You can also implement your own `SSLContext` factory, see <>. [CAUTION] ==== @@ -62,8 +62,11 @@ tab:C#/.NET[] tab:C++[unsupported] -- -The keystore must contain the node's certificate, including its private key. -The trust store must contain the trusted certificates for all other cluster nodes. +The keystore must contain the node's certificate and private key. + +The trust store defines which peer certificates the node accepts. Configure trust on all nodes and clients before replacing certificates. + +Configured protocol and cipher suite names must be supported by the JVM. An unsupported name prevents the node from starting. You can define other properties, such as key algorithm, key store type, or trust manager. See the description of the properties in the <> section. @@ -156,28 +159,91 @@ include::{javaFile}[tag=disable-validation,indent=0] ---- -- +[[reload-or-restart]] +== Reload or Restart + +Use link:tools/control-script#reloading-certificates[`control.sh --ssl reload`] after manually replacing a node's certificate or trust store. + +[cols="2,3",opts="header"] +|=== +|What changed |How to apply it +|Contents of the key or trust store used by `SslContextFactory` +|Run reload. The factory reads the configured files again. +|Store path, password, store type, protocols, cipher suites or other factory settings +|Update the node configuration and restart the node. Reload uses the settings the node started with. +|HTTP REST certificates configured directly in Jetty XML +|Restart the node. +|Stores used by thin clients or JDBC and ODBC applications +|Load them in the applications as described in <<1. Trust the New Certificates,stage 1 below>>. The node reload command does not update applications. +|=== + +If `trustManagers` is configured on the factory, it controls trust instead of a trust store file. Reload keeps those managers. Follow their implementation's procedure to change which certificates they accept. + +When the factory supplies its certificate chain, reload rejects expired or not yet valid certificates and keeps the previous context. Without the chain, reload cannot check certificate dates. +With `SslContextFactory`, reload also rejects a key store that contains no private key. Put the certificate and its private key in the key store and run reload again. +You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. + == Upgrading Certificates -If your SSL certificates are about to expire or have been compromised, you can install new certificates without shutting down the whole cluster. -The following is a procedure for updating certificate. +You can replace certificates without stopping the whole cluster. Check <> for your configuration, then choose the stages that apply to your change: -. First of all, make sure the new certificates are trusted by all cluster nodes. -This step may not be necessary if your trusted stores contain the root certificate and the new certificates are signed by the same CA. -+ --- -Repeat the following procedure for the nodes where the certificate is not trusted: +[cols="2,3",opts="header"] +|=== +|Change |Stages +|Same CA, already trusted by every node and application +|Replace certificates (stage 2). +|New CA or new self-signed certificates +|Add trust → replace certificates → remove old trust if required (stages 1–3). +|Compromised private key +|Replace certificates and prevent further use of the old certificate, as described in <>. +|=== -.. Import the new certificate to the trusted store of the node. -.. Gracefully restart the node. -.. Repeat these steps for all server nodes. +=== 1. Trust the New Certificates -Now all nodes trust the new certificates. --- +*When:* before installing certificates from a new CA or new self-signed certificates. Keep the old certificates trusted throughout the replacement. + +* *On every server and client node:* import the new CA certificate into the trust store. For self-signed certificates, import each peer's new certificate, including application client certificates, instead of a CA certificate. After updating the trust stores on all nodes, run link:tools/control-script#reloading-certificates[`control.sh --ssl reload`] once. +* *In every application that connects over SSL:* update its trust store in the same way. For `control.sh`, update the trust store specified with `--truststore`. + +Load the updated stores in running applications: + +[cols="2,3",opts="header"] +|=== +|Application |How to load the stores +|Java and C++ thin clients +|Restart the running clients. +|JDBC applications using `javax.net.ssl.*` system properties +|Restart the running applications. +|ODBC applications and JDBC applications using connection properties for their stores +|Open new connections. The drivers read the stores for each new connection. +|=== + +*Before stage 2:* check trust on every node and application. For JKS or PKCS#12 trust stores, run `keytool -list -keystore `. Check that the output shows the fingerprint of the new CA certificate, or of each new self-signed certificate. For custom trust managers, use their own tools. Neither `--ssl reload` nor `--ssl status` verifies these trust relationships. -. Import the new certificate (including the private key) to the key store of the corresponding node and remove the old certificate. Then gracefully restart the node. Repeat this procedure for all certificates you want to update. +=== 2. Replace the Certificates +*When:* every node and application trusts the new certificates. Start with one node, check it, then repeat on the remaining nodes. -//Otherwise, first you have to push the trust store to all nodes one by one. It will contain trusts for both new and old certificates while you transition. +. Back up the node's key store. Replace the old certificate and private key with the new certificate, its chain and private key. +. Run `control.sh --ssl reload`, then use `control.sh --ssl status` to check the certificate loaded on that node. The reload command runs on all nodes, even when you changed the files on only one. +. Test new connections before replacing the next node's certificate. Use `control.sh --diagnostic connectivity` to check communication between nodes. Test the client connector and binary REST with new connections from your applications. + +Before removing old trust, also replace any application client certificate that would no longer be trusted. This includes `control.sh` started with `--keystore`. Reload the application stores as described in stage 1. + +*If a connection fails:* stop the rollout. Correct peer trust and TLS settings, or restore the old key store and run `control.sh --ssl reload` again. The restored chain must still be valid. If the command cannot reach the node, restore its configuration and restart it. + +=== 3. Remove the Old Trust + +*When:* all affected nodes and applications use the new certificates, and the old CA or self-signed certificates must no longer be trusted. Skip this stage if the old CA must remain trusted. + +. Use `control.sh --ssl status` to check the new certificates on all updated server and client nodes. Verify new connections and check that every affected application has loaded its replacement client certificate. +. Remove the old CA certificate, or the old self-signed certificates, from node and application trust stores. Run `control.sh --ssl reload` for the nodes. Reload the application stores as described in stage 1. + +=== After a Private Key Compromise + +Replacing a certificate does not prevent someone from using the old certificate with the stolen key. Revoke the old certificate and verify that every node and application enforces revocation. `SslContextFactory` with a trust store file does not enforce revocation when `keyAlgorithm` is `SunX509`, the default. If revocation is not enforced, switch to a new CA using stages 1–3 and remove trust in the old CA. For a self-signed certificate, replace it and remove the old certificate from every trust store. + +Reload leaves existing connections open. After preventing new connections with the old certificate, restart nodes one at a time to close existing connections. == SslContextFactory Properties @@ -223,3 +289,14 @@ Now all nodes trust the new certificates. |A list of pre-configured trust managers. |`N/A` |================= + +== Custom SSL Context Factories + +This section is for developers integrating a certificate source, such as a PKI service. +Extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] to support manual reload. The link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createNew()[createNew() contract] describes fresh creation, overrides, serialized calls, and handling of remote requests. + +To let Ignite report and validate certificate dates, return the key managers and their single X.509 chain from the same certificate load through `createKeyMaterial()`. +If the factory builds the SSL context directly, attach the chain with link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SSLContextWrapper.html[SSLContextWrapper]. +Metadata is optional: without a chain, manual reload can still replace the context, but it cannot check certificate dates. + +A plain `Factory` must return a new context on every call. Returning the context already in use makes reload fail. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index f4ba219555317..ff1b118577c88 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1747,3 +1747,80 @@ Examples: ---- control.sh|bat --event list --enabled ---- + +== TLS Certificates + +Use `--ssl reload` to load replacement certificates and `--ssl status` to check the certificates currently in use. +Both commands run on every server and client node in the current topology. With security enabled, they require the `ADMIN_OPS` system permission. + +=== Reloading Certificates + +After preparing replacement certificates or updating trust stores, run: + +[source, shell] +---- +control.sh|bat --ssl reload [--yes] +---- + +The command asks for confirmation. Add `--yes` to skip the prompt when running from a script. It does not bypass certificate or permission checks. + +Before replacing certificates, follow link:security/ssl-tls#upgrading-certificates[Upgrading Certificates] to prepare trust stores and check the change on one node first. +Reload does not test connections or roll back changes if new connections fail. + +==== What Is Updated + +Reload covers communication, discovery, the client connector, and binary REST. +New connections use the reloaded certificates. Existing connections stay open with their current SSL context. + +The command does not update thin clients or JDBC and ODBC applications. HTTP REST configured directly in Jetty XML also needs a restart. +See link:security/ssl-tls#reload-or-restart[Reload or Restart] for changes that reload can apply. + +Nodes absent from the topology, such as reconnecting client nodes, are neither updated nor listed in the output. +Run reload again after they rejoin. If a node cannot reconnect, restart it after updating its certificate configuration. + +==== Checking the Result + +Each result line lists the transports that share an SSL context factory and are updated together. +If that update fails, those transports keep their previous context. Successful updates stay in use, even on a node where another factory failed. + +The command lists failed nodes first and exits with an error if any update failed. +Correct the reported cause and run reload again. Every node also records its result in the log, including the user's login and network address when security is enabled. + +A successful result means the new SSL context is in use for new connections. Use `--ssl status` to check the loaded certificate, then test new connections as described in link:security/ssl-tls#upgrading-certificates[Upgrading Certificates]. + +=== Checking Certificates + +Use this command after a replacement or when investigating a reload failure. It shows what each node has loaded without reading store files again or requesting certificates from a service: + +[source, shell] +---- +control.sh|bat --ssl status +---- + +For each factory, the output lists its transports, loaded certificate and last reload result: + +[source,text] +---- +e1e18f84-0fb5-484d-a623-15ffe3800000 [consistentId=node01]: client connector, communication, discovery + loaded subject=CN=node01, issuer=CN=ca, serial=2c41d7a09b, notBefore=2026-10-05T09:00:00Z, notAfter=2027-10-05T09:00:00Z + last reload succeeded at 2026-10-05T09:00:02.347Z +---- + +Check the certificate's identity and expiry date in the `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. +Status does not test connections or verify that other nodes and clients trust this certificate. + +[cols="2,3",opts="header"] +|=== +|Output |Meaning and next action +|`last reload failed N time(s) in a row` +|Check the reason and time on this line, correct the cause, and run reload again. +|`PROBLEM: the loaded certificate is not valid now` +|A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. +|`certificate metadata is unavailable` +|The factory did not supply its certificate chain, so the command cannot check certificate dates. With `SslContextFactory`, check that the key store contains exactly one private key and its X.509 certificate chain. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. Check the certificate dates directly in the key store or other certificate source. +|`no SSL context factory is configured` +|This node has no certificates managed by these commands. This message does not make either command fail. +|=== + +Status exits with an error if it finds an invalid certificate or cannot run on a node, for example because the node left the topology. Those nodes appear first under `Failed on N node(s)`. +Unavailable metadata and previous reload failures do not by themselves make status fail. diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index c97fbe559117a..49f2b9038076b 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -447,41 +447,28 @@ public void testUnsupportedCustomCipher() throws Exception { } } - /** - * @throws Exception If failed. - */ + /** An unknown cipher suite prevents a node from starting or a client from connecting. */ @Test public void testInvalidCustomCipher() throws Exception { setSslCtxFactoryToCli = true; supportedCiphers = new String[] {"TLS_RSA_WITH_INVALID_SHA256"}; sslCtxFactory = getTestSslContextFactory(); - startGrids(1); + GridTestUtils.assertThrowsAnyCause(log, () -> startGrids(1), IllegalArgumentException.class, "TLS_RSA_WITH_INVALID_SHA256"); - try { - // Same invalid cipher. - GridTestUtils.assertThrows(log, () -> { - return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + - "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + - "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + - "&sslClientCertificateKeyStorePassword=123456" + - "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + - "&sslTrustCertificateKeyStorePassword=123456"); - }, SQLException.class, "Failed to connect to server"); + supportedCiphers = null; + sslCtxFactory = getTestSslContextFactory(); - // Default ciphers. - GridTestUtils.assertThrows(log, () -> { - return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + - "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + - "&sslClientCertificateKeyStorePassword=123456" + - "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + - "&sslTrustCertificateKeyStorePassword=123456"); - }, SQLException.class, "Failed to SSL connect to server"); - } - finally { - stopAllGrids(); - } + startGrids(1); + GridTestUtils.assertThrows(log, () -> + DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + + "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + + "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + + "&sslClientCertificateKeyStorePassword=123456" + + "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + + "&sslTrustCertificateKeyStorePassword=123456"), + SQLException.class, "Failed to connect to server"); } /** diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 2cf140ba2e2b7..acd8b6ac4d51e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -228,6 +228,10 @@ protected KeyMaterial createKeyMaterial() throws SSLException { * A subclass that only changes the key source can override {@link #createKeyMaterial()} instead. When building a context * through another mechanism, attach its loaded certificate chain, if known, using * {@link SSLContextWrapper#SSLContextWrapper(SSLContext, SSLParameters, X509Certificate[])}. + *

+ * The node calls this method for {@code control.sh --ssl reload}. Calls for the same factory on a node never overlap; + * calls for different factories may run at the same time. A subclass that fetches certificates from a remote source must + * limit how long a call takes and stop when its thread is interrupted. * * @return New initialized SSL context. * @throws IgniteException If the context could not be created. diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java index 2e5b918f7d1ac..8465659268319 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java @@ -84,6 +84,7 @@ import org.apache.ignite.internal.management.snapshot.SnapshotCommand; import org.apache.ignite.internal.management.snapshot.SnapshotDeleteCommand; import org.apache.ignite.internal.management.snapshot.SnapshotRestoreCommand; +import org.apache.ignite.internal.management.ssl.SslCommand; import org.apache.ignite.internal.management.tx.TxCommand; import org.apache.ignite.internal.management.tx.TxCommandArg; import org.apache.ignite.internal.management.tx.TxSortOrder; @@ -1257,6 +1258,7 @@ private boolean requireArgs(Class cmd) { cmd == ConsistencyCommand.class || cmd == CdcCommand.class || cmd == IoTestCommand.class || - cmd == EventCommand.class; + cmd == EventCommand.class || + cmd == SslCommand.class; } } diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java index 902bc9797d750..eed86b4fd234e 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java @@ -67,6 +67,7 @@ import static org.apache.ignite.internal.processors.job.GridJobProcessor.JOBS_VIEW; import static org.apache.ignite.internal.processors.task.GridTaskProcessor.TASKS_VIEW; import static org.apache.ignite.internal.util.IgniteUtils.resolveIgnitePath; +import static org.apache.ignite.plugin.security.SecurityPermission.ADMIN_OPS; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_CREATE; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_DESTROY; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_READ; @@ -195,6 +196,18 @@ public void testServiceCancel() throws Exception { assertEquals(0, svcs.size()); } + /** Reloading TLS certificates requires administrative permission. */ + @Test + public void testSslReload() throws Exception { + checkCommandPermissions(asList("--ssl", "reload"), systemPermissions(ADMIN_OPS)); + } + + /** Reading TLS certificate status requires administrative permission. */ + @Test + public void testSslStatus() throws Exception { + checkCommandPermissions(asList("--ssl", "status"), systemPermissions(ADMIN_OPS)); + } + /** */ @Test public void testTaskCancel() throws Exception { diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/processors/security/GridCommandHandlerSslWithSecurityTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/processors/security/GridCommandHandlerSslWithSecurityTest.java index 2a5ac1cd27f6f..8388ce1632cba 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/processors/security/GridCommandHandlerSslWithSecurityTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/processors/security/GridCommandHandlerSslWithSecurityTest.java @@ -19,6 +19,7 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.security.Permissions; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -28,8 +29,11 @@ import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.commandline.CommandHandler; import org.apache.ignite.internal.commandline.NoopConsole; +import org.apache.ignite.internal.processors.security.impl.TestSecurityData; import org.apache.ignite.internal.processors.security.impl.TestSecurityPluginProvider; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.ListeningTestLogger; +import org.apache.ignite.testframework.LogListener; import org.apache.ignite.util.GridCommandHandlerFactoryAbstractTest; import org.junit.Assume; import org.junit.Test; @@ -46,12 +50,18 @@ * Command line handler test with SSL and security. */ public class GridCommandHandlerSslWithSecurityTest extends GridCommandHandlerFactoryAbstractTest { + /** Login of an operator other than the node, so that the log names the operator and not the node. */ + private static final String OPERATOR = "sslOperator"; + /** Login. */ private final String login = "testUsr"; /** Password. */ private final String pwd = "testPwd"; + /** Captures reload messages from the node. */ + private final ListeningTestLogger nodeLog = new ListeningTestLogger(log); + /** System out. */ protected static PrintStream sysOut; @@ -98,8 +108,10 @@ protected void injectTestSystemOut() { /** {@inheritDoc} */ @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName) - .setPluginProviders(new TestSecurityPluginProvider(login, pwd, ALL_PERMISSIONS, null, false)) - .setSslContextFactory(sslTrustedFactory("node01", "trustone")); + .setPluginProviders(new TestSecurityPluginProvider(login, pwd, ALL_PERMISSIONS, null, false, + new TestSecurityData(OPERATOR, pwd, ALL_PERMISSIONS, new Permissions()))) + .setSslContextFactory(sslTrustedFactory("node01", "trustone")) + .setGridLogger(nodeLog); if (commandHandler.equals(CLI_CMD_HND)) { cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration() @@ -201,6 +213,25 @@ public void testConnector() throws Exception { assertContains(log, testOutput, "--truststore-password *****"); } + /** The node logs the login of whoever ran the reload. */ + @Test + public void testReloadLogsLogin() throws Exception { + IgniteEx crd = startGrid(); + + LogListener logged = LogListener.matches("initiator=management command, originNodeId=" + crd.localNode().id() + + ", login=" + OPERATOR + ", address=").build(); + + nodeLog.registerListener(logged); + + int exitCode = newCommandHandler().execute(Arrays.asList("--ssl", "reload", "--yes", + "--user", OPERATOR, "--password", pwd, + "--keystore", keyStorePath("thinClient"), "--keystore-password", keyStorePassword(), + "--truststore", keyStorePath("trusttwo"), "--truststore-password", keyStorePassword())); + + assertEquals(EXIT_CODE_OK, exitCode); + assertTrue("The reload log must identify the operator", logged.check()); + } + /** * Verify that the command work correctly when request starts with the --password argument * without value that invoke console password input for user, and that it is requested only once. diff --git a/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java b/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java index 6528d919f6105..d245014dd9290 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java @@ -36,6 +36,7 @@ import org.apache.ignite.util.GridCommandHandlerLegacyClientTest; import org.apache.ignite.util.GridCommandHandlerManagementPoolTest; import org.apache.ignite.util.GridCommandHandlerMetadataTest; +import org.apache.ignite.util.GridCommandHandlerSslReloadTest; import org.apache.ignite.util.GridCommandHandlerSslTest; import org.apache.ignite.util.GridCommandHandlerTest; import org.apache.ignite.util.GridCommandHandlerWalTest; @@ -60,6 +61,7 @@ GridCommandHandlerIncompatibleSslConfigTest.class, GridCommandHandlerClusterByClassTest.class, GridCommandHandlerClusterByClassWithSSLTest.class, + GridCommandHandlerSslReloadTest.class, GridCommandHandlerSslTest.class, GridPersistenceCommandsTest.class, diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java new file mode 100644 index 0000000000000..2a7ac6f584949 --- /dev/null +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -0,0 +1,121 @@ +/* + * 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. + */ + +package org.apache.ignite.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.management.ssl.SslReloadCommand; +import org.apache.ignite.ssl.SslContextFactory; +import org.apache.ignite.testframework.GridTestUtils; +import org.junit.Assume; +import org.junit.Test; + +import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; +import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_OK; +import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_UNEXPECTED_ERROR; +import static org.apache.ignite.ssl.SslTestUtils.node; +import static org.apache.ignite.ssl.SslTestUtils.place; +import static org.apache.ignite.ssl.SslTestUtils.servedSubject; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; + +/** Tests {@code --ssl reload} and {@code --ssl status} through the command line handler. */ +public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractTest { + /** Key store the nodes run on; replaced on disk to rotate the certificate. */ + private Path keyStore; + + /** {@inheritDoc} */ + @Override protected boolean sslEnabled() { + return true; + } + + /** {@inheritDoc} */ + @Override protected Factory sslFactory() { + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreFilePath(keyStore.toString()); + factory.setKeyStorePassword(GridTestUtils.keyStorePassword().toCharArray()); + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + + return factory; + } + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + Assume.assumeTrue(cliCommandHandler()); + + keyStore = Files.createTempFile("ignite-cli-ssl-reload-", ".jks"); + + place("node01", keyStore); + + super.beforeTest(); + + injectTestSystemOut(); + } + + /** {@inheritDoc} */ + @Override protected void afterTest() throws Exception { + stopAllGrids(); + + super.afterTest(); + + if (keyStore != null) + Files.deleteIfExists(keyStore); + } + + /** One command reloads every node, client ones included. Status shows the new certificate. A broken store fails the command. */ + @Test + public void testReloadAndStatus() throws Exception { + assertNotNull("The reload must ask for confirmation", new SslReloadCommand().confirmationPrompt(new NoArg())); + + List nodes = List.of(startGrid(0), startGrid(1), startClientGrid(2)); + + place("node02", keyStore); + + String reloadOutput = executeCommand(EXIT_CODE_OK, "--ssl", "reload"); + + for (IgniteEx grid : nodes) + assertContains(log, reloadOutput, + node(grid) + ": reloaded " + transports(grid) + "; loaded subject=CN=node02"); + + assertEquals("CN=node02", servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); + + String statusOutput = executeCommand(EXIT_CODE_OK, "--ssl", "status"); + + for (IgniteEx grid : nodes) + assertContains(log, statusOutput, node(grid) + ": " + transports(grid)); + + assertContains(log, statusOutput, "loaded subject=CN=node02"); + assertNotContains(log, statusOutput, "subject=CN=node01"); + assertContains(log, statusOutput, "last reload succeeded at"); + + Files.write(keyStore, "not a key store".getBytes()); + + assertContains(log, executeCommand(EXIT_CODE_UNEXPECTED_ERROR, "--ssl", "reload"), "Failed to initialize key store"); + } + + /** @return Transports sharing the node's factory. Client nodes do not run binary REST. */ + private static String transports(IgniteEx node) { + return (node.localNode().isClient() ? "" : "binary REST, ") + "client connector, communication, discovery"; + } +} diff --git a/modules/core/pom.xml b/modules/core/pom.xml index bbdf058dea191..47fd6cc45de8b 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -158,6 +158,13 @@ test + + org.bouncycastle + bcpkix-jdk18on + 1.86 + test + + org.eclipse.jetty.ee11 jetty-ee11-servlets diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java index ebc321f96064b..8cb0ab69e1111 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java @@ -77,6 +77,7 @@ import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; +import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.suggestions.GridPerformanceSuggestions; import org.apache.ignite.internal.thread.context.DistributedOperationContext; import org.apache.ignite.internal.util.IgniteExceptionRegistry; @@ -626,6 +627,11 @@ public interface GridKernalContext extends Iterable { */ public GridInternalSubscriptionProcessor internalSubscriptionProcessor(); + /** + * @return Registry of SSL contexts shared by the node transports. + */ + public SslContextRegistry sslContexts(); + /** * @return Default uncaught exception handler used by thread pools. */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java index 773efe5a99d96..1275c19c9ed85 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java @@ -99,6 +99,7 @@ import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; +import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.suggestions.GridPerformanceSuggestions; import org.apache.ignite.internal.thread.context.DistributedOperationContext; import org.apache.ignite.internal.thread.pool.IgniteForkJoinPool; @@ -393,6 +394,9 @@ public class GridKernalContextImpl implements GridKernalContext, Externalizable /** */ private GridInternalSubscriptionProcessor internalSubscriptionProc; + /** */ + private SslContextRegistry sslCtxs; + /** Failure processor. */ private FailureProcessor failureProc; @@ -576,6 +580,8 @@ else if (comp instanceof PdsFoldersResolver) pdsFolderRslvr = (PdsFoldersResolver)comp; else if (comp instanceof GridInternalSubscriptionProcessor) internalSubscriptionProc = (GridInternalSubscriptionProcessor)comp; + else if (comp instanceof SslContextRegistry) + sslCtxs = (SslContextRegistry)comp; else if (comp instanceof IgniteSecurity) security = (IgniteSecurity)comp; else if (comp instanceof CompressionProcessor) @@ -1030,6 +1036,11 @@ protected Object readResolve() throws ObjectStreamException { return internalSubscriptionProc; } + /** {@inheritDoc} */ + @Override public SslContextRegistry sslContexts() { + return sslCtxs; + } + /** * @param disconnected Disconnected flag. */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java index 5d11403186f4e..b2ef870f6a8ea 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java @@ -169,6 +169,7 @@ import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; import org.apache.ignite.internal.processors.tracing.configuration.NoopTracingConfigurationManager; +import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.suggestions.GridPerformanceSuggestions; import org.apache.ignite.internal.suggestions.JvmConfigurationSuggestions; import org.apache.ignite.internal.suggestions.OsConfigurationSuggestions; @@ -936,6 +937,8 @@ public void start( startProcessor(new GridInternalSubscriptionProcessor(ctx)); + startProcessor(new SslContextRegistry(ctx)); + ClusterProcessor clusterProc = new ClusterProcessor(ctx); startProcessor(clusterProc); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java index cfe4fe6deb8f5..c69bdb7b01f99 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java @@ -38,6 +38,7 @@ import org.apache.ignite.internal.management.persistence.PersistenceCommand; import org.apache.ignite.internal.management.property.PropertyCommand; import org.apache.ignite.internal.management.snapshot.SnapshotCommand; +import org.apache.ignite.internal.management.ssl.SslCommand; import org.apache.ignite.internal.management.tx.TxCommand; import org.apache.ignite.internal.management.wal.WalCommand; import org.apache.ignite.internal.util.typedef.internal.U; @@ -77,7 +78,8 @@ public IgniteCommandRegistry() { new CdcCommand(), new ConsistencyCommand(), new EventCommand(), - new IoTestCommand() + new IoTestCommand(), + new SslCommand() ); U.loadService(CommandsProvider.class).forEach(p -> p.commands().forEach(this::register)); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslCommand.java new file mode 100644 index 0000000000000..3ab76998927b4 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslCommand.java @@ -0,0 +1,41 @@ +/* + * 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. + */ +package org.apache.ignite.internal.management.ssl; + +import java.util.Collection; +import java.util.function.Consumer; +import org.apache.ignite.cluster.ClusterNode; +import org.apache.ignite.internal.management.api.ComputeCommand; +import org.apache.ignite.internal.management.api.NoArg; + +/** Base class for {@code --ssl} commands that run on all cluster nodes. */ +public abstract class AbstractSslCommand implements ComputeCommand { + /** {@inheritDoc} */ + @Override public Class argClass() { + return NoArg.class; + } + + /** {@inheritDoc} */ + @Override public Collection nodes(Collection nodes, NoArg arg) { + return nodes; + } + + /** {@inheritDoc} */ + @Override public void printResult(NoArg arg, String res, Consumer printer) { + printer.accept(res); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java new file mode 100644 index 0000000000000..e90153233a596 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java @@ -0,0 +1,73 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; +import org.apache.ignite.IgniteException; +import org.apache.ignite.cluster.ClusterNode; +import org.apache.ignite.compute.ComputeJobResult; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.visor.VisorMultiNodeTask; +import org.apache.ignite.lang.IgniteBiTuple; + +import static org.apache.ignite.internal.ssl.SslCertificates.reason; + +/** + * Collects {@code --ssl} command results from all mapped nodes, listing failures first. + * Each job returns a success flag and a report. If any job reports a failure or throws an exception, the task fails with + * a report containing both failed and successful results. + */ +public abstract class AbstractSslTask extends VisorMultiNodeTask> { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** + * @param node Node. + * @return Node UUID and consistent ID for command output. + */ + static String node(ClusterNode node) { + return node.id() + " [consistentId=" + node.consistentId() + ']'; + } + + /** {@inheritDoc} */ + @Override protected String reduce0(List results) throws IgniteException { + List failed = new ArrayList<>(); + List succeeded = new ArrayList<>(); + + for (ComputeJobResult jobRes : results) { + IgniteException e = jobRes.getException(); + + if (e != null) + failed.add(node(jobRes.getNode()) + ": " + reason(e)); + else { + IgniteBiTuple rep = jobRes.getData(); + + (rep.get1() ? succeeded : failed).add(rep.get2()); + } + } + + if (failed.isEmpty()) + return String.join("\n", succeeded) + '\n'; + + throw new IgniteException("Failed on " + failed.size() + " node(s):\n" + String.join("\n", failed) + '\n' + + (succeeded.isEmpty() ? "" : "\nSucceeded on " + succeeded.size() + " node(s):\n" + String.join("\n", succeeded) + '\n')); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java new file mode 100644 index 0000000000000..449ba793efbad --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java @@ -0,0 +1,29 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import org.apache.ignite.internal.management.api.CommandRegistryImpl; +import org.apache.ignite.internal.management.api.NoArg; + +/** SSL features command. */ +public class SslCommand extends CommandRegistryImpl { + /** */ + public SslCommand() { + super(new SslReloadCommand(), new SslStatusCommand()); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java new file mode 100644 index 0000000000000..83bc26535db27 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java @@ -0,0 +1,38 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import org.apache.ignite.internal.management.api.NoArg; + +/** */ +public class SslReloadCommand extends AbstractSslCommand { + /** {@inheritDoc} */ + @Override public String description() { + return "Reload TLS certificates on all cluster nodes from their SSL context factories"; + } + + /** {@inheritDoc} */ + @Override public Class taskClass() { + return SslReloadTask.class; + } + + /** {@inheritDoc} */ + @Override public String confirmationPrompt(NoArg arg) { + return "Warning: the command will reload TLS certificates on all cluster nodes."; + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java new file mode 100644 index 0000000000000..e25af2cc1f1c9 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -0,0 +1,126 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.processors.security.IgniteSecurity; +import org.apache.ignite.internal.processors.task.GridInternal; +import org.apache.ignite.internal.ssl.SslContextProvider; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.internal.visor.VisorJob; +import org.apache.ignite.lang.IgniteBiTuple; +import org.apache.ignite.plugin.security.SecuritySubject; + +import static org.apache.ignite.internal.ssl.SslCertificates.describe; +import static org.apache.ignite.internal.ssl.SslCertificates.reason; + +/** Reloads TLS certificates on every mapped node. */ +@GridInternal +public class SslReloadTask extends AbstractSslTask { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected VisorJob> job(NoArg arg) { + return new SslReloadJob(arg, debug, ignite.localNode().id()); + } + + /** */ + private static class SslReloadJob extends VisorJob> { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** Node that received the management command. */ + private final UUID originNodeId; + + /** + * @param arg Argument. + * @param debug Debug flag. + * @param originNodeId Node that received the management command. + */ + private SslReloadJob(NoArg arg, boolean debug, UUID originNodeId) { + super(arg, debug); + + this.originNodeId = originNodeId; + } + + /** {@inheritDoc} */ + @Override protected IgniteBiTuple run(NoArg arg) throws IgniteException { + String id = node(ignite.localNode()); + + Collection providers = ignite.context().sslContexts().providers(); + + if (providers.isEmpty()) + return new IgniteBiTuple<>(true, id + ": no SSL context factory is configured"); + + String initiator = initiator(); + + List lines = new ArrayList<>(); + + boolean failed = false; + + for (SslContextProvider provider : providers) { + String transports = provider.transports(); + + try { + String desc = describe(provider.reload()); + + U.log(ignite.log(), "TLS certificates reloaded [transports=" + transports + (desc.isEmpty() ? "" : ", " + desc) + + ", initiator=" + initiator + ']'); + + lines.add(id + ": reloaded " + transports + (desc.isEmpty() ? "" : "; loaded " + desc)); + } + catch (Exception e) { + failed = true; + + String reason = reason(e); + + lines.add(id + ": failed on " + transports + " (" + reason + ')'); + + ignite.log().warning("Failed to reload TLS certificates, the ones in use stay [transports=" + transports + + ", initiator=" + initiator + ", reason=" + reason + ']', e); + } + } + + return new IgniteBiTuple<>(!failed, String.join("\n", lines)); + } + + /** @return Reload initiator details for the node log. */ + private String initiator() { + IgniteSecurity security = ignite.context().security(); + + String res = "management command, originNodeId=" + originNodeId; + + if (security.enabled()) { + SecuritySubject subj = security.securityContext().subject(); + + res += ", login=" + subj.login() + ", address=" + subj.address(); + } + + return res; + } + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusCommand.java new file mode 100644 index 0000000000000..e42874bce83aa --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusCommand.java @@ -0,0 +1,31 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +/** */ +public class SslStatusCommand extends AbstractSslCommand { + /** {@inheritDoc} */ + @Override public String description() { + return "Show the TLS certificates of all cluster nodes and the results of their last reloads"; + } + + /** {@inheritDoc} */ + @Override public Class taskClass() { + return SslStatusTask.class; + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java new file mode 100644 index 0000000000000..9ebc1ca910735 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -0,0 +1,107 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.io.Serial; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.processors.task.GridInternal; +import org.apache.ignite.internal.ssl.SslContextProvider; +import org.apache.ignite.internal.visor.VisorJob; +import org.apache.ignite.lang.IgniteBiTuple; + +import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; +import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; +import static org.apache.ignite.internal.ssl.SslCertificates.describe; +import static org.apache.ignite.internal.ssl.SslCertificates.invalidAt; + +/** Reports loaded TLS certificate metadata; a known chain that is not valid now fails the command. */ +@GridInternal +public class SslStatusTask extends AbstractSslTask { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected VisorJob> job(NoArg arg) { + return new SslStatusJob(arg, debug); + } + + /** */ + private static class SslStatusJob extends VisorJob> { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** */ + private SslStatusJob(NoArg arg, boolean debug) { + super(arg, debug); + } + + /** {@inheritDoc} */ + @Override protected IgniteBiTuple run(NoArg arg) throws IgniteException { + String id = node(ignite.localNode()); + + Collection providers = ignite.context().sslContexts().providers(); + + if (providers.isEmpty()) + return new IgniteBiTuple<>(true, id + ": no SSL context factory is configured"); + + List lines = new ArrayList<>(); + + boolean invalid = false; + + for (SslContextProvider provider : providers) { + lines.add(id + ": " + provider.transports()); + + X509Certificate[] chain = provider.certificateChain(); + + if (chain == null) + lines.add(DOUBLE_INDENT + "certificate metadata is unavailable"); + else { + long chainNotAfter = chainNotAfter(chain); + + lines.add(DOUBLE_INDENT + "loaded " + describe(chain[0]) + + (chainNotAfter < chain[0].getNotAfter().getTime() ? ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) : "")); + + if (invalidAt(chain, System.currentTimeMillis()) != null) { + invalid = true; + + lines.add(DOUBLE_INDENT + "PROBLEM: the loaded certificate is not valid now"); + } + } + + SslContextProvider.ReloadOutcome outcome = provider.reloadOutcome(); + + if (outcome.failures() > 0) { + lines.add(DOUBLE_INDENT + "last reload failed " + outcome.failures() + " time(s) in a row, the last at " + + Instant.ofEpochMilli(outcome.failureTime()) + ": " + outcome.failureReason()); + } + else if (outcome.successTime() > 0) + lines.add(DOUBLE_INDENT + "last reload succeeded at " + Instant.ofEpochMilli(outcome.successTime())); + } + + return new IgniteBiTuple<>(!invalid, String.join("\n", lines)); + } + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java index 8f11ca68b96fd..8e4ea03464820 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java @@ -101,6 +101,7 @@ import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; +import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.suggestions.GridPerformanceSuggestions; import org.apache.ignite.internal.thread.context.DistributedOperationContext; import org.apache.ignite.internal.util.IgniteExceptionRegistry; @@ -700,6 +701,11 @@ private void setField(IgniteEx kernal, String name, Object val) throws NoSuchFie return null; } + /** {@inheritDoc} */ + @Override public SslContextRegistry sslContexts() { + return null; + } + /** {@inheritDoc} */ @Override public Thread.UncaughtExceptionHandler uncaughtExceptionHandler() { return null; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java index 808437eb1719a..19571d1c80015 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java @@ -80,6 +80,7 @@ import static org.apache.ignite.internal.processors.odbc.ClientListenerNioListener.JDBC_CLIENT; import static org.apache.ignite.internal.processors.odbc.ClientListenerNioListener.ODBC_CLIENT; import static org.apache.ignite.internal.processors.odbc.ClientListenerNioListener.THIN_CLIENT; +import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; /** * Client connector processor. @@ -494,7 +495,7 @@ else if (connCtx.managementClient()) { throw new IgniteCheckedException("Failed to create client listener " + "(SSL is enabled but factory is null). Check the ClientConnectorConfiguration"); - GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory.create(), + GridNioSslFilter sslFilter = U.sslFilter(ctx.sslContexts().register(sslCtxFactory, CLIENT_CONNECTOR), true, ByteOrder.nativeOrder(), log, ctx.metric().registry(CLIENT_CONNECTOR_METRICS)); sslFilter.directMode(true); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index b0d3502b25027..96327537f86a9 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -51,6 +51,7 @@ import org.jetbrains.annotations.Nullable; import static org.apache.ignite.internal.processors.metric.impl.MetricUtils.metricName; +import static org.apache.ignite.internal.ssl.SslContextRegistry.BINARY_REST; /** * TCP binary protocol implementation. @@ -102,9 +103,9 @@ public GridTcpRestProtocol(GridKernalContext ctx) { throw new SSLException("SSL is enabled, but SSL context factory is not specified."); if (factory != null) - sslCtx = factory.create(); + sslCtx = ctx.sslContexts().register(factory, BINARY_REST); else - sslCtx = igniteFactory.create(); + sslCtx = ctx.sslContexts().register(igniteFactory, BINARY_REST); } int startPort = cfg.getPort(); int portRange = cfg.getPortRange(); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java new file mode 100644 index 0000000000000..d80377dd4bcc8 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java @@ -0,0 +1,93 @@ +/* + * 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. + */ +package org.apache.ignite.internal.ssl; + +import java.security.SecureRandom; +import java.util.function.Supplier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLContextSpi; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +/** Delegates to the current SSL context so that new connections use reloaded certificates. */ +class CurrentSslContext extends SSLContext { + /** @param ctx Supplier of the context currently in use. */ + CurrentSslContext(Supplier ctx) { + super(new Spi(ctx), ctx.get().getProvider(), ctx.get().getProtocol()); + } + + /** */ + private static class Spi extends SSLContextSpi { + /** */ + private final Supplier ctx; + + /** @param ctx Supplier of the context currently in use. */ + private Spi(Supplier ctx) { + this.ctx = ctx; + } + + /** {@inheritDoc} */ + @Override protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom rnd) { + throw new UnsupportedOperationException("The SSL context is built by its factory"); + } + + /** {@inheritDoc} */ + @Override protected SSLSocketFactory engineGetSocketFactory() { + return ctx.get().getSocketFactory(); + } + + /** {@inheritDoc} */ + @Override protected SSLServerSocketFactory engineGetServerSocketFactory() { + return ctx.get().getServerSocketFactory(); + } + + /** {@inheritDoc} */ + @Override protected SSLEngine engineCreateSSLEngine() { + return ctx.get().createSSLEngine(); + } + + /** {@inheritDoc} */ + @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { + return ctx.get().createSSLEngine(host, port); + } + + /** {@inheritDoc} */ + @Override protected SSLSessionContext engineGetServerSessionContext() { + return ctx.get().getServerSessionContext(); + } + + /** {@inheritDoc} */ + @Override protected SSLSessionContext engineGetClientSessionContext() { + return ctx.get().getClientSessionContext(); + } + + /** {@inheritDoc} */ + @Override protected SSLParameters engineGetDefaultSSLParameters() { + return ctx.get().getDefaultSSLParameters(); + } + + /** {@inheritDoc} */ + @Override protected SSLParameters engineGetSupportedSSLParameters() { + return ctx.get().getSupportedSSLParameters(); + } + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java new file mode 100644 index 0000000000000..63cfb031386c4 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -0,0 +1,91 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.security.cert.X509Certificate; +import org.apache.ignite.internal.util.typedef.X; +import org.jetbrains.annotations.Nullable; + +/** Certificate dates and descriptions used by SSL reload and status. */ +public class SslCertificates { + /** */ + private SslCertificates() { + // No-op. + } + + /** + * @param chain Chain, own certificate first. + * @return Earliest expiry in the chain, in milliseconds since the epoch. + */ + public static long chainNotAfter(X509Certificate[] chain) { + long res = Long.MAX_VALUE; + + for (X509Certificate cert : chain) + res = Math.min(res, cert.getNotAfter().getTime()); + + return res; + } + + /** + * @param chain Chain. + * @param time Time to check, in milliseconds since the epoch. + * @return First certificate of the chain that is not valid at that time, {@code null} if all are. + */ + public static @Nullable X509Certificate invalidAt(X509Certificate[] chain, long time) { + for (X509Certificate cert : chain) { + if (time < cert.getNotBefore().getTime() || time > cert.getNotAfter().getTime()) + return cert; + } + + return null; + } + + /** + * @param cert Certificate, {@code null} if unknown. + * @return Subject, issuer, serial and validity of the certificate; an empty string if it is unknown. + */ + public static String describe(@Nullable X509Certificate cert) { + return cert == null ? "" : "subject=" + cert.getSubjectX500Principal() + ", issuer=" + cert.getIssuerX500Principal() + + ", serial=" + cert.getSerialNumber().toString(16) + ", notBefore=" + cert.getNotBefore().toInstant() + + ", notAfter=" + cert.getNotAfter().toInstant(); + } + + /** + * @param e Failure. + * @return Non-repeated messages from the exception and its causes, or {@link Throwable#toString()} if none are available. + */ + public static String reason(Throwable e) { + StringBuilder sb = new StringBuilder(); + + for (Throwable t : X.getThrowableList(e)) { + String msg = t.getMessage(); + + boolean wrapsCauseOnly = t.getCause() != null && t.getCause().toString().equals(msg); + + if (msg == null || msg.isEmpty() || wrapsCauseOnly || sb.indexOf(msg) >= 0) + continue; + + if (!sb.isEmpty()) + sb.append(": "); + + sb.append(msg); + } + + return !sb.isEmpty() ? sb.toString() : e.toString(); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java new file mode 100644 index 0000000000000..256995ae0c99a --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -0,0 +1,197 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.ConcurrentSkipListSet; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.SSLContextWrapper; +import org.jetbrains.annotations.Nullable; + +import static org.apache.ignite.internal.ssl.SslCertificates.describe; + +/** Owns one factory's context and reloads on this node; transports using that factory share its context. */ +public class SslContextProvider { + /** */ + private final Factory factory; + + /** */ + private final Set transports = new ConcurrentSkipListSet<>(); + + /** Context in use. */ + private volatile SSLContext ctx; + + /** Stable context handed to every transport using this factory. */ + private final SSLContext sharedCtx; + + /** Snapshot of the last successful reload and subsequent failures. */ + private volatile ReloadOutcome outcome = new ReloadOutcome(0, 0, null, 0); + + /** @param factory Factory to build the context with. */ + SslContextProvider(Factory factory) { + this.factory = factory; + + ctx = factory.create(); + ctx.createSSLEngine(); + + sharedCtx = new CurrentSslContext(this::context); + } + + /** @return Factory the context is built with. */ + Factory factory() { + return factory; + } + + /** @return Context to open the next connection with. */ + SSLContext context() { + return ctx; + } + + /** @return Stable context shared by every transport configured with this factory. */ + SSLContext sharedContext() { + return sharedCtx; + } + + /** @param transport Transport the context is handed to. */ + void addTransport(String transport) { + transports.add(transport); + } + + /** @return Comma-separated transport names used in command output and node logs. */ + public String transports() { + return String.join(", ", transports); + } + + /** @return Chain loaded with the context in use, own certificate first, or {@code null} if it is unknown. */ + public @Nullable X509Certificate[] certificateChain() { + return certificateChain(ctx); + } + + /** @return Certificate loaded with the context in use, or {@code null} if it is unknown. */ + public @Nullable X509Certificate certificate() { + X509Certificate[] chain = certificateChain(ctx); + + return chain == null ? null : chain[0]; + } + + /** + * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no + * private key. If a certificate chain is supplied, every certificate must be within its validity period. + * The administrator must ensure that peers trust the new certificates and can connect with the new context. + * + * @return Certificate installed by this reload, or {@code null} if its metadata is unavailable. + * @throws IgniteCheckedException If the new context fails a check or the factory returns the context in use. On any failure, + * including a factory exception, the current context is retained. + */ + public synchronized @Nullable X509Certificate reload() throws IgniteCheckedException { + try { + SSLContext rebuilt = build(); + + if (rebuilt == ctx) + throw new IgniteCheckedException("The SSL context factory returns the context in use, so the node gets no new " + + "certificates from it"); + + X509Certificate[] next = certificateChain(rebuilt); + + check(next); + + ctx = rebuilt; + } + catch (Throwable e) { + onFailure(e); + + throw e; + } + + onSuccess(); + + return certificate(); + } + + /** + * @param ctx Context just built by the factory. + * @return Chain supplied with that context, or {@code null} if the factory supplies no metadata. + */ + static @Nullable X509Certificate[] certificateChain(SSLContext ctx) { + return ctx instanceof SSLContextWrapper wrapper ? wrapper.getCertificateChain() : null; + } + + /** + * @return Context built anew, or the one in use if the factory caches it. + * @throws IgniteCheckedException If the factory reports that the loaded material has no private key. + */ + private SSLContext build() throws IgniteCheckedException { + SSLContext rebuilt = factory instanceof AbstractSslContextFactory f ? f.createNew() : factory.create(); + + if (rebuilt instanceof SSLContextWrapper wrapper && wrapper.isPrivateKeyMissing()) + throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + + rebuilt.createSSLEngine(); + + return rebuilt; + } + + /** + * @param next Chain loaded with the new context, {@code null} if it is unknown. + * @throws IgniteCheckedException If a certificate is outside its validity period. + */ + private void check(@Nullable X509Certificate[] next) throws IgniteCheckedException { + long now = System.currentTimeMillis(); + + X509Certificate invalid = next == null ? null : SslCertificates.invalidAt(next, now); + + if (invalid != null) { + throw new IgniteCheckedException("The new certificate chain is not valid now [" + describe(invalid) + ", now=" + + Instant.ofEpochMilli(now) + ']'); + } + } + + /** Records a successful reload. */ + private void onSuccess() { + outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); + } + + /** @param e Why the reload failed. */ + private void onFailure(Throwable e) { + ReloadOutcome o = outcome; + + outcome = new ReloadOutcome(o.successTime(), System.currentTimeMillis(), SslCertificates.reason(e), o.failures() + 1); + } + + /** @return Snapshot of the last successful reload and subsequent failures. */ + public ReloadOutcome reloadOutcome() { + return outcome; + } + + /** + * Snapshot of the last successful reload and subsequent failures. + * + * @param successTime Time of the last successful reload, {@code 0} if there was none. + * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. + * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. + * @param failures Failed reloads in a row since the last successful one. + */ + public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { + // No-op. + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java new file mode 100644 index 0000000000000..7741509f8a2c8 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -0,0 +1,90 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.processors.GridProcessorAdapter; +import org.apache.ignite.internal.util.typedef.internal.U; + +/** SSL contexts of a node: one provider per configured factory, shared by its transports. */ +public class SslContextRegistry extends GridProcessorAdapter { + /** */ + public static final String COMMUNICATION = "communication"; + + /** */ + public static final String DISCOVERY = "discovery"; + + /** */ + public static final String CLIENT_CONNECTOR = "client connector"; + + /** */ + public static final String BINARY_REST = "binary REST"; + + /** */ + private final Collection providers = new CopyOnWriteArrayList<>(); + + /** @param ctx Kernal context. */ + public SslContextRegistry(GridKernalContext ctx) { + super(ctx); + } + + /** + * @param factory Factory the transport is configured with. + * @param transport Transport, one of the names above. + * @return Context that delegates to the factory's current context. Transports configured with the same factory object share it, + * so reload updates the context for all of them. + */ + public synchronized SSLContext register(Factory factory, String transport) { + SslContextProvider provider = providers.stream().filter(p -> p.factory() == factory).findFirst().orElse(null); + + if (provider == null) { + provider = new SslContextProvider(factory); + provider.addTransport(transport); + warnIfExpired(provider); + + providers.add(provider); + } + else + provider.addTransport(transport); + + return provider.sharedContext(); + } + + /** @param provider Newly registered provider. */ + private void warnIfExpired(SslContextProvider provider) { + X509Certificate[] chain = provider.certificateChain(); + X509Certificate invalid = chain == null ? null : SslCertificates.invalidAt(chain, System.currentTimeMillis()); + + if (invalid != null) { + U.warn(log, "The TLS certificate chain in use is not valid now on " + + provider.transports() + "; put a valid certificate in place and run control.sh --ssl reload [" + + SslCertificates.describe(invalid) + ']'); + } + } + + /** @return One provider per factory object, in registration order; sharing a factory never adds another provider. */ + public Collection providers() { + return Collections.unmodifiableCollection(providers); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java index d4383c096cefb..8582915f28bb1 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java @@ -19,6 +19,7 @@ import java.util.UUID; import java.util.function.Supplier; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteLogger; @@ -29,6 +30,9 @@ import org.apache.ignite.spi.IgniteSpiContext; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.communication.tcp.messages.NodeIdMessage; +import org.jetbrains.annotations.Nullable; + +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; /** * The role of this is aggregate logic of cluster states. @@ -57,6 +61,9 @@ public class ClusterStateProvider { /** Ignite ex supplier. */ private final Supplier igniteExSupplier; + /** SSL context used for new communication connections, {@code null} if SSL is disabled. */ + private final @Nullable SSLContext sslCtx; + /** * @param ignite Ignite. * @param locNodeSupplier Local node supplier. @@ -82,6 +89,9 @@ public ClusterStateProvider( this.spiCtxWithoutLatchSupplier = spiCtxWithoutLatchSupplier; this.log = log; this.igniteExSupplier = igniteExSupplier; + + sslCtx = isSslEnabled() ? ((IgniteEx)ignite).context().sslContexts() + .register(ignite.configuration().getSslContextFactory(), COMMUNICATION) : null; } /** @@ -112,7 +122,12 @@ public boolean isSslEnabled() { * @return {@link SSLEngine} for ssl connections. */ public SSLEngine createSSLEngine() { - return ignite.configuration().getSslContextFactory().create().createSSLEngine(); + return sslCtx.createSSLEngine(); + } + + /** @return SSL context used for new communication connections, {@code null} if SSL is disabled. */ + public @Nullable SSLContext sslContext() { + return sslCtx; } /** diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index 9368f9f8a83db..c6039ae6741d1 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -919,7 +919,7 @@ private IgniteMessageFactory get() { if (stateProvider.isSslEnabled()) { GridNioSslFilter sslFilter = U.sslFilter( - igniteCfg.getSslContextFactory().create(), + stateProvider.sslContext(), true, ByteOrder.LITTLE_ENDIAN, log, diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index 9a87615ffbbe0..5e20f75b774f7 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -63,7 +63,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSocket; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteCheckedException; @@ -6295,16 +6294,7 @@ private class TcpServer extends GridWorker { for (port = spi.locPort; port <= lastPort; port++) { try { - if (spi.isSslEnabled()) { - SSLServerSocket sslSock = (SSLServerSocket)spi.sslSrvSockFactory - .createServerSocket(port, 0, spi.locHost); - - sslSock.setNeedClientAuth(true); - - srvrSock = sslSock; - } - else - srvrSock = new ServerSocket(port, 0, spi.locHost); + srvrSock = new ServerSocket(port, 0, spi.locHost); if (log.isInfoEnabled()) { log.info("Successfully bound to TCP port [port=" + port + @@ -6350,6 +6340,18 @@ private class TcpServer extends GridWorker { blockingSectionEnd(); } + try { + sock = spi.acceptedSocket(sock); + } + catch (IOException | IllegalArgumentException e) { + U.warn(log, "Failed to set up TLS on an accepted connection [rmtAddr=" + sock.getInetAddress() + + ", err=" + e + ']'); + + U.closeQuiet(sock); + + continue; + } + if (log.isInfoEnabled()) { log.info("TCP discovery accepted incoming connection " + "[rmtAddr=" + sock.getInetAddress() + ", rmtPort=" + sock.getPort() + ']'); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 1ec99c1b0382c..fb48018244a28 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -40,8 +40,7 @@ import java.util.regex.Pattern; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.SSLSocket; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; import org.apache.ignite.IgniteCheckedException; @@ -113,6 +112,7 @@ import static org.apache.ignite.IgniteSystemProperties.IGNITE_CONSISTENT_ID_BY_HOST_WITHOUT_PORT; import static org.apache.ignite.IgniteSystemProperties.getBoolean; import static org.apache.ignite.internal.managers.discovery.GridDiscoveryManager.DISCO_METRICS; +import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; /** * Discovery SPI implementation that uses TCP/IP for node discovery. @@ -416,11 +416,9 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery /** Node authenticator. */ protected DiscoverySpiNodeAuthenticator nodeAuth; - /** SSL server socket factory. */ - protected SSLServerSocketFactory sslSrvSockFactory; - - /** SSL socket factory. */ - protected SSLSocketFactory sslSockFactory; + /** SSL context used for new discovery connections, {@code null} if SSL is disabled. */ + @GridToStringExclude + private SSLContext sslCtx; /** SSL enable/disable flag. */ protected boolean sslEnable; @@ -1660,7 +1658,7 @@ Socket createSocket() throws IOException { try { if (isSslEnabled()) - sock = sslSockFactory.createSocket(); + sock = sslCtx.getSocketFactory().createSocket(); else sock = new Socket(); @@ -1678,6 +1676,27 @@ Socket createSocket() throws IOException { } } + /** + * @param sock Socket accepted by the server. + * @return Socket wrapped for server-side TLS using the current SSL context, or the original socket if SSL is disabled. + * @throws IOException If TLS setup fails. + */ + Socket acceptedSocket(Socket sock) throws IOException { + if (!isSslEnabled()) + return sock; + + // A null peer host can trigger reverse DNS lookup when jdk.tls.trustNameService=true. + // Use the numeric address to avoid delaying the accept loop. + SSLSocket sslSock = (SSLSocket)sslCtx.getSocketFactory() + .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); + + // Accepted discovery connections use server mode and require a peer certificate. + sslSock.setUseClientMode(false); + sslSock.setNeedClientAuth(true); + + return sslSock; + } + /** * Writes raw data to the session socket. * @@ -2154,10 +2173,7 @@ private void initializeImpl() { if (isSslEnabled()) { try { - SSLContext sslCtx = ignite.configuration().getSslContextFactory().create(); - - sslSockFactory = sslCtx.getSocketFactory(); - sslSrvSockFactory = sslCtx.getServerSocketFactory(); + sslCtx = ignite.context().sslContexts().register(ignite.configuration().getSslContextFactory(), DISCOVERY); } catch (IgniteException e) { throw new IgniteSpiException("Failed to create SSL context. SSL factory: " diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java new file mode 100644 index 0000000000000..4eb88b3cd098e --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -0,0 +1,393 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.io.Serial; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.jetbrains.annotations.Nullable; +import org.junit.Test; + +import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; +import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; +import static org.apache.ignite.ssl.SslTestUtils.reload; +import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; +import static org.apache.ignite.ssl.SslTestUtils.status; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; +import static org.junit.Assert.assertArrayEquals; + +/** Tests manual reload validation and certificate sources that supply optional metadata. */ +public class SslContextProviderTest extends GridCommonAbstractTest { + /** */ + private static final long MINUTE = 60_000L; + + /** */ + private static final long HOUR = 60 * MINUTE; + + /** Default certificate authority for the tests. */ + private TestCertificateAuthority ca; + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + ca = new TestCertificateAuthority("reloadca"); + } + + /** {@inheritDoc} */ + @Override protected void afterTest() { + stopAllGrids(); + } + + /** Changing a returned chain must not change the provider's certificate or chain. */ + @Test + public void testCertificateChainCannotChangeProvider() { + SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR))); + + X509Certificate[] chain = p.certificateChain(); + + assertNotNull(chain); + + X509Certificate[] expected = chain.clone(); + + chain[0] = null; + + assertSame(expected[0], p.certificate()); + assertArrayEquals(expected, p.certificateChain()); + } + + /** Nodes with different certificates from one authority keep communicating when only one node reloads. */ + @Test + public void testReloadWithDifferentCertificatesFromSameAuthority() throws Exception { + checkNodeRotation(ca); + } + + /** Nodes need to trust each other's authorities, not their own, when one node reloads its certificate. */ + @Test + public void testReloadWithDifferentAuthorities() throws Exception { + checkNodeRotation(new TestCertificateAuthority("peerca")); + } + + /** Reload installs the administrator's material; an actual TLS connection still rejects an authority the peer does not trust. */ + @Test + public void testReloadDoesNotBypassPeerTrust() throws Exception { + long now = System.currentTimeMillis(); + + TestCertificateAuthority peerCa = new TestCertificateAuthority("peerca"); + TestCertificateAuthority untrusted = new TestCertificateAuthority("untrustedca"); + AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - MINUTE, now + HOUR)); + KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + + Issuer issuer = issuer(keys::get, peerCa.trustStore()); + Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); + + IgniteEx g0 = startNode(0, issuer); + IgniteEx g1 = startNode(1, peerIssuer); + + X509Certificate peerCert = provider(g1).certificate(); + + assertEquals(provider(g0).certificate(), servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); + + keys.set(untrusted.issue("node0-new", now, now + 2 * HOUR)); + + assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); + assertTrue(provider(g0).reloadOutcome().successTime() > 0); + assertEquals("CN=untrustedca", provider(g0).certificate().getIssuerX500Principal().getName()); + assertEquals(peerCert, provider(g1).certificate()); + + assertThrowsWithCause(() -> servedCertificate(peerIssuer.createNew(), discoveryPort(g0)), SSLException.class); + } + + /** A factory with no certificate metadata can be reloaded, and status reports the missing metadata. */ + @Test + public void testUnknownCertificate() throws Exception { + Issuer issuer = new Issuer(this::twoKeys); + + IgniteEx g = startClientConnectorOn(issuer); + + SslContextProvider p = provider(g, CLIENT_CONNECTOR); + + assertNull(p.certificateChain()); + assertContains(log, status(g), "certificate metadata is unavailable"); + assertContains(log, reload(g), ": reloaded " + CLIENT_CONNECTOR); + } + + /** Replacing one key with several keys must not prevent manual reload. */ + @Test + public void testReloadCanLoseMetadata() throws Exception { + Issuer issuer = new Issuer(fresh(HOUR), this::twoKeys); + + SslContextProvider p = new SslContextProvider(issuer); + SSLContext inUse = p.context(); + + assertNotNull(p.certificateChain()); + + p.reload(); + + assertNotSame(inUse, p.context()); + assertNull(p.certificateChain()); + assertTrue(p.reloadOutcome().successTime() > 0); + assertEquals(0, p.reloadOutcome().failures()); + } + + /** A not yet valid candidate leaves the working context and its metadata in use. */ + @Test + public void testNotYetValidCertificateNotApplied() throws Exception { + long now = System.currentTimeMillis(); + Issuer issuer = new Issuer(fresh(HOUR), () -> ca.issue("future", now + HOUR, now + 2 * HOUR)); + SslContextProvider p = new SslContextProvider(issuer); + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + + GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "The new certificate chain is not valid now"); + + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + assertEquals(1, p.reloadOutcome().failures()); + } + + /** An unsupported protocol in a replacement context leaves the working context in use. */ + @Test + public void testUnsupportedProtocolNotApplied() { + Issuer issuer = new Issuer(fresh(HOUR)); + SslContextProvider p = new SslContextProvider(issuer); + SSLContext inUse = p.context(); + + issuer.setProtocols("SSLvDoesNotExist"); + + GridTestUtils.assertThrows(log, p::reload, IllegalArgumentException.class, "SSLvDoesNotExist"); + + assertSame(inUse, p.context()); + assertEquals(1, p.reloadOutcome().failures()); + } + + /** @return Store with two valid private keys and therefore no single chain for certificate metadata. */ + private KeyStore twoKeys() throws Exception { + long now = System.currentTimeMillis(); + KeyStore keys = ca.issue("node", now - MINUTE, now + HOUR); + KeyStore other = ca.issue("other", now - MINUTE, now + HOUR); + + keys.setKeyEntry("other", other.getKey("other", TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + other.getCertificateChain("other")); + + return keys; + } + + /** + * @param factory SSL context factory of the client connector. + * @return Started node whose client connector runs on the factory and whose other transports run without SSL. + */ + private IgniteEx startClientConnectorOn(AbstractSslContextFactory factory) throws Exception { + return startGrid(getTestIgniteInstanceName(0), cfg -> cfg + .setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) + .setUseIgniteSslContextFactory(false).setSslContextFactory(factory))); + } + + /** + * @param idx Node index. + * @param factory SSL context factory of the node. + * @return Started node. + */ + private IgniteEx startNode(int idx, AbstractSslContextFactory factory) throws Exception { + return startGrid(getTestIgniteInstanceName(idx), cfg -> cfg.setSslContextFactory(factory)); + } + + /** + * @param source Key store to use on each request. + * @param trust Authorities the node accepts. + * @return Configured issuer. + */ + private Issuer issuer(Callable source, KeyStore trust) { + Issuer issuer = new Issuer(source); + + issuer.trust = trust; + + return issuer; + } + + /** + * Rotates one node's certificate, then restarts each node to check that they load their current certificates and reconnect. + * + * @param peerCa Authority of the peer whose certificate stays unchanged. + */ + private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception { + long now = System.currentTimeMillis(); + + AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - HOUR, now + 9 * MINUTE)); + KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + Issuer issuer = issuer(keys::get, peerCa.trustStore()); + Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); + + IgniteEx g0 = startNode(0, issuer); + IgniteEx g1 = startNode(1, peerIssuer); + + X509Certificate peerCert = provider(g1).certificate(); + + assertFalse(provider(g0).certificate().equals(peerCert)); + assertComputeWorks(g0, g1); + + KeyStore replacement = ca.issue("node0-new", now, now + 2 * HOUR); + X509Certificate replacementCert = (X509Certificate)replacement.getCertificate("node0-new"); + + keys.set(replacement); + + assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); + + assertEquals(replacementCert, provider(g0).certificate()); + assertEquals(peerCert, provider(g1).certificate()); + assertEquals(0, provider(g1).reloadOutcome().successTime()); + assertEquals(replacementCert, servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); + assertEquals(peerCert, servedCertificate(issuer.createNew(), discoveryPort(g1))); + + assertComputeWorks(g0, g1); + + stopGrid(1); + + g1 = startNode(1, issuer(() -> peerKeys, ca.trustStore())); + + assertEquals(replacementCert, provider(g0).certificate()); + assertEquals(peerCert, provider(g1).certificate()); + assertComputeWorks(g0, g1); + + stopGrid(0); + + IgniteEx restarted = startNode(0, issuer(keys::get, peerCa.trustStore())); + + assertEquals(replacementCert, provider(restarted).certificate()); + assertEquals(replacementCert, servedCertificate(peerIssuer.createNew(), discoveryPort(restarted))); + assertEquals(peerCert, provider(g1).certificate()); + assertComputeWorks(restarted, g1); + } + + /** Runs a job from each node on its peer. */ + private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { + for (IgniteEx from : List.of(g0, g1)) { + IgniteEx to = from == g0 ? g1 : g0; + + assertEquals((Integer)1, from.compute(from.cluster().forNodeId(to.localNode().id())).withTimeout(10_000).call(() -> 1)); + } + } + + /** + * @param lifetime Lifetime of the certificate. + * @return Step that issues a certificate valid from the moment it is issued. + */ + private Callable fresh(long lifetime) { + return () -> { + long now = System.currentTimeMillis(); + + return ca.issue("node", now, now + lifetime); + }; + } + + /** + * @param g Node. + * @return SSL context provider used by communication. + */ + private static SslContextProvider provider(IgniteEx g) { + return provider(g, COMMUNICATION); + } + + /** + * @param g Node. + * @param transport Transport. + * @return SSL context provider used by the transport. + */ + private static SslContextProvider provider(IgniteEx g, String transport) { + for (SslContextProvider p : g.context().sslContexts().providers()) { + if (p.transports().contains(transport)) + return p; + } + + throw new AssertionError("Nothing serves " + transport); + } + + /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ + private class Issuer extends AbstractSslContextFactory { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** Answers to the requests in order, the last one repeated for the rest. */ + private final List> steps; + + /** Requests so far. */ + private int calls; + + /** Trust store for new contexts, or {@code null} to trust only the default test authority. */ + private volatile KeyStore trust; + + /** @param steps Answers to the requests in order, the last one repeated for the rest. */ + @SafeVarargs + private Issuer(Callable... steps) { + this.steps = List.of(steps); + } + + /** {@inheritDoc} */ + @Override protected void checkParameters() { + // No-op. + } + + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers() throws SSLException { + return createKeyMaterial().keyManagers(); + } + + /** {@inheritDoc} */ + @Override protected KeyMaterial createKeyMaterial() throws SSLException { + Callable step = steps.get(Math.min(calls++, steps.size() - 1)); + + try { + KeyStore store = step.call(); + + return KeyMaterial.fromKeyStore(TestCertificateAuthority.keyManagers(store), store); + } + catch (IgniteException e) { + throw e; + } + catch (Exception e) { + throw new SSLException(e); + } + } + + /** {@inheritDoc} */ + @Override protected TrustManager[] createTrustManagers() throws SSLException { + try { + return TestCertificateAuthority.trustManagers(trust != null ? trust : ca.trustStore()); + } + catch (Exception e) { + throw new SSLException(e); + } + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java new file mode 100644 index 0000000000000..f95f9689be3eb --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java @@ -0,0 +1,240 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.security.auth.x500.X500Principal; +import org.apache.ignite.ssl.SSLContextWrapper; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * Certificate authority for tests that need certificates valid for a chosen time, which the stores checked into the repository cannot give. + */ +class TestCertificateAuthority { + /** Password of every store made here. */ + static final char[] PWD = "123456".toCharArray(); + + /** */ + private static final SecureRandom RND = new SecureRandom(); + + /** */ + private final X500Principal name; + + /** */ + private final KeyPair keys; + + /** */ + private final X509Certificate cert; + + /** @param cn Common name of the authority. */ + TestCertificateAuthority(String cn) throws Exception { + name = new X500Principal("CN=" + cn); + keys = keyPair(); + + long now = System.currentTimeMillis(); + + cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); + } + + /** + * @param cn Common name of the certificate. + * @param notBefore Time it becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + KeyStore issue(String cn, long notBefore, long notAfter) throws Exception { + return issue(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); + } + + /** + * @param alias Alias for the private key and certificate chain. + * @param subject Subject of the certificate. + * @param pair Key pair of the certificate owner. + * @param notBefore Time it becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + KeyStore issue(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) throws Exception { + X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); + + KeyStore store = emptyStore(); + + store.setKeyEntry(alias, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); + + return store; + } + + /** @return Trust store with this authority alone. */ + KeyStore trustStore() throws Exception { + return trustStore(this); + } + + /** + * @param authorities Authorities to trust. + * @return Trust store with them. + */ + static KeyStore trustStore(TestCertificateAuthority... authorities) throws Exception { + KeyStore store = emptyStore(); + + for (TestCertificateAuthority a : authorities) + store.setCertificateEntry(a.name.getName(), a.cert); + + return store; + } + + /** + * @param keyStore Store with the certificate to present. + * @param trustStore Store with the authorities to trust. + * @return Context using the supplied key and trust stores, with certificate metadata attached. + */ + static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); + + return new SSLContextWrapper(ctx, null, certificateChain(keyStore)); + } + + /** + * @param keyStore Store with at most one private key. + * @return Its certificate chain, or {@code null} if the store has no private key. + */ + static X509Certificate[] certificateChain(KeyStore keyStore) throws Exception { + X509Certificate[] chain = null; + + for (String alias : Collections.list(keyStore.aliases())) { + if (!keyStore.isKeyEntry(alias)) + continue; + + if (chain != null) + throw new IllegalArgumentException("The test key store must contain at most one private key"); + + Certificate[] certs = keyStore.getCertificateChain(alias); + + chain = Arrays.copyOf(certs, certs.length, X509Certificate[].class); + } + + return chain; + } + + /** + * @param keyStore Store with the certificate to present. + * @return Key managers that present it. + */ + static KeyManager[] keyManagers(KeyStore keyStore) throws Exception { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + kmf.init(keyStore, PWD); + + return kmf.getKeyManagers(); + } + + /** + * @param trustStore Store with the authorities to trust. + * @return Trust managers that trust them. + */ + static TrustManager[] trustManagers(KeyStore trustStore) throws Exception { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + tmf.init(trustStore); + + return tmf.getTrustManagers(); + } + + /** + * @param store Store. + * @param path File to write or replace. + */ + static void save(KeyStore store, Path path) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + store.store(out, PWD); + } + } + + /** + * @param subj Subject. + * @param subjKeys Key pair of the subject. + * @param notBefore Time the certificate becomes valid. + * @param notAfter Time it expires. + * @param ca Whether the certificate is the authority's own. + * @return Certificate signed by this authority. + */ + private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, + new BigInteger(Long.SIZE, RND).add(BigInteger.ONE), + new Date(notBefore), + new Date(notAfter), + subj, + subjKeys.getPublic()); + + if (ca) + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + else + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()); + + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + /** */ + private static KeyPair keyPair() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + + gen.initialize(new ECGenParameterSpec("secp256r1")); + + return gen.generateKeyPair(); + } + + /** */ + private static KeyStore emptyStore() throws Exception { + KeyStore store = KeyStore.getInstance("PKCS12"); + + store.load(null, PWD); + + return store; + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java index 70a4312aa92e0..399ab0e62372a 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java @@ -181,6 +181,14 @@ public void testNonExistentProtocol() throws Exception { ); } + /** A node with a protocol the JVM does not know must not start, even alone. */ + @Test + public void testNonExistentProtocolRefusedAtStart() { + protocols = new String[] {"SSLvDoesNotExist"}; + + GridTestUtils.assertThrows(log, () -> startGrid(0), IgniteCheckedException.class, "SSLvDoesNotExist"); + } + /** * @throws Exception If failed. */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java new file mode 100644 index 0000000000000..28ffe214f4e4b --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -0,0 +1,621 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.Ignite; +import org.apache.ignite.IgniteException; +import org.apache.ignite.Ignition; +import org.apache.ignite.client.IgniteClient; +import org.apache.ignite.client.SslMode; +import org.apache.ignite.compute.ComputeJobResult; +import org.apache.ignite.configuration.ClientConfiguration; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.configuration.ConnectorConfiguration; +import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.management.ssl.SslReloadTask; +import org.apache.ignite.internal.ssl.SslCertificates; +import org.apache.ignite.internal.ssl.SslContextProvider; +import org.apache.ignite.internal.ssl.SslContextRegistry; +import org.apache.ignite.internal.util.typedef.G; +import org.apache.ignite.internal.util.typedef.X; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; +import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; +import org.apache.ignite.testframework.GridTestNode; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.ListeningTestLogger; +import org.apache.ignite.testframework.LogListener; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; +import org.mockito.Mockito; + +import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; +import static org.apache.ignite.internal.ssl.SslContextRegistry.BINARY_REST; +import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; +import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.SslTestUtils.assertClientAuthentication; +import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; +import static org.apache.ignite.ssl.SslTestUtils.node; +import static org.apache.ignite.ssl.SslTestUtils.place; +import static org.apache.ignite.ssl.SslTestUtils.reload; +import static org.apache.ignite.ssl.SslTestUtils.reloadFailure; +import static org.apache.ignite.ssl.SslTestUtils.servedSubject; +import static org.apache.ignite.ssl.SslTestUtils.status; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; + +/** + * Tests {@code --ssl reload} and {@code --ssl status} on running nodes. Each node has separate key and trust stores so that tests + * can replace or corrupt them independently. node01 is signed by the old CA (oneca); node02, node03 and the expired node02old + * are signed by the new CA (twoca). + */ +public class SslContextReloadTest extends GridCommonAbstractTest { + /** Transports of a node whose client connector shares the factory of the node. */ + private static final String ALL_TRANSPORTS = CLIENT_CONNECTOR + ", " + COMMUNICATION + ", " + DISCOVERY; + + /** Directory the stores of the nodes are placed in. */ + private Path dir; + + /** Whether the client connector runs on a factory of its own. */ + private boolean ownClientConnectorFactory; + + /** Test trust store every node starts on, unless one is placed for it. */ + private String initTrustStore = "trustboth"; + + /** */ + private final ListeningTestLogger nodeLog = new ListeningTestLogger(log); + + /** {@inheritDoc} */ + @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { + IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName).setGridLogger(nodeLog); + + if (!Files.exists(keyStore(igniteInstanceName))) + place("node01", keyStore(igniteInstanceName)); + + if (!Files.exists(trustStore(igniteInstanceName))) + place(initTrustStore, trustStore(igniteInstanceName)); + + ClientConnectorConfiguration cliCfg = new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) + .setUseIgniteSslContextFactory(!ownClientConnectorFactory); + + if (ownClientConnectorFactory) + cliCfg.setSslContextFactory(storeFactory(igniteInstanceName)); + + return cfg.setSslContextFactory(storeFactory(igniteInstanceName)).setClientConnectorConfiguration(cliCfg); + } + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + dir = Files.createTempDirectory("ignite-ssl-reload-"); + } + + /** {@inheritDoc} */ + @Override protected void afterTest() { + stopAllGrids(); + + U.delete(dir); + } + + /** + * A rotation on a running cluster reaches new connections, the report, the node log and status, and leaves established + * connections working. + */ + @Test + public void testReloadOnRunningCluster() throws Exception { + IgniteEx g0 = startGrid(0); + IgniteEx g1 = startGrid(1); + + LogListener logged = LogListener.matches(Pattern.compile("TLS certificates reloaded \\[transports=" + ALL_TRANSPORTS + + ", subject=CN=node02, .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); + + nodeLog.registerListener(logged); + + // The client trusts only the old CA, so it cannot reconnect after the node switches to a certificate from the new CA. + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() + .setAddresses("127.0.0.1:" + g0.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory("node01", "trustone")) + .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { + placeKeys("node02"); + + String res = reload(g0, g1); + + cli.cacheNames(); + + assertReloaded(res, g0, ALL_TRANSPORTS); + assertReloaded(res, g1, ALL_TRANSPORTS); + assertTrue(logged.check()); + } + + assertEquals("CN=node02", servedSubject(g0.context().clientListener().port())); + assertEquals("CN=node02", servedSubject(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); + assertEquals("CN=node02", servedSubject(discoveryPort(g0))); + + SslContextProvider p = g0.context().sslContexts().providers().iterator().next(); + + assertEquals("CN=node02", p.certificate().getSubjectX500Principal().getName()); + assertContains(log, p.certificate().getIssuerX500Principal().getName(), "CN=twoca"); + assertTrue(p.reloadOutcome().successTime() > 0); + + String status = status(g0); + + assertContains(log, status, "chainNotAfter=" + Instant.ofEpochMilli(SslCertificates.chainNotAfter(p.certificateChain()))); + assertContains(log, status, "last reload succeeded at " + Instant.ofEpochMilli(p.reloadOutcome().successTime())); + + stopGrid(0); + + IgniteEx restarted = startGrid(0); + + assertEquals("CN=node02", servedSubject(restarted.context().clientListener().port())); + assertEquals("CN=node02", servedSubject(discoveryPort(restarted))); + } + + /** One node fails: the others still take the new certificate and appear in the report; the command fails. */ + @Test + public void testPartialFailureAcrossNodes() throws Exception { + IgniteEx g0 = startGrid(0); + IgniteEx g1 = startGrid(1); + + place("node02", keyStore(g0.name())); + Files.write(keyStore(g1.name()), "not a key store".getBytes()); + + String res = reloadFailure(g0, g1); + + assertReloaded(res, g0, ALL_TRANSPORTS); + assertFailed(res, g1, ALL_TRANSPORTS); + assertContains(log, res, "Failed to initialize key store"); + + String failed = "Failed on 1 node(s):\n" + node(g1) + ": failed on "; + String succeeded = "\n\nSucceeded on 1 node(s):\n" + node(g0) + ": reloaded "; + + assertTrue("The failed node must be listed first, in a separate block: " + res, + res.contains(failed) && res.indexOf(succeeded) > res.indexOf(failed)); + + assertEquals("CN=node02", servedSubject(g0.context().clientListener().port())); + assertEquals("CN=node01", servedSubject(g1.context().clientListener().port())); + + String status = status(g1); + + assertContains(log, status, "last reload failed 1 time(s) in a row"); + assertContains(log, status, "Failed to initialize key store"); + assertContains(log, status, "loaded subject=CN=node01,"); + } + + /** + * A factory that is not an {@link AbstractSslContextFactory} and returns the context in use fails the reload: the node gets no + * new certificates from it. + */ + @Test + public void testCachingFactoryNotReloaded() throws Exception { + IgniteEx g = startGrid(0, cfg -> { + SSLContext ctx = cfg.getSslContextFactory().create(); + + cfg.setSslContextFactory(() -> ctx); + }); + + placeKeys("node02"); + + String res = reloadFailure(g); + + assertFailed(res, g, ALL_TRANSPORTS); + assertContains(log, res, "returns the context in use"); + + assertEquals("CN=node01", servedSubject(g.context().clientListener().port())); + } + + /** A shared custom factory loads once at startup and once per reload. Status does not load it. */ + @Test + public void testCustomFactoryReloaded() throws Exception { + TestFactory factory = new TestFactory(); + + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true)); + }); + + SslContextRegistry registry = g.context().sslContexts(); + SSLContext ctx = registry.register(factory, COMMUNICATION); + + assertSame(ctx, registry.register(factory, DISCOVERY)); + assertSame(ctx, registry.register(factory, CLIENT_CONNECTOR)); + assertSame(ctx, registry.register(factory, BINARY_REST)); + assertEquals(1, registry.providers().size()); + assertEquals(1, factory.calls); + + String transports = BINARY_REST + ", " + ALL_TRANSPORTS; + + assertContains(log, status(g), node(g) + ": " + transports); + assertEquals("Status must not load the factory", 1, factory.calls); + + placeKeys("node02"); + + assertReloaded(reload(g), g, transports); + assertEquals("Reload must load the shared factory once", 2, factory.calls); + assertSame(ctx, registry.register(factory, COMMUNICATION)); + assertContains(log, status(g), "loaded subject=CN=node02,"); + assertEquals("Status must not load the factory", 2, factory.calls); + + assertEquals("CN=node02", servedSubject(discoveryPort(g))); + assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); + assertEquals("CN=node02", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); + } + + /** Equal but distinct factories reload independently, including when one of them fails. */ + @Test + public void testEqualFactoriesReloadIndependently() throws Exception { + TestFactory nodeFactory = new TestFactory(); + TestFactory clientFactory = new TestFactory(); + + assertNotSame(nodeFactory, clientFactory); + assertEquals(nodeFactory, clientFactory); + assertEquals(nodeFactory.hashCode(), clientFactory.hashCode()); + + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(nodeFactory); + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory); + }); + + SslContextRegistry registry = g.context().sslContexts(); + + assertEquals(2, registry.providers().size()); + assertNotSame(registry.register(nodeFactory, COMMUNICATION), registry.register(clientFactory, CLIENT_CONNECTOR)); + assertEquals(1, nodeFactory.calls); + assertEquals(1, clientFactory.calls); + + placeKeys("node02"); + + String res = reload(g); + + assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); + assertReloaded(res, g, CLIENT_CONNECTOR); + assertEquals(2, nodeFactory.calls); + assertEquals(2, clientFactory.calls); + + nodeFactory.fail = true; + placeKeys("node03"); + + res = reloadFailure(g); + + assertFailed(res, g, COMMUNICATION + ", " + DISCOVERY); + assertReloaded(res, g, CLIENT_CONNECTOR); + assertEquals(3, nodeFactory.calls); + assertEquals(3, clientFactory.calls); + assertEquals("CN=node02", servedSubject(discoveryPort(g))); + assertEquals("CN=node03", servedSubject(g.context().clientListener().port())); + } + + /** An expired certificate stays out of use, and the report, log and status identify the rejected chain. */ + @Test + public void testExpiredCertificateNotApplied() throws Exception { + LogListener expired = LogListener.matches(Pattern.compile("Failed to reload TLS certificates, the ones in use stay .*" + + "reason=The new certificate chain is not valid now \\[subject=CN=node02old,")).build(); + + nodeLog.registerListener(expired); + + IgniteEx g = startGrid(0); + + placeKeys("node02old"); + + String res = reloadFailure(g); + + assertFailed(res, g, ALL_TRANSPORTS); + assertContains(log, res, "The new certificate chain is not valid now [subject=CN=node02old,"); + assertTrue(expired.check()); + assertEquals("CN=node01", servedSubject(discoveryPort(g))); + + String status = status(g); + + assertContains(log, status, "loaded subject=CN=node01, issuer="); + assertContains(log, status, "last reload failed 1 time(s) in a row"); + assertContains(log, status, "The new certificate chain is not valid now"); + } + + /** A trust-only store must not replace a working node's keys; new connections still receive the original certificate. */ + @Test + public void testKeyStoreWithoutKeyNotApplied() throws Exception { + IgniteEx g = startGrid(0); + + placeKeys("trustboth"); + + String res = reloadFailure(g); + + assertFailed(res, g, ALL_TRANSPORTS); + assertContains(log, res, "The new key material has no private key, so the node would present no certificate"); + assertEquals("CN=node01", servedSubject(g.context().clientListener().port())); + assertEquals("CN=node01", servedSubject(discoveryPort(g))); + assertClientConnected(g, "node01"); + } + + /** A client connector and binary REST with factories of their own reload separately from the node-wide factory. */ + @Test + public void testClientTransportsWithSeparateFactories() throws Exception { + initTrustStore = "trustone"; + ownClientConnectorFactory = true; + + IgniteEx g = startGrid(0, cfg -> { + cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true) + .setSslFactory(storeFactory(cfg.getIgniteInstanceName()))); + }); + + placeKeys("node02"); + + String res = reload(g); + + assertReloaded(res, g, CLIENT_CONNECTOR); + assertReloaded(res, g, BINARY_REST); + assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); + + assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); + assertEquals("CN=node02", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); + assertEquals("CN=node02", servedSubject(discoveryPort(g))); + } + + /** Manual reload accepts several keys, reports unavailable metadata and keeps existing and new TLS connections working. */ + @Test + public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Exception { + ownClientConnectorFactory = true; + + IgniteEx g = startGrid(0); + + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() + .setAddresses("127.0.0.1:" + g.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory("node01", "trustboth")) + .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { + placeKeys("node0102"); + + String res = reload(g); + + assertContains(log, res, node(g) + ": reloaded " + CLIENT_CONNECTOR); + assertContains(log, res, node(g) + ": reloaded " + COMMUNICATION + ", " + DISCOVERY); + + cli.cacheNames(); + } + + String subj = servedSubject(g.context().clientListener().port()); + + assertTrue(subj, List.of("CN=node01", "CN=node02").contains(subj)); + + String status = status(g); + + assertContains(log, status, "certificate metadata is unavailable"); + assertContains(log, status, "last reload succeeded at"); + assertNotContains(log, status, "loaded subject="); + } + + /** + * The authority is replaced in steps: trust the new one, present its certificates, drop the old one. Every TCP transport + * accepts certificates from the new authority and rejects old certificates and clients without certificates. The nodes + * reconnect and a node of the new authority joins. + */ + @Test + public void testCertificateAuthorityReplaced() throws Exception { + initTrustStore = "trustone"; + + IgniteEx g0 = startGrid(0, cfg -> { + cfg.getClientConnectorConfiguration().setSslClientAuth(true); + cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); + }); + IgniteEx g1 = startGrid(1, cfg -> { + cfg.getClientConnectorConfiguration().setSslClientAuth(true); + cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); + }); + + assertTransportClientAuthentication(g0, "node01", "node02"); + + placeTrust("trustboth"); + reload(g0, g1); + + assertClientConnected(g0, "node01"); + assertClientConnected(g0, "node02"); + + placeKeys("node02"); + reload(g0, g1); + + placeTrust("trusttwo"); + reload(g0, g1); + + String reloadedStatus = status(g0, g1); + + assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*CN=twoca").matcher(reloadedStatus).find()); + + for (IgniteEx node : List.of(g0, g1)) { + assertClientConnected(node, "node02"); + assertTransportClientAuthentication(node, "node02", "node01"); + } + + ConnectionClientPool pool = U.field(g0.configuration().getCommunicationSpi(), "clientPool"); + + pool.forceCloseConnection(g1.localNode().id()); + + assertEquals((Integer)1, g0.compute(g0.cluster().forNodeId(g1.localNode().id())).call(() -> 1)); + + place("node03", keyStore(getTestIgniteInstanceName(2))); + place("trusttwo", trustStore(getTestIgniteInstanceName(2))); + + IgniteEx g2 = startGrid(2); + + assertEquals(3, g2.cluster().nodes().size()); + + g2.getOrCreateCache(DEFAULT_CACHE_NAME).put(1, 1); + + assertEquals((Integer)1, g0.cache(DEFAULT_CACHE_NAME).get(1)); + } + + /** Status names the certificate of every transport, a client connector on its own factory included, and fails on an expired one. */ + @Test + public void testStatus() throws Exception { + ownClientConnectorFactory = true; + + place("node02old", keyStore(getTestIgniteInstanceName(0))); + + IgniteEx g = startGrid(0); + + String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); + String id = node(g); + + assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=CN=node02old, issuer="); + assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=CN=node02old, issuer="); + assertContains(log, res, "CN=twoca"); + assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); + } + + /** A node that starts on an expired certificate logs a warning. */ + @Test + public void testExpiredCertificateWarnedAtStart() throws Exception { + place("node02old", keyStore(getTestIgniteInstanceName(0))); + + LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now").build(); + + nodeLog.registerListener(expired); + + startGrid(0); + + assertTrue(expired.check()); + } + + /** A failed job result still identifies the node by UUID and consistent ID. */ + @Test + public void testFailedJobNamesNode() { + GridTestNode n = new GridTestNode(UUID.randomUUID()); + + n.setConsistentId("cid"); + + ComputeJobResult res = Mockito.mock(ComputeJobResult.class); + + Mockito.when(res.getNode()).thenReturn(n); + Mockito.when(res.getException()).thenReturn(new IgniteException("node left")); + + Throwable e = GridTestUtils.assertThrows(log, () -> new SslReloadTask().reduce(List.of(res)).result(), IgniteException.class, null); + + assertContains(log, e.getMessage(), "Failed on 1 node(s):\n" + n.id() + " [consistentId=cid]: node left"); + } + + /** Both commands report that no SSL context factory is configured. */ + @Test + public void testWithoutSsl() throws Exception { + IgniteEx g = startGrid(getTestIgniteInstanceName(0), cfg -> cfg.setSslContextFactory(null) + .setClientConnectorConfiguration(new ClientConnectorConfiguration())); + + assertContains(log, reload(g), node(g) + ": no SSL context factory is configured"); + assertContains(log, status(g), node(g) + ": no SSL context factory is configured"); + } + + /** Checks client authentication on discovery, communication, the client connector and binary REST. */ + private void assertTransportClientAuthentication(IgniteEx node, String trustedKey, String untrustedKey) throws Exception { + for (int port : new int[] { + discoveryPort(node), + ((TcpCommunicationSpi)node.configuration().getCommunicationSpi()).boundPort(), + node.context().clientListener().port(), + node.localNode().attribute(ATTR_REST_TCP_PORT) + }) + assertClientAuthentication(port, trustedKey, untrustedKey); + } + + /** Opens a new authenticated client connection; its own trust store accepts both test authorities. */ + private void assertClientConnected(IgniteEx node, String keyStore) { + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory(keyStore, "trustboth")) + .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { + cli.cacheNames(); + } + } + + /** @return Factory reading the stores of the node. */ + private Factory storeFactory(String igniteInstanceName) { + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreFilePath(keyStore(igniteInstanceName).toString()); + factory.setKeyStorePassword(GridTestUtils.keyStorePassword().toCharArray()); + factory.setTrustStoreFilePath(trustStore(igniteInstanceName).toString()); + factory.setTrustStorePassword(GridTestUtils.keyStorePassword().toCharArray()); + + return factory; + } + + /** */ + private Path keyStore(String igniteInstanceName) { + return dir.resolve(igniteInstanceName + "-key.jks"); + } + + /** */ + private Path trustStore(String igniteInstanceName) { + return dir.resolve(igniteInstanceName + "-trust.jks"); + } + + /** @param name Test key store to place for every running node. */ + private void placeKeys(String name) throws IOException { + for (Ignite node : G.allGrids()) + place(name, keyStore(node.name())); + } + + /** @param name Test trust store to place for every running node. */ + private void placeTrust(String name) throws IOException { + for (Ignite node : G.allGrids()) + place(name, trustStore(node.name())); + } + + /** */ + private void assertReloaded(String res, IgniteEx node, String transports) { + assertContains(log, res, node(node) + ": reloaded " + transports + "; loaded "); + } + + /** */ + private void assertFailed(String res, IgniteEx node, String transports) { + assertContains(log, res, node(node) + ": failed on " + transports + " ("); + } + + /** Counts loads and deliberately considers distinct instances equal. */ + private class TestFactory implements Factory { + /** Factory calls, including failed loads. */ + private int calls; + + /** Whether loads fail. */ + private boolean fail; + + /** {@inheritDoc} */ + @Override public SSLContext create() { + calls++; + + if (fail) + throw new IgniteException("Certificate source is unavailable"); + + return storeFactory(getTestIgniteInstanceName(0)).create(); + } + + /** {@inheritDoc} */ + @Override public boolean equals(Object obj) { + return obj instanceof TestFactory; + } + + /** {@inheritDoc} */ + @Override public int hashCode() { + return 0; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java new file mode 100644 index 0000000000000..b1cceaa5008c6 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java @@ -0,0 +1,289 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.KeyStore; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509KeyManager; +import org.apache.ignite.cluster.ClusterGroup; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.management.ssl.AbstractSslTask; +import org.apache.ignite.internal.management.ssl.SslReloadTask; +import org.apache.ignite.internal.management.ssl.SslStatusTask; +import org.apache.ignite.internal.util.typedef.X; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.internal.visor.VisorTaskArgument; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.testframework.GridTestUtils; +import org.jetbrains.annotations.Nullable; + +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** Runs the {@code --ssl} commands the way control.sh does, probes what the transports serve, and places the test stores. */ +public class SslTestUtils { + /** Timeout for a probe connection or TLS handshake. */ + private static final int SOCKET_TIMEOUT = 30_000; + + /** */ + private SslTestUtils() { + // No-op. + } + + /** + * @param nodes Nodes whose certificates are reloaded. The first node submits the command. + * @return Report of the command. + */ + public static String reload(IgniteEx... nodes) throws Exception { + return execute(SslReloadTask.class, nodes); + } + + /** + * @param nodes Nodes whose certificate status is requested. The first node submits the command. + * @return Report of the command. + */ + public static String status(IgniteEx... nodes) throws Exception { + return execute(SslStatusTask.class, nodes); + } + + /** + * @param nodes Nodes whose certificates are reloaded. The first node submits the command. + * @return Exception stack trace with all causes, including any wrappers added by compute. + */ + public static String reloadFailure(IgniteEx... nodes) { + return X.getFullStackTrace(GridTestUtils.assertThrows(null, () -> reload(nodes), Exception.class, null)); + } + + /** + * @param probe Context to connect with; discovery asks the client for a certificate it trusts. + * @param port Port to connect to. + * @return Certificate the node presents on a new TLS connection to that port. + */ + public static X509Certificate servedCertificate(SSLContext probe, int port) throws Exception { + try (SSLSocket sock = (SSLSocket)probe.getSocketFactory().createSocket()) { + sock.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), SOCKET_TIMEOUT); + sock.setSoTimeout(SOCKET_TIMEOUT); + + sock.startHandshake(); + + return (X509Certificate)sock.getSession().getPeerCertificates()[0]; + } + } + + /** + * @param port Port to connect to; the probe presents node01 and trusts both test authorities. + * @return Subject of the certificate the node presents on a new TLS connection to that port. + */ + public static String servedSubject(int port) throws Exception { + return servedCertificate(GridTestUtils.sslTrustedFactory("node01", "trustboth").create(), port).getSubjectX500Principal().getName(); + } + + /** + * Checks that the server accepts a trusted client and rejects both an untrusted certificate and no certificate. + * Each probe uses a fresh context to prevent session resumption. TLS 1.2 completes the handshake only after the server + * has checked the client certificate. + * + * @param port Server port. + * @param trustedKey Test key store trusted by the server. + * @param untrustedKey Test key store not trusted by the server. + */ + public static void assertClientAuthentication(int port, String trustedKey, String untrustedKey) throws Exception { + try (SSLSocket sock = tls12Socket(clientContext(trustedKey), port)) { + sock.startHandshake(); + } + catch (IOException e) { + throw new AssertionError("Trusted client must connect [port=" + port + ", client=" + trustedKey + ']', e); + } + + assertHandshakeRefused(untrustedKey, port); + assertHandshakeRefused(null, port); + } + + /** + * Creates a client that trusts both test authorities and presents the selected certificate even if the server does not + * advertise its authority. This lets tests distinguish an untrusted certificate from a missing certificate. + * + * @param keyStore Single-key test store, or {@code null} for a client without a certificate. + * @return Fresh client context. + */ + public static SSLContext clientContext(@Nullable String keyStore) throws Exception { + KeyManager[] keyManagers = new KeyManager[0]; + + if (keyStore != null) { + KeyStore keys = store(keyStore); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + kmf.init(keys, keyStorePassword().toCharArray()); + + X509KeyManager km = (X509KeyManager)kmf.getKeyManagers()[0]; + + String alias = keys.aliases().nextElement(); + + assertNotNull("No private key in test store " + keyStore, km.getPrivateKey(alias)); + + X509KeyManager always = new X509KeyManager() { + @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket sock) { + return alias; + } + + @Override public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[] {alias}; + } + + @Override public String[] getServerAliases(String keyType, Principal[] issuers) { + return km.getServerAliases(keyType, issuers); + } + + @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket sock) { + return km.chooseServerAlias(keyType, issuers, sock); + } + + @Override public X509Certificate[] getCertificateChain(String alias) { + return km.getCertificateChain(alias); + } + + @Override public PrivateKey getPrivateKey(String alias) { + return km.getPrivateKey(alias); + } + }; + + keyManagers = new KeyManager[] {always}; + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + tmf.init(store("trustboth")); + + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(keyManagers, tmf.getTrustManagers(), null); + + return ctx; + } + + /** @param name Test store. */ + private static KeyStore store(String name) throws Exception { + KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + + try (InputStream in = Files.newInputStream(Path.of(keyStorePath(name)))) { + store.load(in, keyStorePassword().toCharArray()); + } + + return store; + } + + /** + * @param keyStore Untrusted test store, or {@code null} for a client without a certificate. + * @param port Server port. + */ + private static void assertHandshakeRefused(@Nullable String keyStore, int port) throws Exception { + String client = keyStore == null ? "no certificate" : keyStore; + + try (SSLSocket sock = tls12Socket(clientContext(keyStore), port)) { + try { + sock.startHandshake(); + + fail("Server must refuse the client [port=" + port + ", client=" + client + ']'); + } + catch (SSLException | SocketException expected) { + // A TLS alert or a connection reset is a refusal. A timeout must fail the test. + } + } + catch (IOException e) { + throw new AssertionError("Client refusal check failed [port=" + port + ", client=" + client + ']', e); + } + } + + /** + * @param ctx Client context. + * @param port Server port. + * @return Connected socket limited to TLS 1.2. + */ + private static SSLSocket tls12Socket(SSLContext ctx, int port) throws IOException { + SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket(); + + try { + sock.setEnabledProtocols(new String[] {"TLSv1.2"}); + sock.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), SOCKET_TIMEOUT); + sock.setSoTimeout(SOCKET_TIMEOUT); + + return sock; + } + catch (IOException | RuntimeException e) { + U.closeQuiet(sock); + + throw e; + } + } + + /** @return Node UUID and consistent ID in command output format. */ + public static String node(IgniteEx node) { + return node.localNode().id() + " [consistentId=" + node.localNode().consistentId() + ']'; + } + + /** @return Discovery port of the node. */ + public static int discoveryPort(IgniteEx node) { + return ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); + } + + /** + * @param name Test store, as {@code tests.properties} names it. + * @param dest File to replace. + */ + public static void place(String name, Path dest) throws IOException { + Files.copy(Path.of(GridTestUtils.keyStorePath(name)), dest, StandardCopyOption.REPLACE_EXISTING); + } + + /** + * @param task Task of the command. + * @param nodes Nodes to run on. + * @return Report of the command. + */ + private static String execute(Class task, IgniteEx... nodes) throws Exception { + List ids = Arrays.stream(nodes).map(n -> n.localNode().id()).collect(Collectors.toList()); + + ClusterGroup serversAndClients = nodes[0].cluster(); + + return nodes[0].compute(serversAndClients).execute(task, new VisorTaskArgument<>(ids, new NoArg(), false)).result(); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java index 19e63c8a78595..d7667f5161ebd 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java @@ -78,6 +78,7 @@ import org.apache.ignite.internal.processors.cacheobject.NoOpBinary; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.tracing.configuration.NoopTracingConfigurationManager; +import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.util.typedef.internal.CU; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.lang.IgniteBiTuple; @@ -157,10 +158,17 @@ public IgniteMock(String name, String locHost, UUID nodeId, Marshaller marshalle try { kernalCtx = new StandaloneGridKernalContext(new GridTestLog4jLogger(), null) { + /** */ + private final SslContextRegistry sslCtxs = new SslContextRegistry(this); + @Override public GridInternalSubscriptionProcessor internalSubscriptionProcessor() { return new GridInternalSubscriptionProcessor(this); } + @Override public SslContextRegistry sslContexts() { + return sslCtxs; + } + @Override public Map nodeAttributes() { return Collections.emptyMap(); } diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 3df66ce6f6b2f..e753928726dbc 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -74,12 +74,14 @@ import org.apache.ignite.internal.processors.security.service.ServiceAuthorizationTest; import org.apache.ignite.internal.processors.security.service.ServiceStaticConfigTest; import org.apache.ignite.internal.processors.security.snapshot.SnapshotPermissionCheckTest; +import org.apache.ignite.internal.ssl.SslContextProviderTest; import org.apache.ignite.internal.thread.context.ComputeTaskOperationContextPropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributePropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributesTest; import org.apache.ignite.plugin.security.SecurityBasicPermissionSetSerializationTest; import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SslContextMetadataTest; +import org.apache.ignite.ssl.SslContextReloadTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -143,6 +145,8 @@ IgniteSecurityProcessorTest.class, MultipleSSLContextsTest.class, SslContextMetadataTest.class, + SslContextProviderTest.class, + SslContextReloadTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output index 62c99c348b4a7..1704fd14485d5 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output @@ -477,6 +477,12 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). + Reload TLS certificates on all cluster nodes from their SSL context factories: + control.(sh|bat) --ssl reload + + Show the TLS certificates of all cluster nodes and the results of their last reloads: + control.(sh|bat) --ssl status + By default commands affecting the cluster require interactive confirmation. Use --yes option to disable it. diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output index d2755b6b26998..048e7d005e40a 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output @@ -477,6 +477,12 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). + Reload TLS certificates on all cluster nodes from their SSL context factories: + control.(sh|bat) --ssl reload + + Show the TLS certificates of all cluster nodes and the results of their last reloads: + control.(sh|bat) --ssl status + By default commands affecting the cluster require interactive confirmation. Use --yes option to disable it. From 02cf61a4fa126204248255585b7e593f61a511b4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:42:15 +0300 Subject: [PATCH 003/242] IGNITE-29210 Remove unused migrated test import --- .../org/apache/ignite/internal/ssl/SslContextProviderTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 4eb88b3cd098e..c8789df377c87 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -34,7 +34,6 @@ import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; -import org.jetbrains.annotations.Nullable; import org.junit.Test; import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; From 87263288d4007685d79be92b34627225225b90d2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:48:02 +0300 Subject: [PATCH 004/242] IGNITE-29211 Renew TLS certificates automatically before expiry --- .../_docs/monitoring-metrics/new-metrics.adoc | 16 + docs/_docs/security/ssl-tls.adoc | 50 +- docs/_docs/tools/control-script.adoc | 7 +- .../ignite/ssl/AbstractSslContextFactory.java | 55 +- .../management/ssl/SslStatusTask.java | 3 + .../ignite/internal/ssl/SslCertificates.java | 2 +- .../internal/ssl/SslContextProvider.java | 129 +- .../internal/ssl/SslContextRegistry.java | 34 +- .../ignite/internal/ssl/SslRenewal.java | 225 +++ .../ignite/internal/ssl/SslRenewalTest.java | 1304 +++++++++++++++++ .../ignite/testsuites/SecurityTestSuite.java | 2 + 11 files changed, 1811 insertions(+), 16 deletions(-) create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 435b7741ae23c..39d3230ca8514 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -510,6 +510,22 @@ Register name: `rest.client` NOTE: `RejectedSslSessionsCount` and `SslHandshakeDurationHistogram` are registered only when SSL is enabled. +== SSL + +Use this metric to detect unsuccessful link:security/ssl-tls#renewing-certificates-automatically[automatic renewal] attempts. Check link:tools/control-script#checking-certificates[`control.sh --ssl status`] and the node log to find the affected connections and cause. + +Register name: `ssl` + +[cols="2,1,3",opts="header"] +|=== +|Name | Type | Description +|ConsecutiveRenewalFailures | integer | Sum of consecutive unsuccessful automatic renewal attempts across the node's SSL context factories. An unchanged certificate chain, a loading error or a rejected certificate counts as an unsuccessful attempt. A shared factory is counted once. +|=== + +When automatic renewal or manual reload installs a chain with a later expiry, that factory's count resets to zero. The first certificate to expire determines the chain's expiry. Other factories' counts stay unchanged. Failed manual reloads do not increase the metric. + +Nodes without automatic renewal report zero. + == Discovery IO Register name: `io.discovery` diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 6c6a24acabdfb..e64ed29451c45 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -245,6 +245,50 @@ Replacing a certificate does not prevent someone from using the old certificate Reload leaves existing connections open. After preventing new connections with the old certificate, restart nodes one at a time to close existing connections. +== Renewing Certificates Automatically + +Automatic renewal lets a node pick up certificates delivered by another system before its current certificate expires. +Use it when a service updates key store files or a <> retrieves certificates from a service. +For manual file replacement, use <> and run reload after making the change. + +=== Prepare Certificate Delivery + +The delivery system must make a valid replacement available before renewal is due. Ignite reads the files when renewal is due, not when they change. +The replacement chain must expire later than the current chain. If a CA certificate expires first, replace it too. + +With `SslContextFactory`, keep exactly one private key and its X.509 certificate chain in each key store. +A custom factory must supply the chain with each SSL context. Without it, a node with automatic renewal enabled cannot start because it cannot determine when renewal is due. + +Nodes and applications must trust the replacement certificates before they are loaded. For a new CA, follow the stages in <>. +When renewal installs a replacement certificate, `SslContextFactory` also reloads its trust store. Keep the old CA in the trust store until every affected node and application has switched certificates. Otherwise, the renewed node cannot connect to nodes that still use certificates from the old CA. To apply a trust store change without replacing the certificate, run `control.sh --ssl reload`. + +=== Enable Renewal + +Set `renewalEnabled` to `true` on each SSL context factory that should renew certificates, then restart the nodes one at a time. +Configure the factory used by each connection type: + +[cols="2,3",opts="header"] +|=== +|Connections |Factory setting +|Between cluster nodes +|`IgniteConfiguration.sslContextFactory` +|Thin clients and JDBC/ODBC +|The node's factory, or `ClientConnectorConfiguration.sslContextFactory` with `useIgniteSslContextFactory=false` +|Binary REST +|The node's factory, or `ConnectorConfiguration.sslFactory` +|=== + +Connection types that share one factory renew together. + +To change how early renewal starts, set `renewBeforeFraction` as described in the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#setRenewBeforeFraction(double)[AbstractSslContextFactory Javadoc]. Changes to these settings take effect after a node restart. + +=== Check Renewal + +Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and the next attempt time. + +If the source still returns the current certificate, or loading the replacement fails, the node keeps its current context and retries. +The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] metric counts these unsuccessful attempts. Check the node log for the cause, fix certificate delivery or configuration, and use reload if you need to apply the correction immediately. + == SslContextFactory Properties `SslContextFactory` supports the following properties: @@ -293,10 +337,10 @@ Reload leaves existing connections open. After preventing new connections with t == Custom SSL Context Factories This section is for developers integrating a certificate source, such as a PKI service. -Extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] to support manual reload. The link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createNew()[createNew() contract] describes fresh creation, overrides, serialized calls, and handling of remote requests. +Extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] to support manual reload and automatic renewal. The link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createNew()[createNew() contract] describes fresh creation, overrides, serialized calls, and handling of remote requests. To let Ignite report and validate certificate dates, return the key managers and their single X.509 chain from the same certificate load through `createKeyMaterial()`. If the factory builds the SSL context directly, attach the chain with link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SSLContextWrapper.html[SSLContextWrapper]. -Metadata is optional: without a chain, manual reload can still replace the context, but it cannot check certificate dates. +When automatic renewal is enabled, the chain is required both at startup and on every reload. Otherwise, metadata is optional: without a chain, manual reload can still replace the context, but it cannot check certificate dates. -A plain `Factory` must return a new context on every call. Returning the context already in use makes reload fail. +A plain `Factory` supports manual reload only and must return a new context on every call. Returning the context already in use makes reload fail. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index ff1b118577c88..56ac91c5be037 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1790,20 +1790,21 @@ A successful result means the new SSL context is in use for new connections. Use === Checking Certificates -Use this command after a replacement or when investigating a reload failure. It shows what each node has loaded without reading store files again or requesting certificates from a service: +Use this command after a replacement or when investigating a reload or automatic renewal failure. It shows what each node has loaded without reading store files again or requesting certificates from a service: [source, shell] ---- control.sh|bat --ssl status ---- -For each factory, the output lists its transports, loaded certificate and last reload result: +For each factory, the output lists its transports, loaded certificate and last reload result. If automatic renewal is scheduled, it also shows the next attempt time: [source,text] ---- e1e18f84-0fb5-484d-a623-15ffe3800000 [consistentId=node01]: client connector, communication, discovery loaded subject=CN=node01, issuer=CN=ca, serial=2c41d7a09b, notBefore=2026-10-05T09:00:00Z, notAfter=2027-10-05T09:00:00Z last reload succeeded at 2026-10-05T09:00:02.347Z + next automatic renewal at 2027-08-11T15:00:00Z ---- Check the certificate's identity and expiry date in the `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. @@ -1813,7 +1814,7 @@ Status does not test connections or verify that other nodes and clients trust th |=== |Output |Meaning and next action |`last reload failed N time(s) in a row` -|Check the reason and time on this line, correct the cause, and run reload again. +|Check the reason and time on this line, correct the cause, and run reload again. The line includes automatic renewal errors, but not attempts that found only the certificate already in use. Use the link:monitoring-metrics/new-metrics#ssl[renewal metric] and node logs to investigate those attempts. |`PROBLEM: the loaded certificate is not valid now` |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index acd8b6ac4d51e..db51568915d49 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -46,6 +46,9 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default SSL protocol. */ public static final String DFLT_SSL_PROTOCOL = "TLS"; + /** Default share of the certificate lifetime left when a node starts to renew it. */ + public static final double DFLT_RENEW_BEFORE_FRACTION = 0.15; + /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -58,6 +61,12 @@ public abstract class AbstractSslContextFactory implements Factory { /** Cached instance of an {@link SSLContext}. */ protected final AtomicReference sslCtx = new AtomicReference<>(); + /** Whether a node builds its SSL context again before the certificate expires, to pick up a new certificate. */ + private boolean renewalEnabled; + + /** Share of the certificate lifetime left when a node starts to renew it. */ + private double renewBeforeFraction = DFLT_RENEW_BEFORE_FRACTION; + /** * Gets protocol for secure transport. * @@ -114,6 +123,43 @@ public void setProtocols(String... protocols) { this.protocols = protocols; } + /** @return Whether a node builds its SSL context again before the certificate expires, to pick up a new certificate. */ + public boolean isRenewalEnabled() { + return renewalEnabled; + } + + /** + * Sets whether a node calls {@link #createNew()} before the certificate expires to pick up a new certificate. + * A file-based factory rereads its files. It does not replace them. + * When enabled, the factory must provide certificate chain metadata or the node does not start. Disabled by default. + * Set this before starting the node; changing it on a running node takes effect after a restart. + * + * @param renewalEnabled Whether renewal is enabled. + */ + public void setRenewalEnabled(boolean renewalEnabled) { + this.renewalEnabled = renewalEnabled; + } + + /** @return Share of the certificate lifetime left when a node starts to renew the certificate. */ + public double getRenewBeforeFraction() { + return renewBeforeFraction; + } + + /** + * Sets the share of the certificate lifetime left when a node starts to renew the certificate. The lifetime runs from the time + * the certificate becomes valid to the earliest expiry in its chain. The value must be greater than {@code 0} and less than + * {@code 1}, otherwise this method throws {@link IllegalArgumentException}. + * If not specified, {@link #DFLT_RENEW_BEFORE_FRACTION} is used. + * Set this before starting the node; changing it on a running node takes effect after a restart. + * + * @param renewBeforeFraction Share of the lifetime. + */ + public void setRenewBeforeFraction(double renewBeforeFraction) { + A.ensure(renewBeforeFraction > 0 && renewBeforeFraction < 1, "renewBeforeFraction must be greater than 0 and less than 1"); + + this.renewBeforeFraction = renewBeforeFraction; + } + /** * Creates SSL context based on factory settings. * @@ -229,9 +275,12 @@ protected KeyMaterial createKeyMaterial() throws SSLException { * through another mechanism, attach its loaded certificate chain, if known, using * {@link SSLContextWrapper#SSLContextWrapper(SSLContext, SSLParameters, X509Certificate[])}. *

- * The node calls this method for {@code control.sh --ssl reload}. Calls for the same factory on a node never overlap; - * calls for different factories may run at the same time. A subclass that fetches certificates from a remote source must - * limit how long a call takes and stop when its thread is interrupted. + * The node calls this method for {@code control.sh --ssl reload} and automatic certificate renewal. Calls for the same + * factory on a node never overlap; calls for different factories may run at the same time. Automatic renewal requires + * certificate chain metadata both at startup and on every reload. + *

+ * A subclass that fetches certificates from a remote source must limit how long a call takes and stop when its thread is + * interrupted. When the node stops, it interrupts an automatic renewal in progress and waits for it to end. * * @return New initialized SSL context. * @throws IgniteException If the context could not be created. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 9ebc1ca910735..970be3107d750 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -99,6 +99,9 @@ private SslStatusJob(NoArg arg, boolean debug) { } else if (outcome.successTime() > 0) lines.add(DOUBLE_INDENT + "last reload succeeded at " + Instant.ofEpochMilli(outcome.successTime())); + + if (provider.nextRenewalTime() > 0) + lines.add(DOUBLE_INDENT + "next automatic renewal at " + Instant.ofEpochMilli(provider.nextRenewalTime())); } return new IgniteBiTuple<>(!invalid, String.join("\n", lines)); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 63cfb031386c4..ff35b4e16db6e 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -21,7 +21,7 @@ import org.apache.ignite.internal.util.typedef.X; import org.jetbrains.annotations.Nullable; -/** Certificate dates and descriptions used by SSL reload and status. */ +/** Certificate dates and descriptions used by SSL reload, renewal and status. */ public class SslCertificates { /** */ private SslCertificates() { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 256995ae0c99a..bc261f40a73cf 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -19,18 +19,26 @@ import java.security.cert.X509Certificate; import java.time.Instant; +import java.util.Arrays; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.ScheduledExecutorService; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteInterruptedException; +import org.apache.ignite.IgniteLogger; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SSLContextWrapper; import org.jetbrains.annotations.Nullable; +import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; import static org.apache.ignite.internal.ssl.SslCertificates.describe; -/** Owns one factory's context and reloads on this node; transports using that factory share its context. */ +/** Owns one factory's context, reloads and automatic renewal on this node; transports using that factory share its context. */ public class SslContextProvider { /** */ private final Factory factory; @@ -44,9 +52,18 @@ public class SslContextProvider { /** Stable context handed to every transport using this factory. */ private final SSLContext sharedCtx; + /** Renewal window configured at registration, or {@code 0} when automatic renewal is disabled. */ + private final double renewBeforeFraction; + + /** Automatic renewal, attached before this provider is registered. */ + private @Nullable SslRenewal renewal; + /** Snapshot of the last successful reload and subsequent failures. */ private volatile ReloadOutcome outcome = new ReloadOutcome(0, 0, null, 0); + /** Failed automatic attempts since a chain with a later expiry was put in use. */ + private volatile int renewalFailures; + /** @param factory Factory to build the context with. */ SslContextProvider(Factory factory) { this.factory = factory; @@ -54,6 +71,8 @@ public class SslContextProvider { ctx = factory.create(); ctx.createSSLEngine(); + renewBeforeFraction = factory instanceof AbstractSslContextFactory f && f.isRenewalEnabled() ? f.getRenewBeforeFraction() : 0; + sharedCtx = new CurrentSslContext(this::context); } @@ -94,9 +113,27 @@ public String transports() { return chain == null ? null : chain[0]; } + /** + * @param exec Renewal thread of the node. + * @param log Renewal logger. + */ + void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { + if (renewBeforeFraction == 0) + return; + + if (certificateChain() == null) { + throw new IgniteException("The SSL context factory provides no certificate chain, so it cannot be renewed automatically " + + "[transports=" + transports() + ']'); + } + + renewal = new SslRenewal(this, renewBeforeFraction, exec, log); + renewal.replan(); + } + /** * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no * private key. If a certificate chain is supplied, every certificate must be within its validity period. + * Certificate metadata is required when automatic renewal is enabled. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * * @return Certificate installed by this reload, or {@code null} if its metadata is unavailable. @@ -113,11 +150,63 @@ public String transports() { X509Certificate[] next = certificateChain(rebuilt); - check(next); + check(next, renewBeforeFraction > 0); + + putInUse(rebuilt, next); + } + catch (Throwable e) { + onFailure(e); + + throw e; + } + + onSuccess(); + + return certificate(); + } + + /** + * Installs a new context only if its chain expires later than the current chain. Otherwise, renewal would immediately be due again. + * Returning the current chain counts as an unsuccessful attempt and leaves the context unchanged. + * + * @param expected Context the renewal was planned for. + * @return Certificate installed by this attempt, or {@code null} if the factory returned the chain in use or the planned + * context had already been replaced. + * @throws IgniteCheckedException If the new context supplies no chain, or another chain that fails the checks of {@link #reload()} + * or does not expire later. + */ + synchronized @Nullable X509Certificate renew(SSLContext expected) throws IgniteCheckedException { + if (ctx != expected) + return null; + + try { + SSLContext rebuilt = build(); + + X509Certificate[] next = certificateChain(rebuilt); + X509Certificate[] chain = certificateChain(ctx); + + if (next != null && Arrays.equals(next, chain)) { + renewalFailures++; + + return null; + } + + check(next, true); - ctx = rebuilt; + if (chain != null && chainNotAfter(next) <= chainNotAfter(chain)) { + throw new IgniteCheckedException("The new certificate chain expires no later than the one in use; if a certificate " + + "authority in the chain expires first, renew it [" + describe(next[0]) + + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter(next)) + + ", inUseChainNotAfter=" + Instant.ofEpochMilli(chainNotAfter(chain)) + ']'); + } + + putInUse(rebuilt, next); } catch (Throwable e) { + if (!Thread.currentThread().isInterrupted() && + !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) + renewalFailures++; + onFailure(e); throw e; @@ -128,6 +217,19 @@ public String transports() { return certificate(); } + /** + * @param rebuilt Context that passed the checks. + * @param next Chain loaded with it, {@code null} if it is unknown. + */ + private void putInUse(SSLContext rebuilt, @Nullable X509Certificate[] next) { + X509Certificate[] chain = certificateChain(ctx); + + if (next != null && (chain == null || chainNotAfter(next) > chainNotAfter(chain))) + renewalFailures = 0; + + ctx = rebuilt; + } + /** * @param ctx Context just built by the factory. * @return Chain supplied with that context, or {@code null} if the factory supplies no metadata. @@ -153,9 +255,13 @@ private SSLContext build() throws IgniteCheckedException { /** * @param next Chain loaded with the new context, {@code null} if it is unknown. - * @throws IgniteCheckedException If a certificate is outside its validity period. + * @param requireChain Whether automatic renewal requires a known chain. + * @throws IgniteCheckedException If the chain is required but missing, or a certificate is outside its validity period. */ - private void check(@Nullable X509Certificate[] next) throws IgniteCheckedException { + private void check(@Nullable X509Certificate[] next, boolean requireChain) throws IgniteCheckedException { + if (next == null && requireChain) + throw new IgniteCheckedException("The new SSL context provides no certificate chain, so it cannot be renewed automatically"); + long now = System.currentTimeMillis(); X509Certificate invalid = next == null ? null : SslCertificates.invalidAt(next, now); @@ -169,6 +275,9 @@ private void check(@Nullable X509Certificate[] next) throws IgniteCheckedExcepti /** Records a successful reload. */ private void onSuccess() { outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); + + if (renewal != null) + renewal.replan(); } /** @param e Why the reload failed. */ @@ -183,6 +292,11 @@ public ReloadOutcome reloadOutcome() { return outcome; } + /** @return Failed automatic attempts since a chain with a later expiry was put in use, including unchanged certificates. */ + int renewalFailures() { + return renewalFailures; + } + /** * Snapshot of the last successful reload and subsequent failures. * @@ -194,4 +308,9 @@ public ReloadOutcome reloadOutcome() { public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { // No-op. } + + /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ + public long nextRenewalTime() { + return renewal == null ? 0 : renewal.nextRenewalTime(); + } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 7741509f8a2c8..1ed24535af24b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -21,13 +21,17 @@ import java.util.Collection; import java.util.Collections; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.processors.GridProcessorAdapter; import org.apache.ignite.internal.util.typedef.internal.U; -/** SSL contexts of a node: one provider per configured factory, shared by its transports. */ +import static org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor; + +/** SSL contexts of a node: one provider per configured factory, with automatic renewals and their node-wide failure metric. */ public class SslContextRegistry extends GridProcessorAdapter { /** */ public static final String COMMUNICATION = "communication"; @@ -44,9 +48,24 @@ public class SslContextRegistry extends GridProcessorAdapter { /** */ private final Collection providers = new CopyOnWriteArrayList<>(); + /** + * Single-threaded executor for all renewals on this node, so a slow factory does not block a shared pool thread. + * Its thread starts with the first renewal. + */ + private final ScheduledExecutorService renewalExec; + /** @param ctx Kernal context. */ public SslContextRegistry(GridKernalContext ctx) { super(ctx); + + renewalExec = newSingleThreadScheduledExecutor("ssl-renewal", ctx.igniteInstanceName()); + } + + /** {@inheritDoc} */ + @Override public void onKernalStart(boolean active) { + ctx.metric().registry("ssl").register("ConsecutiveRenewalFailures", + () -> providers.stream().mapToInt(SslContextProvider::renewalFailures).sum(), + "Sum of unsuccessful automatic renewals across factories; each factory resets its count after a later-expiring chain."); } /** @@ -61,6 +80,7 @@ public synchronized SSLContext register(Factory factory, String tran if (provider == null) { provider = new SslContextProvider(factory); provider.addTransport(transport); + provider.startRenewal(renewalExec, ctx.log(SslRenewal.class)); warnIfExpired(provider); providers.add(provider); @@ -87,4 +107,16 @@ private void warnIfExpired(SslContextProvider provider) { public Collection providers() { return Collections.unmodifiableCollection(providers); } + + /** Interrupts a renewal attempt in progress and waits for it to end. */ + @Override public void onKernalStop(boolean cancel) { + renewalExec.shutdownNow(); + + try { + renewalExec.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java new file mode 100644 index 0000000000000..3f2b4fda5dff4 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -0,0 +1,225 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteLogger; +import org.apache.ignite.internal.thread.context.OperationContext; +import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.jetbrains.annotations.Nullable; + +/** + * Renews one factory's certificates before expiry when {@link AbstractSslContextFactory#setRenewalEnabled(boolean) renewal is enabled}. + * Renewal planning and attempts run in the node's renewal thread. + */ +class SslRenewal { + /** How the node log names an automatic renewal in {@code initiator=}. */ + private static final String INITIATOR = "automatic renewal"; + + /** + * Interval between warnings that the factory has no newer certificates. A change in expiry status bypasses this interval. + */ + private static final long WAIT_LOG_INTERVAL = TimeUnit.DAYS.toMillis(1); + + /** Initial retry pause and minimum interval between renewal attempts, in milliseconds. */ + private static long minPause = 60_000L; + + /** Maximum retry pause before adding jitter, in milliseconds. */ + private static long maxPause = 3_600_000L; + + /** */ + private final SslContextProvider provider; + + /** Share of the certificate lifetime left when the renewal window opens. */ + private final double renewBeforeFraction; + + /** */ + private final ScheduledExecutorService exec; + + /** */ + private final IgniteLogger log; + + /** Last planned attempt, {@code null} before the first one. */ + private ScheduledFuture next; + + /** Time of the next automatic renewal, {@code 0} if none is planned. */ + private volatile long nextRenewalTime; + + /** Earliest expiry in the chain in use when the renewal was planned. */ + private long chainNotAfter; + + /** How long before {@link #chainNotAfter} the renewal window opens. */ + private long window; + + /** Current retry pause before adding jitter; {@link #plan()} resets it to {@code 0}. */ + private long pause; + + /** When the node last logged that the factory has no newer certificates; {@link #plan()} resets it to {@code 0}. */ + private long waitLogTime; + + /** How late the certificates in use were when that was logged: {@code 0} in time, {@code 1} expiring soon, {@code 2} expired. */ + private int waitLoggedLateness; + + /** + * @param provider Provider whose context the factory builds. + * @param renewBeforeFraction Share of the certificate lifetime left when the renewal window opens. + * @param exec Renewal thread. + * @param log Logger. + */ + SslRenewal(SslContextProvider provider, double renewBeforeFraction, ScheduledExecutorService exec, IgniteLogger log) { + this.provider = provider; + this.renewBeforeFraction = renewBeforeFraction; + this.exec = exec; + this.log = log; + } + + /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ + long nextRenewalTime() { + return nextRenewalTime; + } + + /** Plans the next renewal in the renewal thread. */ + void replan() { + submit(this::plan, 0); + } + + /** Plans the next renewal from the current certificate chain's expiry. */ + private void plan() { + pause = waitLogTime = 0; + + SSLContext planned = provider.context(); + X509Certificate[] chain = SslContextProvider.certificateChain(planned); + + chainNotAfter = SslCertificates.chainNotAfter(chain); + + long lifetime = Math.max(0, chainNotAfter - chain[0].getNotBefore().getTime()); + + window = (long)(lifetime * renewBeforeFraction); + + long at = Math.max(chainNotAfter - window, Math.max(provider.reloadOutcome().successTime() + minPause, U.currentTimeMillis())); + + schedule(at, planned); + + U.log(log, "TLS certificates will be renewed automatically [transports=" + provider.transports() + + ", nextRenewal=" + Instant.ofEpochMilli(at) + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) + ']'); + } + + /** + * Attempts renewal and schedules a retry if the factory fails or returns unchanged certificates. + * + * @param planned Context in use when the attempt was planned. + */ + private void attempt(SSLContext planned) { + try { + X509Certificate renewed = provider.renew(planned); + + if (renewed != null) { + U.log(log, "TLS certificates reloaded [transports=" + provider.transports() + ", " + + SslCertificates.describe(renewed) + ", initiator=" + INITIATOR + ']'); + } + else if (provider.context() == planned) + retry(planned, null); + } + catch (Throwable e) { + retry(planned, e); + } + } + + /** + * Schedules a retry with exponential backoff and logs why renewal did not succeed. Exceptions are logged on every attempt. + * Unchanged certificates are logged once a day, or sooner if an attempt finds they are close to expiry or expired. + * Does nothing during shutdown, since stopping the node interrupts the current attempt. + * + * @param planned Context the attempt was planned for. + * @param e Why the attempt failed, {@code null} if the factory returned the certificates in use. + */ + private void retry(SSLContext planned, @Nullable Throwable e) { + if (exec.isShutdown()) + return; + + long now = U.currentTimeMillis(); + + pause = pause == 0 ? minPause : Math.min(pause * 2, maxPause); + + long at = now + pause + ThreadLocalRandom.current().nextLong(pause / 2 + 1); + + schedule(at, planned); + + boolean soon = now >= chainNotAfter - window / 2 || at >= chainNotAfter; + int lateness = now >= chainNotAfter ? 2 : soon ? 1 : 0; + + if (e == null) { + if (now - waitLogTime < WAIT_LOG_INTERVAL && lateness <= waitLoggedLateness) + return; + + waitLogTime = now; + waitLoggedLateness = lateness; + } + + String msg = (e == null ? "The SSL context factory has no newer TLS certificates yet, the ones in use stay" : + "Failed to reload TLS certificates, the ones in use stay") + + (lateness == 2 ? ", though they have expired" : lateness == 1 ? " and expire soon" : "") + + " [transports=" + provider.transports() + + (e == null ? "" : ", initiator=" + INITIATOR + ", reason=" + SslCertificates.reason(e)) + + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) + ", nextRenewal=" + Instant.ofEpochMilli(at) + ']'; + + if (soon) + U.error(log, msg, e); + else + U.warn(log, msg, e); + } + + /** + * @param at Time of the next attempt. + * @param planned Context the attempt is planned for. + */ + private void schedule(long at, SSLContext planned) { + if (next != null) + next.cancel(false); + + nextRenewalTime = at; + + next = submit(() -> attempt(planned), Math.max(0, at - U.currentTimeMillis())); + } + + /** + * Runs the task in the renewal thread under the node's operation context. This prevents automatic renewal from inheriting + * the security subject of an operator who ran {@code --ssl reload}. + * + * @param task Task. + * @param delay Delay, in milliseconds. + * @return Future of the task, {@code null} if the node is stopping. + */ + private @Nullable ScheduledFuture submit(Runnable task, long delay) { + try (Scope ignored = OperationContext.restoreSnapshot(null)) { + return exec.schedule(task, delay, TimeUnit.MILLISECONDS); + } + catch (RejectedExecutionException stopping) { + return null; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java new file mode 100644 index 0000000000000..74b61104c289f --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -0,0 +1,1304 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.io.Serial; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RunnableScheduledFuture; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.thread.context.OperationContext; +import org.apache.ignite.internal.thread.context.OperationContextAttribute; +import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.metric.IntMetric; +import org.apache.ignite.spi.metric.Metric; +import org.apache.ignite.spi.metric.ReadOnlyMetricRegistry; +import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.SslContextFactory; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.MemorizingAppender; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.Test; + +import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; +import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.AbstractSslContextFactory.DFLT_RENEW_BEFORE_FRACTION; +import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; +import static org.apache.ignite.ssl.SslTestUtils.reload; +import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; +import static org.apache.ignite.ssl.SslTestUtils.status; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; +import static org.junit.Assert.assertArrayEquals; + +/** Tests that a node renews the certificates of a factory with renewal enabled before they expire. */ +public class SslRenewalTest extends GridCommonAbstractTest { + /** */ + private static final long MINUTE = 60_000L; + + /** */ + private static final long HOUR = 60 * MINUTE; + + /** Tolerance in milliseconds for differences in clock precision when checking scheduled times. */ + private static final long CLOCK_SLACK = 50; + + /** Failure of an issuer that cannot be reached. */ + private static final String ISSUER_DOWN = "Issuer is unavailable"; + + /** What the node logs while the factory returns the certificates in use. */ + private static final String WAITING = "The SSL context factory has no newer TLS certificates yet, the ones in use stay"; + + /** Transports that share the node-wide SSL context, as the log lists them. */ + private static final String NODE_TRANSPORTS = "transports=communication, discovery"; + + /** Operation context attribute standing for whoever runs a reload. */ + private static final OperationContextAttribute OPERATOR = OperationContextAttribute.newInstance(); + + /** Default certificate authority for the tests. */ + private TestCertificateAuthority ca; + + /** What the renewals log, with the levels. */ + private final MemorizingAppender appender = new MemorizingAppender(); + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + ca = new TestCertificateAuthority("renewalca"); + + appender.start(); + appender.installSelfOn(SslRenewal.class); + + pauses(100, 1_000); + } + + /** {@inheritDoc} */ + @Override protected void afterTest() { + try { + stopAllGrids(); + } + finally { + appender.removeSelfFrom(SslRenewal.class); + appender.stop(); + + pauses(MINUTE, HOUR); + } + } + + /** Renewal starts within its window, serves the new certificate, schedules its next renewal and stops with the node. */ + @Test + public void testRenewsBeforeExpiry() throws Exception { + long now = System.currentTimeMillis(); + long notAfter = (now + 40_000) / 1000 * 1000; + long notBefore = notAfter - 233_000; + long due = notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + + assertTrue("The window must open seconds after the node starts, well before expiry", due - now > 3_000 && notAfter - due > 30_000); + + IgniteEx g = startNode(new Issuer(valid(notBefore, notAfter), fresh(HOUR))); + + assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); + + assertTrue("The certificate must have been renewed", chainNotAfter(g) > now + 30 * MINUTE); + assertTrue("The renewal must not come before the window opens", + provider(g).reloadOutcome().successTime() >= due - CLOCK_SLACK); + assertEquals("New connections must get the renewed certificate", chainNotAfter(g), + servedCertificate(probe(), discoveryPort(g)).getNotAfter().getTime()); + + long next = dueTime(g); + + assertTrue(waitForCondition(() -> nextRenewalTime(g) == next, 10_000)); + + assertTrue("A node renewing certificates must run the renewal thread", renewalThreadAlive(g.name())); + + stopGrid(0); + + assertTrue("The renewal thread must stop with the node", waitForCondition(() -> !renewalThreadAlive(g.name()), 10_000)); + } + + /** Plans renewal from the authority's expiry when it expires before the leaf certificate. */ + @Test + public void testRenewalPlannedBeforeAuthorityExpiry() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore keys = ca.issue("node", now - HOUR, now + TimeUnit.DAYS.toMillis(400)); + + X509Certificate leaf = (X509Certificate)keys.getCertificate("node"); + X509Certificate authority = (X509Certificate)keys.getCertificateChain("node")[1]; + + long notBefore = leaf.getNotBefore().getTime(); + long notAfter = authority.getNotAfter().getTime(); + long due = notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + + assertTrue("The authority must expire before the leaf", notAfter < leaf.getNotAfter().getTime()); + + IgniteEx g = startNode(new Issuer(() -> keys)); + + assertTrue("Renewal must be planned by the authority's expiry", + waitForCondition(() -> nextRenewalTime(g) == due, 10_000)); + } + + /** With less than half of the window left, failures must be logged as errors and counted, and the status must show the next attempt. */ + @Test + public void testLateFailureLoggedAsError() throws Exception { + long now = System.currentTimeMillis(); + + IgniteEx g = startNode(new Issuer(valid(now - HOUR, now + 2 * MINUTE), failure())); + + String late = "Failed to reload TLS certificates, the ones in use stay and expire soon [" + NODE_TRANSPORTS + + ", initiator=automatic renewal, reason=" + ISSUER_DOWN; + + assertTrue(waitForCondition(() -> logged(Level.ERROR, late) >= 2, 30_000)); + + assertTrue(metrics(g).findMetric("ConsecutiveRenewalFailures").value() >= 2); + assertContains(log, status(g), "next automatic renewal at"); + } + + /** A certificate that expires no later than the one in use must not be put in use. */ + @Test + public void testRenewalNotMovingExpiryRejected() { + long now = System.currentTimeMillis(); + + SslContextProvider p = new SslContextProvider(new Issuer(valid(now - HOUR, now + 5 * MINUTE))); + + assertRenewalRejected(p, "The new certificate chain expires no later than the one in use"); + } + + /** An automatic renewal preserves connections to a peer whose certificate and different authority stay unchanged. */ + @Test + public void testRenewalWithDifferentAuthorities() throws Exception { + checkNodeRotation(new TestCertificateAuthority("peerca")); + } + + /** An expired certificate can be replaced with a valid chain from another authority. */ + @Test + public void testExpiredCertificateRenewed() throws Exception { + long now = System.currentTimeMillis(); + + TestCertificateAuthority other = new TestCertificateAuthority("otherca"); + + Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.issue("node", now, now + HOUR)); + + SslContextProvider p = new SslContextProvider(issuer); + + p.addTransport(COMMUNICATION); + + issuer.trust = other.trustStore(); + + assertNotNull(p.renew(p.context())); + assertEquals("CN=otherca", p.certificate().getIssuerX500Principal().getName()); + } + + /** A renewal logs the certificate it installed even if a manual reload replaces it before the log is written. */ + @Test + public void testRenewalLogsAppliedCertificate() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + MINUTE), + () -> ca.issue("renewed", now, now + HOUR), () -> ca.issue("manual", now, now + 2 * HOUR)); + + SslContextProvider p = new SslContextProvider(issuer) { + @Override X509Certificate renew(SSLContext expected) throws IgniteCheckedException { + X509Certificate renewed = super.renew(expected); + + if (renewed != null) + super.reload(); + + return renewed; + } + }; + + p.addTransport(COMMUNICATION); + + ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1); + + try { + p.startRenewal(exec, log.getLogger(SslRenewal.class)); + + assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 10_000)); + assertEquals("CN=manual", p.certificate().getSubjectX500Principal().getName()); + assertEquals(1, logged(Level.INFO, "TLS certificates reloaded [transports=communication, subject=CN=renewed,")); + } + finally { + exec.shutdownNow(); + + assertTrue(exec.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + /** A certificate that is not valid yet must not be put in use. */ + @Test + public void testRenewalNotValidYetRejected() { + long now = System.currentTimeMillis(); + + SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), valid(now + 10 * MINUTE, now + 2 * HOUR))); + + assertRenewalRejected(p, "The new certificate chain is not valid now"); + } + + /** + * A renewal planned before a reload must not ask for a certificate or replace the one the reload has put in use. The reload + * must leave the factory's {@code create()} returning the first context. + */ + @Test + public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + + Issuer issuer = new Issuer(() -> inUse); + + SslContextProvider p = new SslContextProvider(issuer); + + SSLContext planned = p.context(); + + assertNull(p.renew(planned)); + assertEquals(1, p.renewalFailures()); + + p.reload(); + + SSLContext reloaded = p.context(); + + assertNull(p.renew(planned)); + assertEquals(1, p.renewalFailures()); + assertSame(reloaded, p.context()); + assertEquals("The superseded renewal must not ask for another certificate", 3, issuer.calls.size()); + assertSame(planned, issuer.create()); + } + + /** A reload during renewal planning prevents the outdated attempt from calling the factory or replacing the new context. */ + @Test + public void testReloadDuringRenewalPlanning() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), fresh(2 * HOUR), fresh(3 * HOUR)); + + CountDownLatch planRead = new CountDownLatch(1); + CountDownLatch continuePlanning = new CountDownLatch(1); + CountDownLatch reloaded = new CountDownLatch(1); + CountDownLatch allowReplan = new CountDownLatch(1); + CountDownLatch attemptStarted = new CountDownLatch(1); + + AtomicBoolean firstPlan = new AtomicBoolean(true); + AtomicReference reloadThread = new AtomicReference<>(); + + SslContextProvider p = new SslContextProvider(issuer) { + @Override public ReloadOutcome reloadOutcome() { + ReloadOutcome outcome = super.reloadOutcome(); + + if (firstPlan.getAndSet(false)) { + planRead.countDown(); + + U.awaitQuiet(continuePlanning); + } + + return outcome; + } + + @Override X509Certificate renew(SSLContext expected) throws IgniteCheckedException { + attemptStarted.countDown(); + + return super.renew(expected); + } + }; + + ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1) { + @Override public ScheduledFuture schedule(Runnable task, long delay, TimeUnit unit) { + if (Thread.currentThread() == reloadThread.get()) { + reloaded.countDown(); + + U.awaitQuiet(allowReplan); + } + + return super.schedule(task, delay, unit); + } + }; + IgniteInternalFuture reload = null; + + try { + p.startRenewal(exec, log); + + assertTrue(planRead.await(10, TimeUnit.SECONDS)); + + reload = GridTestUtils.runAsync(() -> { + reloadThread.set(Thread.currentThread()); + + p.reload(); + + return null; + }); + + assertTrue(reloaded.await(10, TimeUnit.SECONDS)); + + SSLContext inUse = p.context(); + + // Let the old plan start its attempt before the reload queues a new plan. + continuePlanning.countDown(); + + assertTrue(attemptStarted.await(10, TimeUnit.SECONDS)); + + allowReplan.countDown(); + + reload.get(10_000); + exec.submit(() -> {}).get(10, TimeUnit.SECONDS); + + assertEquals("Only the initial load and the manual reload may call the factory", 2, issuer.calls.size()); + assertSame(inUse, p.context()); + assertTrue("The next attempt must follow the new certificate", p.nextRenewalTime() > now + HOUR); + } + finally { + continuePlanning.countDown(); + allowReplan.countDown(); + + exec.shutdownNow(); + + if (reload != null) + reload.get(10_000); + + assertTrue(exec.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + /** A successful reload by the command must cancel the planned retry and plan the next renewal by the new certificate. */ + @Test + public void testReloadByCommandReplans() throws Exception { + long now = System.currentTimeMillis(); + + pauses(MINUTE, MINUTE); + + IgniteEx g = startNode(new Issuer(valid(now - HOUR, now + 9 * MINUTE), failure(), fresh(2 * HOUR), fresh(HOUR))); + + assertTrue(waitForCondition(() -> logged(Level.WARN, ISSUER_DOWN) == 1, 30_000)); + + long retry = nextRenewalTime(g); + + assertTrue("A retry must be planned after the pause", + retry >= provider(g).reloadOutcome().failureTime() + MINUTE - CLOCK_SLACK); + + reload(g); + + long due = dueTime(g); + + assertTrue(waitForCondition(() -> nextRenewalTime(g) == due, 10_000)); + + assertTrue("The next renewal must follow the new certificate", due > now + HOUR); + + ScheduledThreadPoolExecutor exec = GridTestUtils.getFieldValue(g.context().sslContexts(), "renewalExec", "delegate"); + + assertTrue("The cancelled retry must not stay planned", exec.getQueue().stream().map(t -> (RunnableScheduledFuture)t) + .filter(f -> !f.isCancelled()).allMatch(f -> f.getDelay(TimeUnit.MILLISECONDS) > HOUR)); + } + + /** Renewals must run in an operation context of their own, not in the one of whoever ran a reload before them. */ + @Test + public void testReloadContextNotCarriedIntoRenewals() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(fresh(HOUR), valid(now - HOUR, now + 9 * MINUTE), fresh(HOUR)); + + IgniteEx g = startNode(issuer); + + try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { + provider(g).reload(); + } + + assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); + + assertEquals("operator", issuer.calls.get(1).operator); + assertNull("The renewal must not run as the operator", issuer.calls.get(2).operator); + } + + /** + * A failed renewal must be retried until it succeeds. Each pause must double, from the shortest to the longest one. While more + * than half of the window is left, each failure must be logged as a warning. The success must clear the count of failures. + */ + @Test + public void testFailedRenewalRetried() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), + failure(), failure(), failure(), failure(), failure(), failure(), fresh(HOUR)); + + pauses(100, 400); + + IgniteEx g = startNode(issuer); + + assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); + + long[] pauses = {100, 200, 400, 400, 400}; + + for (int i = 0; i < pauses.length; i++) { + long pause = issuer.calls.get(i + 2).time - issuer.calls.get(i + 1).time; + + assertTrue("Pause " + (i + 1) + " must last at least " + pauses[i] + " ms: " + pause, pause >= pauses[i] - CLOCK_SLACK); + } + + LogEvent fifth = events(Level.WARN, ISSUER_DOWN).get(4); + + assertTrue("The longest pause must hold, a doubling one would be 1600 ms by now", plannedPause(fifth) <= 600); + + assertEquals(8, issuer.calls.size()); + assertEquals(6, logged(Level.WARN, "initiator=automatic renewal, reason=" + ISSUER_DOWN)); + assertEquals(0, logged(Level.ERROR, ISSUER_DOWN)); + assertEquals(0, metrics(g).findMetric("ConsecutiveRenewalFailures").value()); + assertTrue(chainNotAfter(g) > now + 30 * MINUTE); + } + + /** A failure must be an error once the next attempt would come only after expiry, even with more than half of the window left. */ + @Test + public void testFailureBeforeExpiringRetryLoggedAsError() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 2 * MINUTE), failure()); + + issuer.setRenewBeforeFraction(0.05); + pauses(5 * MINUTE, 5 * MINUTE); + + startNode(issuer); + + assertTrue(waitForCondition(() -> logged(Level.ERROR, "initiator=automatic renewal, reason=" + ISSUER_DOWN) == 1, 30_000)); + } + + /** A certificate due for renewal as soon as it is issued must not make the node renew more often than allowed. */ + @Test + public void testRenewalsNoMoreOftenThanMinPause() throws Exception { + AtomicInteger issued = new AtomicInteger(); + + Callable dueOnIssue = () -> { + long t = System.currentTimeMillis(); + + // A second later each time: notAfter is rounded down to a second, and a renewal that does not move it is refused. + return ca.issue("node", t - HOUR, t + 5 * MINUTE + issued.incrementAndGet() * 1_000L); + }; + + Issuer issuer = new Issuer(dueOnIssue); + + pauses(300, 300); + + startNode(issuer); + + assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) >= 4, 30_000)); + assertTrue("Every attempt must renew the certificate", + appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); + + for (int i = 2; i < 5; i++) { + long pause = issuer.calls.get(i).time - issuer.calls.get(i - 1).time; + + assertTrue("Renewals must be 300 ms apart at least: " + pause, pause >= 300 - CLOCK_SLACK); + } + } + + /** A {@code renewBeforeFraction} out of range must be refused at once. */ + @Test + public void testInvalidSettingsRefused() { + SslContextFactory factory = new SslContextFactory(); + + assertThrowsWithCause(() -> factory.setRenewBeforeFraction(1), IllegalArgumentException.class); + assertThrowsWithCause(() -> factory.setRenewBeforeFraction(0), IllegalArgumentException.class); + } + + /** The client connector with a factory of its own must be renewed on its own. */ + @Test + public void testClientConnectorRenewed() throws Exception { + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 5 * MINUTE), fresh(HOUR)); + + IgniteEx g = startClientConnectorOn(issuer); + + assertTrue(waitForCondition(() -> automaticRenewals("transports=" + CLIENT_CONNECTOR) == 1, 30_000)); + + SslContextProvider p = provider(g, CLIENT_CONNECTOR); + + long notAfter = SslCertificates.chainNotAfter(p.certificateChain()); + + assertTrue(notAfter > now + 30 * MINUTE); + assertTrue(waitForCondition(() -> p.nextRenewalTime() > now + 30 * MINUTE, 10_000)); + + assertEquals("New connections must get the renewed certificate", notAfter, + servedCertificate(probe(), g.context().clientListener().port()).getNotAfter().getTime()); + } + + /** Renewal must be off unless enabled: the node must not ask the factory again, plan a renewal or run the renewal thread. */ + @Test + public void testRenewalOffByDefault() throws Exception { + assertFalse(new SslContextFactory().isRenewalEnabled()); + + long now = System.currentTimeMillis(); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 5 * MINUTE)); + + issuer.setRenewalEnabled(false); + + IgniteEx g = startNode(issuer); + + assertEquals("A certificate in the window must stay", 1, issuer.calls.size()); + assertEquals(0, nextRenewalTime(g)); + assertFalse(renewalThreadAlive(g.name())); + assertTrue(appender.events().isEmpty()); + } + + /** The node sums failures once per factory, and a recovery clears only that factory's contribution. */ + @Test + public void testRenewalFailuresMetric() throws Exception { + try (IgniteEx g = startGrid(0)) { + assertEquals(0, metrics(g).findMetric("ConsecutiveRenewalFailures").value()); + } + + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + + Issuer nodeIssuer = new Issuer(() -> inUse, () -> inUse, fresh(2 * HOUR)); + Issuer clientIssuer = new Issuer(() -> inUse, () -> inUse, () -> inUse, fresh(2 * HOUR)); + + nodeIssuer.setRenewalEnabled(false); + clientIssuer.setRenewalEnabled(false); + + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(nodeIssuer); + cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) + .setUseIgniteSslContextFactory(false).setSslContextFactory(clientIssuer)); + }); + + SslContextProvider node = provider(g); + SslContextProvider client = provider(g, CLIENT_CONNECTOR); + + assertSame(node, provider(g, DISCOVERY)); + + for (ReadOnlyMetricRegistry reg : g.context().metric()) + assertFalse("Transport aliases must not be registered: " + reg.name(), reg.name().startsWith("ssl.")); + + IntMetric failures = metrics(g).findMetric("ConsecutiveRenewalFailures"); + + assertEquals(0, failures.value()); + + assertNull(node.renew(node.context())); + assertEquals(1, failures.value()); + + assertNull(client.renew(client.context())); + assertNull(client.renew(client.context())); + assertEquals(3, failures.value()); + + assertNotNull(node.renew(node.context())); + assertEquals(2, failures.value()); + + assertNotNull(client.renew(client.context())); + assertEquals(0, failures.value()); + } + + /** + * When the factory returns the current certificate, renewal retries with increasing pauses, counts each unsuccessful attempt + * and logs one warning without a stack trace. Manual reload resets the pause and allows a new warning. + * Only the warning after reload must name all transports: the first may be logged before discovery is registered. + */ + @Test + public void testWaitsForNewerCertificate() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + 9 * MINUTE); + + Issuer issuer = new Issuer(() -> inUse); + + IgniteEx g = startNode(issuer); + + assertTrue(waitForCondition(() -> provider(g).renewalFailures() >= 5, 30_000)); + + long grown = issuer.calls.get(5).time - issuer.calls.get(4).time; + + assertTrue("The pause must double while waiting, up to 800 ms by the fifth one: " + grown, grown >= 800 - CLOCK_SLACK); + + assertEquals(1, logged(Level.WARN, WAITING)); + assertEquals(0, logged(Level.ERROR, WAITING)); + assertTrue("The wait must be logged without a stack trace", appender.events().stream().allMatch(e -> e.getThrown() == null)); + + ReadOnlyMetricRegistry reg = metrics(g); + + assertTrue(reg.findMetric("ConsecutiveRenewalFailures").value() >= 5); + assertNull(provider(g).reloadOutcome().failureReason()); + + Iterator metrics = reg.iterator(); + + assertEquals("ssl.ConsecutiveRenewalFailures", metrics.next().name()); + assertFalse("Only the renewal problem counter must be exposed", metrics.hasNext()); + + reload(g); + + assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 2, 30_000)); + + LogEvent afterReload = events(Level.WARN, WAITING).get(1); + + assertContains(log, afterReload.getMessage().getFormattedMessage(), WAITING + " [" + NODE_TRANSPORTS); + assertTrue("The pause must be the shortest one again, not 800 ms or more", plannedPause(afterReload) < 400); + } + + /** + * When no newer certificate is available, log an error once when less than half of the renewal window remains and once at + * expiry. Further attempts must not repeat these messages. + */ + @Test + public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { + AtomicReference inUse = new AtomicReference<>(); + + // Issued on the first request, so that the time left does not depend on how long the node takes to start. + Issuer issuer = new Issuer(() -> { + if (inUse.get() == null) { + long t = System.currentTimeMillis(); + + inUse.set(ca.issue("node", t - 80_000, t + 20_000)); + } + + return inUse.get(); + }); + + issuer.setRenewBeforeFraction(0.22); + + startNode(issuer); + + assertTrue(waitForCondition(() -> logged(Level.ERROR, WAITING + " and expire soon") == 1, 30_000)); + + int calls = issuer.calls.size(); + + assertTrue(waitForCondition(() -> issuer.calls.size() >= calls + 2, 30_000)); + + assertEquals(1, logged(Level.WARN, WAITING)); + assertEquals(1, logged(Level.ERROR, WAITING)); + + assertTrue(waitForCondition(() -> logged(Level.ERROR, WAITING + ", though they have expired") == 1, 30_000)); + + int expiredCalls = issuer.calls.size(); + + assertTrue(waitForCondition(() -> issuer.calls.size() >= expiredCalls + 2, 30_000)); + + assertEquals(1, logged(Level.WARN, WAITING)); + assertEquals(2, logged(Level.ERROR, WAITING)); + } + + /** A node that stops in the middle of a renewal attempt must interrupt it and log no failure. */ + @Test + public void testStopDuringRenewalLogsNoFailure() throws Exception { + long now = System.currentTimeMillis(); + + CountDownLatch entered = new CountDownLatch(1); + + Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), () -> { + entered.countDown(); + + Thread.sleep(Long.MAX_VALUE); + + return null; + }); + + IgniteEx g = startNode(issuer); + + assertTrue(entered.await(30, TimeUnit.SECONDS)); + + SslContextProvider p = provider(g); + + stopGrid(0); + + assertEquals(0, p.renewalFailures()); + assertTrue(appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); + } + + /** Cached and expired unchanged chains stay in use and count as unsuccessful renewal attempts. */ + @Test + public void testSameCertificatesUnchanged() throws Exception { + long now = System.currentTimeMillis(); + + SSLContext cached = TestCertificateAuthority.context(ca.issue("node", now - HOUR, now + HOUR), ca.trustStore()); + + SslContextProvider p = new SslContextProvider(() -> cached); + + assertNull(p.renew(p.context())); + assertEquals(1, p.renewalFailures()); + + assertNull(p.renew(p.context())); + assertEquals(2, p.renewalFailures()); + assertEquals(0, p.reloadOutcome().failures()); + + KeyStore expired = ca.issue("node", now - 2 * HOUR, now - HOUR); + + SslContextProvider expiredProvider = new SslContextProvider(new Issuer(() -> expired)); + + SSLContext expiredCtx = expiredProvider.context(); + + assertNull(expiredProvider.renew(expiredCtx)); + assertSame(expiredCtx, expiredProvider.context()); + assertEquals(1, expiredProvider.renewalFailures()); + } + + /** Returning the same certificate and throwing both count until a renewal extends the chain's lifetime. */ + @Test + public void testUnsuccessfulRenewalsCounted() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + + SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, fresh(2 * HOUR))); + + assertNull(p.renew(p.context())); + assertEquals(1, p.renewalFailures()); + + GridTestUtils.assertThrows(log, () -> p.renew(p.context()), IgniteException.class, ISSUER_DOWN); + + assertEquals(2, p.renewalFailures()); + assertNull(p.renew(p.context())); + assertEquals(3, p.renewalFailures()); + assertEquals(1, p.reloadOutcome().failures()); + + assertNotNull(p.renew(p.context())); + assertEquals(0, p.renewalFailures()); + } + + /** Manual reloads neither count as renewal attempts nor clear their failures unless the chain expires later. */ + @Test + public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + + Issuer issuer = new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, () -> inUse, + valid(now - HOUR, now + HOUR), fresh(2 * HOUR)); + + SslContextProvider p = new SslContextProvider(issuer); + + assertNull(p.renew(p.context())); + assertEquals(1, p.renewalFailures()); + + // A failed manual reload is not an automatic renewal attempt. + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); + + assertEquals(1, p.renewalFailures()); + + // Reloading the same certificate clears the reload error, but not the renewal failures. + p.reload(); + + assertEquals(1, p.renewalFailures()); + assertNull(p.reloadOutcome().failureReason()); + + TestCertificateAuthority other = new TestCertificateAuthority("otherca"); + + issuer.trust = TestCertificateAuthority.trustStore(ca, other); + + // Updating only the trust store does not renew the certificate. + p.reload(); + + assertEquals(1, p.renewalFailures()); + + // A different certificate with the same expiry still does not extend the chain's lifetime. + p.reload(); + + assertFalse(inUse.getCertificate("node").equals(p.certificate())); + assertEquals(1, p.renewalFailures()); + + // Only the final certificate extends the chain's lifetime. + p.reload(); + + assertEquals(0, p.renewalFailures()); + } + + /** Extending only the leaf does not clear failures when the authority still expires first. */ + @Test + public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { + long now = System.currentTimeMillis(); + + KeyStore inUse = ca.issue("node", now - HOUR, now + TimeUnit.DAYS.toMillis(400)); + + SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, + valid(now - HOUR, now + TimeUnit.DAYS.toMillis(450)))); + + long chainExpiry = SslCertificates.chainNotAfter(p.certificateChain()); + long leafExpiry = p.certificate().getNotAfter().getTime(); + + assertNull(p.renew(p.context())); + + p.reload(); + + assertTrue(p.certificate().getNotAfter().getTime() > leafExpiry); + assertEquals(chainExpiry, SslCertificates.chainNotAfter(p.certificateChain())); + assertEquals(1, p.renewalFailures()); + } + + /** The file-based factory with renewal enabled must put in use the key store replaced on disk, with no command. */ + @Test + public void testReplacedKeyStoreFileRenewed() throws Exception { + long now = System.currentTimeMillis(); + + Path keyStore = Files.createTempFile("ignite-ssl-renewal-", ".p12"); + + try { + TestCertificateAuthority.save(ca.issue("node", now - HOUR, now + 9 * MINUTE), keyStore); + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreFilePath(keyStore.toString()); + factory.setKeyStorePassword(TestCertificateAuthority.PWD); + factory.setKeyStoreType("PKCS12"); + factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); + factory.setRenewalEnabled(true); + + IgniteEx g = startNode(factory); + + assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 1, 30_000)); + + TestCertificateAuthority.save(ca.issue("node", now, now + HOUR), keyStore); + + assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); + assertTrue(chainNotAfter(g) > now + 30 * MINUTE); + } + finally { + Files.delete(keyStore); + } + } + + /** Automatic renewal without certificate metadata must stop the node from starting. */ + @Test + public void testUnknownCertificateRefusedAtStart() { + Issuer issuer = new Issuer(SslRenewalTest::noKey); + + GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, + "The SSL context factory provides no certificate chain"); + } + + /** Renewal rejects a store without a private key and retains the context and chain in use. */ + @Test + public void testKeyStoreWithoutKeyNotRenewed() { + SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), SslRenewalTest::noKey)); + + assertRenewalRejected(p, "The new key material has no private key, so the node would present no certificate"); + } + + /** Renewal settings are fixed at registration; changing the factory must not let reload lose the chain used by renewal. */ + @Test + public void testReloadCannotLoseMetadataWithRenewal() { + Issuer issuer = new Issuer(fresh(HOUR), this::twoKeys); + SslContextProvider p = new SslContextProvider(issuer); + + issuer.setRenewalEnabled(false); + + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + String reason = "The new SSL context provides no certificate chain, so it cannot be renewed automatically"; + + assertNotNull(chain); + + GridTestUtils.assertThrows(log, () -> { + p.reload(); + + return null; + }, IgniteCheckedException.class, reason); + + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + + assertRenewalRejected(p, reason); + } + + /** @return Store with two valid private keys and therefore no single chain for renewal metadata. */ + private KeyStore twoKeys() throws Exception { + long now = System.currentTimeMillis(); + KeyStore keys = ca.issue("node", now - MINUTE, now + HOUR); + KeyStore other = ca.issue("other", now - MINUTE, now + HOUR); + + keys.setKeyEntry("other", other.getKey("other", TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + other.getCertificateChain("other")); + + return keys; + } + + /** @return Key store with no private key. */ + private static KeyStore noKey() throws Exception { + KeyStore noKey = KeyStore.getInstance(KeyStore.getDefaultType()); + + noKey.load(null, null); + + return noKey; + } + + /** + * @param factory SSL context factory of the client connector. + * @return Started node whose client connector runs on the factory and whose other transports run without SSL. + */ + private IgniteEx startClientConnectorOn(AbstractSslContextFactory factory) throws Exception { + return startGrid(getTestIgniteInstanceName(0), cfg -> cfg + .setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) + .setUseIgniteSslContextFactory(false).setSslContextFactory(factory))); + } + + /** + * @param factory SSL context factory of the node. + * @return Started node. + */ + private IgniteEx startNode(AbstractSslContextFactory factory) throws Exception { + return startNode(0, factory); + } + + /** + * @param idx Node index. + * @param factory SSL context factory of the node. + * @return Started node. + */ + private IgniteEx startNode(int idx, AbstractSslContextFactory factory) throws Exception { + return startGrid(getTestIgniteInstanceName(idx), cfg -> cfg.setSslContextFactory(factory)); + } + + /** + * @param source Key store to use on each request. + * @param trust Authorities the node accepts. + * @param renewal Whether the node renews automatically. + * @return Configured issuer. + */ + private Issuer issuer(Callable source, KeyStore trust, boolean renewal) { + Issuer issuer = new Issuer(source); + + issuer.trust = trust; + issuer.setRenewalEnabled(renewal); + + return issuer; + } + + /** + * Rotates one node's certificate, then restarts each node to check that they load their current certificates and reconnect. + * + * @param peerCa Authority of the peer whose certificate stays unchanged. + */ + private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception { + long now = System.currentTimeMillis(); + + AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - HOUR, now + 9 * MINUTE)); + KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + Issuer issuer = issuer(keys::get, peerCa.trustStore(), true); + Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore(), false); + + IgniteEx g0 = startNode(0, issuer); + IgniteEx g1 = startNode(1, peerIssuer); + + X509Certificate peerCert = provider(g1).certificate(); + + assertFalse(provider(g0).certificate().equals(peerCert)); + assertComputeWorks(g0, g1); + + KeyStore replacement = ca.issue("node0-new", now, now + 2 * HOUR); + X509Certificate renewed = (X509Certificate)replacement.getCertificate("node0-new"); + + keys.set(replacement); + + assertTrue(waitForCondition(() -> renewed.equals(provider(g0).certificate()), 30_000)); + + assertEquals(renewed, provider(g0).certificate()); + assertEquals(peerCert, provider(g1).certificate()); + assertEquals(0, provider(g1).reloadOutcome().successTime()); + assertEquals(renewed, servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); + assertEquals(peerCert, servedCertificate(issuer.createNew(), discoveryPort(g1))); + + assertComputeWorks(g0, g1); + + stopGrid(1); + + g1 = startNode(1, issuer(() -> peerKeys, ca.trustStore(), false)); + + assertEquals(renewed, provider(g0).certificate()); + assertEquals(peerCert, provider(g1).certificate()); + assertComputeWorks(g0, g1); + + stopGrid(0); + + IgniteEx restarted = startNode(0, issuer(keys::get, peerCa.trustStore(), true)); + + assertEquals(renewed, provider(restarted).certificate()); + assertEquals(renewed, servedCertificate(peerIssuer.createNew(), discoveryPort(restarted))); + assertEquals(peerCert, provider(g1).certificate()); + assertComputeWorks(restarted, g1); + } + + /** Runs a job from each node on its peer. */ + private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { + for (IgniteEx from : List.of(g0, g1)) { + IgniteEx to = from == g0 ? g1 : g0; + + assertEquals((Integer)1, from.compute(from.cluster().forNodeId(to.localNode().id())).withTimeout(10_000).call(() -> 1)); + } + } + + /** + * @param notBefore Time the certificate becomes valid. + * @param notAfter Time it expires. + * @return Step that issues a certificate valid for that time. + */ + private Callable valid(long notBefore, long notAfter) { + return () -> ca.issue("node", notBefore, notAfter); + } + + /** + * @param lifetime Lifetime of the certificate. + * @return Step that issues a certificate valid from the moment it is issued. + */ + private Callable fresh(long lifetime) { + return () -> { + long now = System.currentTimeMillis(); + + return ca.issue("node", now, now + lifetime); + }; + } + + /** @return Step that simulates an unreachable issuer. */ + private static Callable failure() { + return () -> { + throw new IgniteException(ISSUER_DOWN); + }; + } + + /** @return Context to connect to the nodes with; a new one each time, so that no session is resumed with an old certificate. */ + private SSLContext probe() throws Exception { + long now = System.currentTimeMillis(); + + return TestCertificateAuthority.context(ca.issue("probe", now - MINUTE, now + HOUR), ca.trustStore()); + } + + /** + * @param min Pause after the first unsuccessful renewal attempt, in milliseconds. + * @param max Longest pause, in milliseconds. + */ + private static void pauses(long min, long max) { + GridTestUtils.setFieldValue(SslRenewal.class, "minPause", min); + GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", max); + } + + /** + * @param p Provider. + * @param reason Reason the renewal must be rejected for. + */ + private static void assertRenewalRejected(SslContextProvider p, String reason) { + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + int failures = p.renewalFailures(); + + GridTestUtils.assertThrows(log, () -> p.renew(inUse), IgniteCheckedException.class, reason); + + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + assertEquals(failures + 1, p.renewalFailures()); + } + + /** + * @param level Level. + * @param text Text the message contains. + * @return Number of renewal messages containing the text at the given level. + */ + private long logged(Level level, String text) { + return events(level, text).size(); + } + + /** + * @param level Level. + * @param text Text the message contains. + * @return Renewal messages containing the text at the given level, in log order. + */ + private List events(Level level, String text) { + return appender.events().stream().filter(e -> e.getLevel() == level && e.getMessage().getFormattedMessage().contains(text)) + .collect(Collectors.toList()); + } + + /** + * @param transports Transports as the log names them. + * @return How many times the renewals put new certificates in use. + */ + private long automaticRenewals(String transports) { + return logged(Level.INFO, "TLS certificates reloaded [" + transports); + } + + /** + * @param e Event logged by an unsuccessful renewal attempt. + * @return Time from the event to the next attempt it planned, in milliseconds. + */ + private static long plannedPause(LogEvent e) { + String msg = e.getMessage().getFormattedMessage(); + int from = msg.indexOf("nextRenewal=") + "nextRenewal=".length(); + + return Instant.parse(msg.substring(from, msg.indexOf(']', from))).toEpochMilli() - e.getTimeMillis(); + } + + /** + * @param g Node. + * @return Start of the renewal window, calculated with the default renewal fraction. + */ + private static long dueTime(IgniteEx g) { + long notBefore = provider(g).certificate().getNotBefore().getTime(); + long notAfter = chainNotAfter(g); + + return notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + } + + /** */ + private static long nextRenewalTime(IgniteEx g) { + return provider(g).nextRenewalTime(); + } + + /** */ + private static long chainNotAfter(IgniteEx g) { + return SslCertificates.chainNotAfter(provider(g).certificateChain()); + } + + /** + * @param g Node. + * @return SSL metrics of the node. + */ + private static ReadOnlyMetricRegistry metrics(IgniteEx g) { + for (ReadOnlyMetricRegistry reg : g.context().metric()) { + if (reg.name().equals("ssl")) + return reg; + } + + throw new AssertionError("SSL metrics are not registered"); + } + + /** + * @param g Node. + * @return SSL context provider used by communication. + */ + private static SslContextProvider provider(IgniteEx g) { + return provider(g, COMMUNICATION); + } + + /** + * @param g Node. + * @param transport Transport. + * @return SSL context provider used by the transport. + */ + private static SslContextProvider provider(IgniteEx g, String transport) { + for (SslContextProvider p : g.context().sslContexts().providers()) { + if (p.transports().contains(transport)) + return p; + } + + throw new AssertionError("Nothing serves " + transport); + } + + /** + * @param name Node name. + * @return Whether a renewal thread of the node is alive. + */ + private static boolean renewalThreadAlive(String name) { + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.isAlive() && t.getName().startsWith("ssl-renewal") && t.getName().contains(name)) + return true; + } + + return false; + } + + /** One request to an issuer. */ + private static class Call { + /** Time the request arrived, in milliseconds since the epoch. */ + private final long time = System.currentTimeMillis(); + + /** Operation context attribute standing for the operator, as the request saw it. */ + private final @Nullable String operator = OperationContext.get(OPERATOR); + } + + /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ + private class Issuer extends AbstractSslContextFactory { + /** */ + @Serial + private static final long serialVersionUID = 0L; + + /** Answers to the requests in order, the last one repeated for the rest. */ + private final List> steps; + + /** Requests so far. */ + private final List calls = new CopyOnWriteArrayList<>(); + + /** Trust store for new contexts, or {@code null} to trust only the default test authority. */ + private volatile KeyStore trust; + + /** @param steps Answers to the requests in order, the last one repeated for the rest. */ + @SafeVarargs + private Issuer(Callable... steps) { + this.steps = List.of(steps); + + setRenewalEnabled(true); + } + + /** {@inheritDoc} */ + @Override protected void checkParameters() { + // No-op. + } + + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers() throws SSLException { + return createKeyMaterial().keyManagers(); + } + + /** {@inheritDoc} */ + @Override protected KeyMaterial createKeyMaterial() throws SSLException { + Callable step = steps.get(Math.min(calls.size(), steps.size() - 1)); + + calls.add(new Call()); + + try { + KeyStore store = step.call(); + + return KeyMaterial.fromKeyStore(TestCertificateAuthority.keyManagers(store), store); + } + catch (IgniteException e) { + throw e; + } + catch (Exception e) { + throw new SSLException(e); + } + } + + /** {@inheritDoc} */ + @Override protected TrustManager[] createTrustManagers() throws SSLException { + try { + return TestCertificateAuthority.trustManagers(trust != null ? trust : ca.trustStore()); + } + catch (Exception e) { + throw new SSLException(e); + } + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index e753928726dbc..194a80fb6d709 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -75,6 +75,7 @@ import org.apache.ignite.internal.processors.security.service.ServiceStaticConfigTest; import org.apache.ignite.internal.processors.security.snapshot.SnapshotPermissionCheckTest; import org.apache.ignite.internal.ssl.SslContextProviderTest; +import org.apache.ignite.internal.ssl.SslRenewalTest; import org.apache.ignite.internal.thread.context.ComputeTaskOperationContextPropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributePropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributesTest; @@ -147,6 +148,7 @@ SslContextMetadataTest.class, SslContextProviderTest.class, SslContextReloadTest.class, + SslRenewalTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, From 3178e42142613d575991aba21058c5f0219a27b9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:51:45 +0300 Subject: [PATCH 005/242] IGNITE-29211 Restore renewal prerequisites and guide navigation --- docs/_docs/security/ssl-tls.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index e64ed29451c45..b8612cdea8419 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -62,7 +62,7 @@ tab:C#/.NET[] tab:C++[unsupported] -- -The keystore must contain the node's certificate and private key. +The keystore must contain the node's certificate and private key. For automatic renewal, keep exactly one private key and its X.509 chain in each store. The trust store defines which peer certificates the node accepts. Configure trust on all nodes and clients before replacing certificates. @@ -163,6 +163,7 @@ include::{javaFile}[tag=disable-validation,indent=0] == Reload or Restart Use link:tools/control-script#reloading-certificates[`control.sh --ssl reload`] after manually replacing a node's certificate or trust store. +For automatically delivered certificates, see <>. [cols="2,3",opts="header"] |=== From ae4204169a2ce22833d1856624423ef74c96d665 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 20:12:15 +0300 Subject: [PATCH 006/242] IGNITE-29209 Preserve peer addresses in metadata wrappers --- .../java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java | 2 +- .../java/org/apache/ignite/ssl/SslContextMetadataTest.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 170cd5280561a..060ef9f83d6e6 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -72,7 +72,7 @@ class DelegatingSSLContextSpi extends SSLContextSpi { /** {@inheritDoc} */ @Override protected SSLEngine engineCreateSSLEngine(String s, int i) { - final SSLEngine engine = delegate.createSSLEngine(); + final SSLEngine engine = delegate.createSSLEngine(s, i); if (parameters != null) engine.setSSLParameters(parameters); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index cc26636f98ae4..27c12f0349208 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -197,7 +197,7 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); } - /** Adding metadata without TLS parameters preserves the default engine settings and socket factory classes. */ + /** Adding metadata without TLS parameters preserves the peer address, default settings and socket factory classes. */ @Test public void testMetadataWrapperPreservesContextBehavior() throws Exception { SSLContext delegate = SSLContext.getInstance("TLS"); @@ -205,8 +205,10 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { delegate.init(null, null, null); SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(loadStore("node01"), "node01")); - SSLEngine engine = wrapper.createSSLEngine(); + SSLEngine engine = wrapper.createSSLEngine("peer.example", 10800); + assertEquals("peer.example", engine.getPeerHost()); + assertEquals(10800, engine.getPeerPort()); assertArrayEquals(delegate.createSSLEngine().getEnabledProtocols(), engine.getEnabledProtocols()); assertArrayEquals(delegate.createSSLEngine().getEnabledCipherSuites(), engine.getEnabledCipherSuites()); assertEquals(delegate.getSocketFactory().getClass(), wrapper.getSocketFactory().getClass()); From 6a1597b4c09d7d2ca27be033b8a24ce7d44462b5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 20:13:46 +0300 Subject: [PATCH 007/242] IGNITE-29211 Exclude interrupted renewal from reload outcome --- .../org/apache/ignite/internal/ssl/SslContextProvider.java | 5 +++-- .../java/org/apache/ignite/internal/ssl/SslRenewalTest.java | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index bc261f40a73cf..5e72ec5f969e6 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -204,10 +204,11 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { } catch (Throwable e) { if (!Thread.currentThread().isInterrupted() && - !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) + !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) { renewalFailures++; - onFailure(e); + onFailure(e); + } throw e; } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 74b61104c289f..74b78534afb63 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -716,7 +716,7 @@ public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { assertEquals(2, logged(Level.ERROR, WAITING)); } - /** A node that stops in the middle of a renewal attempt must interrupt it and log no failure. */ + /** A node that stops in the middle of a renewal attempt must interrupt it without recording or logging a failure. */ @Test public void testStopDuringRenewalLogsNoFailure() throws Exception { long now = System.currentTimeMillis(); @@ -740,6 +740,7 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { stopGrid(0); assertEquals(0, p.renewalFailures()); + assertEquals(0, p.reloadOutcome().failures()); assertTrue(appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); } From c06c291ad432222cfca8d20540f85e9cbcda7f05 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 20:16:12 +0300 Subject: [PATCH 008/242] IGNITE-29210 Report unavailable SSL command targets --- docs/_docs/tools/control-script.adoc | 3 ++- .../management/ssl/AbstractSslTask.java | 27 ++++++++++++++++--- .../ignite/ssl/SslContextReloadTest.java | 26 ++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index ff1b118577c88..f075090468b0b 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1784,7 +1784,8 @@ Each result line lists the transports that share an SSL context factory and are If that update fails, those transports keep their previous context. Successful updates stay in use, even on a node where another factory failed. The command lists failed nodes first and exits with an error if any update failed. -Correct the reported cause and run reload again. Every node also records its result in the log, including the user's login and network address when security is enabled. +Correct the reported cause and run reload again. +Each factory reload attempt is also recorded in the node log, including the user's login and network address when security is enabled. A successful result means the new SSL context is in use for new connections. Use `--ssl status` to check the loaded certificate, then test new connections as described in link:security/ssl-tls#upgrading-certificates[Upgrading Certificates]. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java index e90153233a596..4ab76fbc179df 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/AbstractSslTask.java @@ -19,26 +19,41 @@ import java.io.Serial; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.UUID; import org.apache.ignite.IgniteException; import org.apache.ignite.cluster.ClusterNode; import org.apache.ignite.compute.ComputeJobResult; import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.visor.VisorMultiNodeTask; +import org.apache.ignite.internal.visor.VisorTaskArgument; import org.apache.ignite.lang.IgniteBiTuple; import static org.apache.ignite.internal.ssl.SslCertificates.reason; /** - * Collects {@code --ssl} command results from all mapped nodes, listing failures first. - * Each job returns a success flag and a report. If any job reports a failure or throws an exception, the task fails with - * a report containing both failed and successful results. + * Collects {@code --ssl} command results from all selected nodes, listing failures first. + * Each job returns a success flag and a report. If a selected node is unavailable, or a job fails, the task fails with a report + * containing both failed and successful results. */ public abstract class AbstractSslTask extends VisorMultiNodeTask> { /** */ @Serial private static final long serialVersionUID = 0L; + /** Nodes selected by the command, including those that leave before jobs are mapped. */ + private Collection nodeIds = List.of(); + + /** {@inheritDoc} */ + @Override protected Collection jobNodes(VisorTaskArgument arg) { + nodeIds = List.copyOf(super.jobNodes(arg)); + + return nodeIds; + } + /** * @param node Node. * @return Node UUID and consistent ID for command output. @@ -51,8 +66,11 @@ static String node(ClusterNode node) { @Override protected String reduce0(List results) throws IgniteException { List failed = new ArrayList<>(); List succeeded = new ArrayList<>(); + Set missing = new LinkedHashSet<>(nodeIds); for (ComputeJobResult jobRes : results) { + missing.remove(jobRes.getNode().id()); + IgniteException e = jobRes.getException(); if (e != null) @@ -64,6 +82,9 @@ static String node(ClusterNode node) { } } + for (UUID id : missing) + failed.add(id + ": node was unavailable to run the command"); + if (failed.isEmpty()) return String.join("\n", succeeded) + '\n'; diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 28ffe214f4e4b..77530021f8b2a 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -37,13 +37,17 @@ import org.apache.ignite.configuration.ConnectorConfiguration; import org.apache.ignite.configuration.IgniteConfiguration; import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.management.ssl.AbstractSslTask; import org.apache.ignite.internal.management.ssl.SslReloadTask; +import org.apache.ignite.internal.management.ssl.SslStatusTask; import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.ssl.SslContextProvider; import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.util.typedef.G; import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.internal.visor.VisorTaskArgument; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.testframework.GridTestNode; @@ -515,6 +519,28 @@ public void testFailedJobNamesNode() { assertContains(log, e.getMessage(), "Failed on 1 node(s):\n" + n.id() + " [consistentId=cid]: node left"); } + /** A target missing at mapping fails both commands without preventing work on the remaining node. */ + @Test + public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { + IgniteEx g = startGrid(0); + UUID unavailable = UUID.randomUUID(); + + placeKeys("node02"); + + VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); + + for (Class task : List.of(SslReloadTask.class, SslStatusTask.class)) { + Throwable err = GridTestUtils.assertThrows(log, () -> g.compute().execute(task, arg).result(), IgniteException.class, null); + + assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + + ": node was unavailable to run the command"); + assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); + assertContains(log, err.getMessage(), "loaded subject=CN=node02,"); + } + + assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); + } + /** Both commands report that no SSL context factory is configured. */ @Test public void testWithoutSsl() throws Exception { From 0802865a3e198fd6e98ef188ed29665dcf1cd77a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:20:01 +0300 Subject: [PATCH 009/242] IGNITE-29209 Name SSLEngine peer parameters host and port --- .../java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 060ef9f83d6e6..193aa6c6a2013 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -71,8 +71,8 @@ class DelegatingSSLContextSpi extends SSLContextSpi { } /** {@inheritDoc} */ - @Override protected SSLEngine engineCreateSSLEngine(String s, int i) { - final SSLEngine engine = delegate.createSSLEngine(s, i); + @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { + final SSLEngine engine = delegate.createSSLEngine(host, port); if (parameters != null) engine.setSSLParameters(parameters); From a9e849bfa8f01ccb580fc3b4a55549fb3e49cb4c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:20:01 +0300 Subject: [PATCH 010/242] IGNITE-29210 Name SSLEngine peer parameters host and port --- .../java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 060ef9f83d6e6..193aa6c6a2013 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -71,8 +71,8 @@ class DelegatingSSLContextSpi extends SSLContextSpi { } /** {@inheritDoc} */ - @Override protected SSLEngine engineCreateSSLEngine(String s, int i) { - final SSLEngine engine = delegate.createSSLEngine(s, i); + @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { + final SSLEngine engine = delegate.createSSLEngine(host, port); if (parameters != null) engine.setSSLParameters(parameters); From 078f587e7b59b95aa1d4250d9e9474efea3e00cb Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:20:02 +0300 Subject: [PATCH 011/242] IGNITE-29211 Name SSLEngine peer parameters host and port --- .../java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 060ef9f83d6e6..193aa6c6a2013 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -71,8 +71,8 @@ class DelegatingSSLContextSpi extends SSLContextSpi { } /** {@inheritDoc} */ - @Override protected SSLEngine engineCreateSSLEngine(String s, int i) { - final SSLEngine engine = delegate.createSSLEngine(s, i); + @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { + final SSLEngine engine = delegate.createSSLEngine(host, port); if (parameters != null) engine.setSSLParameters(parameters); From cf8143f403bcdc19e2d7451adfd6cdc8eb30e1e4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:53:44 +0300 Subject: [PATCH 012/242] IGNITE-29209 Allow factories to implement only createKeyMaterial --- .../ignite/ssl/AbstractSslContextFactory.java | 17 ++++-- .../apache/ignite/ssl/SslContextFactory.java | 7 ++- .../ignite/ssl/SslContextMetadataTest.java | 60 ++++++++++++++++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 2cf140ba2e2b7..07599a5301ad7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -175,15 +175,20 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract void checkParameters() throws SSLException; /** - * @return Created Key Managers. - * @throws SSLException If Key Managers could not be created. + * Creates key managers without certificate metadata. + * + * @return Created key managers. + * @throws SSLException If this method is not overridden or key managers cannot be created. + * @deprecated Override {@link #createKeyMaterial()} to return key managers with their certificate chain. */ - protected abstract KeyManager[] createKeyManagers() throws SSLException; + @Deprecated + protected KeyManager[] createKeyManagers() throws SSLException { + throw new SSLException("Subclass must override createKeyMaterial() or createKeyManagers()"); + } /** - * Creates key managers and their certificate chain from the same load of the key material. The default calls - * {@link #createKeyManagers()} and leaves the chain unknown. Subclasses that can provide certificate metadata should - * override this method and supply the chain without loading the material again. + * Creates key managers and their certificate chain from the same load of the key material. New subclasses should + * override this method. The default calls the legacy {@link #createKeyManagers()} hook and leaves the chain unknown. * * @return Key managers and their chain, if known. * @throws SSLException If the key material could not be loaded. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index e8525771f6250..05940885e1224 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -258,7 +258,12 @@ public static TrustManager getDisabledTrustManager() { return new DisabledX509TrustManager(); } - /** {@inheritDoc} */ + /** + * {@inheritDoc} + * + * @deprecated Use {@link #createKeyMaterial()} to retain certificate metadata. + */ + @Deprecated @Override protected final KeyManager[] createKeyManagers() throws SSLException { return createKeyMaterial().keyManagers(); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 27c12f0349208..01395a1625314 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -29,8 +29,10 @@ 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; import javax.net.ssl.TrustManager; +import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -146,7 +148,44 @@ public void testLegacyOverridesAreUsedWithoutMetadata() { assertEquals(2, factory.trust); } - /** Neither the source arrays nor arrays returned to callers can replace the recorded key managers and chain. */ + /** A subclass can still obtain key managers from the file factory's legacy hook. */ + @Test + public void testLegacyKeyManagerAdapter() throws Exception { + StoreFactory factory = new StoreFactory(loadStore("node01")); + KeyManager[] mgrs = factory.legacyKeyManagers(); + + assertNotNull(mgrs); + assertTrue(mgrs.length > 0); + assertEquals(1, factory.loads); + } + + /** A factory that implements neither key-material hook reports the missing implementation. */ + @Test + public void testMissingKeyMaterialImplementation() { + AbstractSslContextFactory factory = new AbstractSslContextFactory() { + /** {@inheritDoc} */ + @Override protected void checkParameters() { + // No parameters are required by this factory. + } + + /** {@inheritDoc} */ + @Override protected TrustManager[] createTrustManagers() { + return null; + } + }; + + String msg = "Subclass must override createKeyMaterial() or createKeyManagers()"; + + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, msg); + + assertTrue(err.getCause() instanceof SSLException); + + err = GridTestUtils.assertThrows(log, factory::createNew, IgniteException.class, msg); + + assertTrue(err.getCause() instanceof SSLException); + } + + /** A subclass can supply material directly, and callers cannot replace its recorded managers or chain through arrays. */ @Test public void testKeyMaterialDefensiveCopies() throws Exception { KeyManager mgr = new KeyManager() { }; @@ -161,14 +200,25 @@ public void testKeyMaterialDefensiveCopies() throws Exception { assertSame(mgr, material.keyManagers()[0]); - LegacyFactory factory = new LegacyFactory() { + AbstractSslContextFactory factory = new AbstractSslContextFactory() { + /** {@inheritDoc} */ + @Override protected void checkParameters() { + // No parameters are required by this factory. + } + /** {@inheritDoc} */ @Override protected KeyMaterial createKeyMaterial() { return material; } + + /** {@inheritDoc} */ + @Override protected TrustManager[] createTrustManagers() { + return null; + } }; assertArrayEquals(expected, chain(factory.create())); + assertArrayEquals(expected, chain(factory.createNew())); } /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ @@ -266,6 +316,12 @@ private static class StoreFactory extends SslContextFactory { return store; } + + /** @return Key managers supplied by the inherited legacy hook. */ + @SuppressWarnings("deprecation") + private KeyManager[] legacyKeyManagers() throws SSLException { + return super.createKeyManagers(); + } } /** A subclass that implements the manager hooks without overriding createKeyMaterial(). */ From 42d5fe4cbecbdb58a4c8cdcb4a6a509ddc6f968d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 22:13:03 +0300 Subject: [PATCH 013/242] IGNITE-29209 Verify missing-key metadata and clarify wrapper constructors --- .../apache/ignite/ssl/SSLContextWrapper.java | 6 +++++ .../ignite/ssl/SslContextMetadataTest.java | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java index 22b44b85db234..af2110151203b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java @@ -33,6 +33,9 @@ public class SSLContextWrapper extends SSLContext { private final boolean privateKeyMissing; /** + * Wraps a context with custom SSL parameters. If the delegate is an {@code SSLContextWrapper}, copies its + * certificate chain and missing-private-key flag. + * * @param delegate Wrapped SSL context. * @param sslParameters Extended SSL parameters. */ @@ -45,6 +48,8 @@ public SSLContextWrapper(SSLContext delegate, SSLParameters sslParameters) { /** * Wraps a context with its loaded certificate chain. The chain describes the key material, not the outcome of a TLS * handshake. The delegate must not be reinitialized with different key material after wrapping. + * This constructor uses the supplied chain and sets the missing-private-key flag to {@code false}. + * It does not copy metadata from the delegate. * * @param delegate Wrapped SSL context. * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. @@ -56,6 +61,7 @@ public SSLContextWrapper(SSLContext delegate, @Nullable SSLParameters sslParamet /** * Wraps a context with its loaded certificate chain and records whether that key material has no private key. + * Both metadata values come from the arguments, even if the delegate is an {@code SSLContextWrapper}. * * @param delegate Wrapped SSL context. * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 01395a1625314..bb9c816b10228 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -53,6 +53,7 @@ public void testMetadataUsesOneLoadAndStaysWithContext() throws Exception { SSLContext first = factory.create(); assertArrayEquals(chain(firstStore, "node01"), chain(first)); + assertFalse(privateKeyMissing(first)); assertEquals(1, factory.loads); factory.store = loadStore("node02"); @@ -64,6 +65,7 @@ public void testMetadataUsesOneLoadAndStaysWithContext() throws Exception { assertEquals(2, factory.loads); assertArrayEquals(chain(firstStore, "node01"), chain(first)); assertArrayEquals(chain(factory.store, "node02"), chain(next)); + assertFalse(privateKeyMissing(next)); } /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ @@ -87,10 +89,15 @@ public void testMetadataWithTlsParameters() throws Exception { } } - /** More than one private key leaves the chain unknown without preventing context creation. */ + /** Multiple private keys leave the chain unknown but are not reported as missing. */ @Test public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { - assertNull(chain(new StoreFactory(loadStore("node0102")).create())); + StoreFactory factory = new StoreFactory(loadStore("node0102")); + + for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { + assertNull(chain(ctx)); + assertFalse(privateKeyMissing(ctx)); + } } /** Trusted certificates and secret keys do not count as private keys. */ @@ -106,11 +113,17 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { StoreFactory factory = new StoreFactory(store); - assertNull(chain(factory.create())); + SSLContext withoutKey = factory.create(); + + assertNull(chain(withoutKey)); + assertTrue(privateKeyMissing(withoutKey)); store.setKeyEntry("key", keys.getKey("node01", PASSWORD), PASSWORD, keys.getCertificateChain("node01")); - assertArrayEquals(chain(keys, "node01"), chain(factory.createNew())); + SSLContext withKey = factory.createNew(); + + assertArrayEquals(chain(keys, "node01"), chain(withKey)); + assertFalse(privateKeyMissing(withKey)); } /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ @@ -292,6 +305,11 @@ private static X509Certificate[] chain(SSLContext ctx) { return ctx instanceof SSLContextWrapper ? ((SSLContextWrapper)ctx).getCertificateChain() : null; } + /** @return Whether the context reports that its key material has no private key. */ + private static boolean privateKeyMissing(SSLContext ctx) { + return ctx instanceof SSLContextWrapper && ((SSLContextWrapper)ctx).isPrivateKeyMissing(); + } + /** A file factory whose existing store-loading hook supplies the test material. */ private static class StoreFactory extends SslContextFactory { /** */ From ece42625e74f1b68b3f577b0a94cdc55e7320144 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 22:24:21 +0300 Subject: [PATCH 014/242] IGNITE-29210 Document rejection of material without a private key --- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 97c6edc4d25ed..7e92634693db4 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -288,7 +288,8 @@ private KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] /** * Takes the chain from the store used to create the key managers. A store with one private key and an X.509 chain * supplies that chain. More than one private key, or a chain containing a non-X.509 certificate, leaves the chain - * unknown. A store without a private key records that fact separately from an unknown chain. + * unknown. A store without a private key records that fact separately from an unknown chain. The node refuses such + * material during reload. * * @param keyMgrs Key managers initialized from the store. * @param store Already loaded key store. From eac1c13646dd7d308ca0d54c65fe5fef47a3ab14 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 22:24:22 +0300 Subject: [PATCH 015/242] IGNITE-29211 Verify unknown metadata stays quiet with renewal disabled --- .../ignite/ssl/AbstractSslContextFactory.java | 3 ++- .../ignite/internal/ssl/SslRenewalTest.java | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 9b539d23b5490..83fefb1020dea 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -337,7 +337,8 @@ private KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] /** * Takes the chain from the store used to create the key managers. A store with one private key and an X.509 chain * supplies that chain. More than one private key, or a chain containing a non-X.509 certificate, leaves the chain - * unknown. A store without a private key records that fact separately from an unknown chain. + * unknown. A store without a private key records that fact separately from an unknown chain. The node refuses such + * material during reload or renewal. * * @param keyMgrs Key managers initialized from the store. * @param store Already loaded key store. diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 74b78534afb63..5cc2c7d331e58 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -912,6 +912,24 @@ public void testKeyStoreWithoutKeyNotRenewed() { assertRenewalRejected(p, "The new key material has no private key, so the node would present no certificate"); } + /** With renewal disabled, missing certificate metadata must not schedule renewal or produce renewal log messages. */ + @Test + public void testUnknownCertificateWithoutRenewal() throws Exception { + Issuer issuer = new Issuer(this::twoKeys); + + issuer.setRenewalEnabled(false); + + IgniteEx g = startClientConnectorOn(issuer); + + SslContextProvider p = provider(g, CLIENT_CONNECTOR); + + assertNull(p.certificateChain()); + assertEquals(0, p.nextRenewalTime()); + assertContains(log, status(g), "certificate metadata is unavailable"); + assertContains(log, reload(g), ": reloaded " + CLIENT_CONNECTOR); + assertTrue(appender.events().isEmpty()); + } + /** Renewal settings are fixed at registration; changing the factory must not let reload lose the chain used by renewal. */ @Test public void testReloadCannotLoseMetadataWithRenewal() { From fdb2a29f3bb41abc22809f96bac0924ffebff62b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 22:50:15 +0300 Subject: [PATCH 016/242] IGNITE-29209 Keep legacy key-manager loading outside KeyMaterial --- .../ignite/ssl/AbstractSslContextFactory.java | 19 +++++++++++-------- .../ignite/ssl/SslContextMetadataTest.java | 4 +++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 07599a5301ad7..237aaeb2bf4d4 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -124,6 +124,9 @@ private SSLContext createSslContext() throws SSLException { checkParameters(); KeyMaterial material = createKeyMaterial(); + KeyManager[] keyMgrs = material == null ? createKeyManagers() : material.keyMgrs; + X509Certificate[] chain = material == null ? null : material.chain; + boolean privateKeyMissing = material != null && material.privateKeyMissing; TrustManager[] trustMgrs = createTrustManagers(); @@ -142,10 +145,10 @@ private SSLContext createSslContext() throws SSLException { sslParameters.setProtocols(protocols); } - if (sslParameters != null || material.chain != null || material.privateKeyMissing) - ctx = new SSLContextWrapper(ctx, sslParameters, material.chain, material.privateKeyMissing); + if (sslParameters != null || chain != null || privateKeyMissing) + ctx = new SSLContextWrapper(ctx, sslParameters, chain, privateKeyMissing); - ctx.init(material.keyMgrs, trustMgrs, null); + ctx.init(keyMgrs, trustMgrs, null); return ctx; } @@ -187,14 +190,14 @@ protected KeyManager[] createKeyManagers() throws SSLException { } /** - * Creates key managers and their certificate chain from the same load of the key material. New subclasses should - * override this method. The default calls the legacy {@link #createKeyManagers()} hook and leaves the chain unknown. + * Creates key managers and their certificate metadata from the same load. New subclasses must return the loaded material. + * The default returns {@code null} so existing subclasses can still supply keys through {@link #createKeyManagers()}. * - * @return Key managers and their chain, if known. + * @return Loaded key material, or {@code null} to use the legacy key-manager hook. * @throws SSLException If the key material could not be loaded. */ - protected KeyMaterial createKeyMaterial() throws SSLException { - return new KeyMaterial(createKeyManagers(), null); + protected @Nullable KeyMaterial createKeyMaterial() throws SSLException { + return null; } /** diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index bb9c816b10228..ae9466abe009f 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -148,9 +148,11 @@ public void testTrustOnlyContextCreation() throws Exception { /** A subclass that does not override createKeyMaterial() still supplies validation and managers on every build. */ @Test - public void testLegacyOverridesAreUsedWithoutMetadata() { + public void testLegacyOverridesAreUsedWithoutMetadata() throws Exception { LegacyFactory factory = new LegacyFactory(); + assertNull(factory.createKeyMaterial()); + SSLContext first = factory.create(); assertSame(first, factory.create()); From 78053e973eb03f077e402eee775d6baca4d51650 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 23:39:22 +0300 Subject: [PATCH 017/242] IGNITE-29209 Keep legacy adaptation in createKeyMaterial --- .../ignite/ssl/AbstractSslContextFactory.java | 19 ++++++++----------- .../ignite/ssl/SslContextMetadataTest.java | 4 +--- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 237aaeb2bf4d4..d269abbd90722 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -124,9 +124,6 @@ private SSLContext createSslContext() throws SSLException { checkParameters(); KeyMaterial material = createKeyMaterial(); - KeyManager[] keyMgrs = material == null ? createKeyManagers() : material.keyMgrs; - X509Certificate[] chain = material == null ? null : material.chain; - boolean privateKeyMissing = material != null && material.privateKeyMissing; TrustManager[] trustMgrs = createTrustManagers(); @@ -145,10 +142,10 @@ private SSLContext createSslContext() throws SSLException { sslParameters.setProtocols(protocols); } - if (sslParameters != null || chain != null || privateKeyMissing) - ctx = new SSLContextWrapper(ctx, sslParameters, chain, privateKeyMissing); + if (sslParameters != null || material.chain != null || material.privateKeyMissing) + ctx = new SSLContextWrapper(ctx, sslParameters, material.chain, material.privateKeyMissing); - ctx.init(keyMgrs, trustMgrs, null); + ctx.init(material.keyMgrs, trustMgrs, null); return ctx; } @@ -190,14 +187,14 @@ protected KeyManager[] createKeyManagers() throws SSLException { } /** - * Creates key managers and their certificate metadata from the same load. New subclasses must return the loaded material. - * The default returns {@code null} so existing subclasses can still supply keys through {@link #createKeyManagers()}. + * Creates key managers and their certificate metadata from the same load. New subclasses should override this method. + * The default adapts {@link #createKeyManagers()} for existing subclasses and leaves the certificate chain unknown. * - * @return Loaded key material, or {@code null} to use the legacy key-manager hook. + * @return Loaded key material. * @throws SSLException If the key material could not be loaded. */ - protected @Nullable KeyMaterial createKeyMaterial() throws SSLException { - return null; + protected KeyMaterial createKeyMaterial() throws SSLException { + return new KeyMaterial(createKeyManagers(), null); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index ae9466abe009f..bb9c816b10228 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -148,11 +148,9 @@ public void testTrustOnlyContextCreation() throws Exception { /** A subclass that does not override createKeyMaterial() still supplies validation and managers on every build. */ @Test - public void testLegacyOverridesAreUsedWithoutMetadata() throws Exception { + public void testLegacyOverridesAreUsedWithoutMetadata() { LegacyFactory factory = new LegacyFactory(); - assertNull(factory.createKeyMaterial()); - SSLContext first = factory.create(); assertSame(first, factory.create()); From bda9a61a0095af49ae985df09ddef730b79689bf Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 23:50:54 +0300 Subject: [PATCH 018/242] IGNITE-29209 Format SSL context metadata delegation. --- .../main/java/org/apache/ignite/ssl/SSLContextWrapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java index af2110151203b..af182ac8435cc 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java @@ -40,8 +40,8 @@ public class SSLContextWrapper extends SSLContext { * @param sslParameters Extended SSL parameters. */ public SSLContextWrapper(SSLContext delegate, SSLParameters sslParameters) { - this(delegate, sslParameters, delegate instanceof SSLContextWrapper - ? ((SSLContextWrapper)delegate).getCertificateChain() : null, + this(delegate, sslParameters, + delegate instanceof SSLContextWrapper ? ((SSLContextWrapper)delegate).getCertificateChain() : null, delegate instanceof SSLContextWrapper && ((SSLContextWrapper)delegate).isPrivateKeyMissing()); } From 542f3c77ff499d416f72173ba1aa19f7ba575a97 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 00:50:25 +0300 Subject: [PATCH 019/242] IGNITE-29210 Test SSL reload recovery and retained context. --- .../internal/ssl/SslContextProviderTest.java | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index c8789df377c87..a28413ed09697 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -23,7 +23,6 @@ import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; @@ -159,6 +158,49 @@ public void testReloadCanLoseMetadata() throws Exception { assertEquals(0, p.reloadOutcome().failures()); } + /** Reload failures keep the active context and last success time. A successful retry clears the failure history. */ + @Test + public void testReloadFailureRetainsContextUntilRecovery() throws Exception { + Callable unavailable = () -> { + throw new IgniteException("Certificate source is unavailable"); + }; + + SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), fresh(HOUR), unavailable, unavailable, fresh(2 * HOUR))); + + p.reload(); + + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + long lastSuccess = p.reloadOutcome().successTime(); + + assertTrue(lastSuccess > 0); + + for (int failures = 1; failures <= 2; failures++) { + long attemptStart = System.currentTimeMillis(); + + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, "Certificate source is unavailable"); + + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + assertEquals(lastSuccess, p.reloadOutcome().successTime()); + assertEquals(failures, p.reloadOutcome().failures()); + assertTrue(p.reloadOutcome().failureTime() >= attemptStart); + assertContains(log, p.reloadOutcome().failureReason(), "Certificate source is unavailable"); + } + + long recoveryStart = System.currentTimeMillis(); + + X509Certificate recovered = p.reload(); + + assertNotSame(inUse, p.context()); + assertEquals(recovered, p.certificate()); + assertTrue(recovered.getNotAfter().after(chain[0].getNotAfter())); + assertTrue(p.reloadOutcome().successTime() >= recoveryStart); + assertEquals(0, p.reloadOutcome().failures()); + assertEquals(0, p.reloadOutcome().failureTime()); + assertNull(p.reloadOutcome().failureReason()); + } + /** A not yet valid candidate leaves the working context and its metadata in use. */ @Test public void testNotYetValidCertificateNotApplied() throws Exception { @@ -357,11 +399,6 @@ private Issuer(Callable... steps) { // No-op. } - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers() throws SSLException { - return createKeyMaterial().keyManagers(); - } - /** {@inheritDoc} */ @Override protected KeyMaterial createKeyMaterial() throws SSLException { Callable step = steps.get(Math.min(calls++, steps.size() - 1)); From 65c8098c9703b69cbcb8c04d4ac5167c71b74a3d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 00:50:25 +0300 Subject: [PATCH 020/242] IGNITE-29209 Test SSL metadata and legacy factory guarantees. --- .../ignite/ssl/SslContextMetadataTest.java | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index bb9c816b10228..fdeb374866928 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -18,7 +18,6 @@ package org.apache.ignite.ssl; import java.io.InputStream; -import java.net.ServerSocket; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; @@ -31,6 +30,8 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; @@ -146,16 +147,28 @@ public void testTrustOnlyContextCreation() throws Exception { } } - /** A subclass that does not override createKeyMaterial() still supplies validation and managers on every build. */ + /** Legacy hooks run for each new context, but not when create() returns its cached context. */ @Test - public void testLegacyOverridesAreUsedWithoutMetadata() { + public void testLegacyHooksRunOncePerBuiltContext() { LegacyFactory factory = new LegacyFactory(); SSLContext first = factory.create(); - assertSame(first, factory.create()); - assertNotSame(first, factory.createNew()); assertNull(chain(first)); + assertEquals(1, factory.checks); + assertEquals(1, factory.keys); + assertEquals(1, factory.trust); + + assertSame(first, factory.create()); + + assertEquals(1, factory.checks); + assertEquals(1, factory.keys); + assertEquals(1, factory.trust); + + SSLContext next = factory.createNew(); + + assertNotSame(first, next); + assertNull(chain(next)); assertEquals(2, factory.checks); assertEquals(2, factory.keys); assertEquals(2, factory.trust); @@ -260,7 +273,32 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); } - /** Adding metadata without TLS parameters preserves the peer address, default settings and socket factory classes. */ + /** Explicit metadata takes precedence over the wrapped context's metadata. */ + @Test + public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { + X509Certificate[] expectedChain = chain(loadStore("node01"), "node01"); + SSLContext delegate = SSLContext.getInstance("TLS"); + + delegate.init(null, null, null); + + SSLContextWrapper keyless = new SSLContextWrapper(delegate, null, null, true); + SSLContextWrapper withChain = new SSLContextWrapper(keyless, null, expectedChain); + + assertArrayEquals(expectedChain, withChain.getCertificateChain()); + assertFalse(withChain.isPrivateKeyMissing()); + + SSLContextWrapper unknownChain = new SSLContextWrapper(withChain, null, null); + + assertNull(unknownChain.getCertificateChain()); + assertFalse(unknownChain.isPrivateKeyMissing()); + + SSLContextWrapper explicitlyKeyless = new SSLContextWrapper(withChain, null, null, true); + + assertNull(explicitlyKeyless.getCertificateChain()); + assertTrue(explicitlyKeyless.isPrivateKeyMissing()); + } + + /** Adding metadata without TLS parameters preserves peer addresses, TLS defaults and unconnected socket creation. */ @Test public void testMetadataWrapperPreservesContextBehavior() throws Exception { SSLContext delegate = SSLContext.getInstance("TLS"); @@ -269,16 +307,26 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(loadStore("node01"), "node01")); SSLEngine engine = wrapper.createSSLEngine("peer.example", 10800); + SSLEngine delegateEngine = delegate.createSSLEngine(); assertEquals("peer.example", engine.getPeerHost()); assertEquals(10800, engine.getPeerPort()); - assertArrayEquals(delegate.createSSLEngine().getEnabledProtocols(), engine.getEnabledProtocols()); - assertArrayEquals(delegate.createSSLEngine().getEnabledCipherSuites(), engine.getEnabledCipherSuites()); - assertEquals(delegate.getSocketFactory().getClass(), wrapper.getSocketFactory().getClass()); - assertEquals(delegate.getServerSocketFactory().getClass(), wrapper.getServerSocketFactory().getClass()); + assertArrayEquals(delegateEngine.getEnabledProtocols(), engine.getEnabledProtocols()); + assertArrayEquals(delegateEngine.getEnabledCipherSuites(), engine.getEnabledCipherSuites()); + + try (SSLSocket expected = (SSLSocket)delegate.getSocketFactory().createSocket(); + SSLSocket actual = (SSLSocket)wrapper.getSocketFactory().createSocket()) { + assertFalse(actual.isConnected()); + assertFalse(actual.isBound()); + assertArrayEquals(expected.getEnabledProtocols(), actual.getEnabledProtocols()); + assertArrayEquals(expected.getEnabledCipherSuites(), actual.getEnabledCipherSuites()); + } - try (ServerSocket sock = wrapper.getServerSocketFactory().createServerSocket()) { - assertFalse(sock.isBound()); + try (SSLServerSocket expected = (SSLServerSocket)delegate.getServerSocketFactory().createServerSocket(); + SSLServerSocket actual = (SSLServerSocket)wrapper.getServerSocketFactory().createServerSocket()) { + assertFalse(actual.isBound()); + assertArrayEquals(expected.getEnabledProtocols(), actual.getEnabledProtocols()); + assertArrayEquals(expected.getEnabledCipherSuites(), actual.getEnabledCipherSuites()); } } From f72363123da33b5db0aa11093466ad1b8f0e3a60 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:06:24 +0300 Subject: [PATCH 021/242] IGNITE-29209 Generate explicit key material for SSL metadata tests. --- modules/core/pom.xml | 7 + .../ignite/ssl/SslContextMetadataTest.java | 82 +++--- .../TestCertificateAuthority.java | 247 ++++++++++++++++++ 3 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java diff --git a/modules/core/pom.xml b/modules/core/pom.xml index bbdf058dea191..47fd6cc45de8b 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -158,6 +158,13 @@ test + + org.bouncycastle + bcpkix-jdk18on + 1.86 + test + + org.eclipse.jetty.ee11 jetty-ee11-servlets diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index fdeb374866928..9ca68bbabb3d9 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -17,13 +17,11 @@ package org.apache.ignite.ssl; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.KeyStore; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Arrays; +import java.util.concurrent.TimeUnit; import javax.crypto.spec.SecretKeySpec; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; @@ -33,8 +31,10 @@ import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; +import javax.security.auth.x500.X500Principal; import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -42,46 +42,56 @@ /** Tests that certificate metadata belongs to the key material used to create a context. */ public class SslContextMetadataTest extends GridCommonAbstractTest { - /** Store and key password of the test fixtures. */ - private static final char[] PASSWORD = "123456".toCharArray(); + /** Store and key password of the generated test material. */ + private static final char[] PASSWORD = TestCertificateAuthority.PWD; + + /** Authority that signs the test certificates. */ + private TestCertificateAuthority ca; + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + ca = new TestCertificateAuthority("metadata-ca"); + } /** {@code createNew()} loads the store once, preserves the cached context and attaches the new chain to the new context. */ @Test public void testMetadataUsesOneLoadAndStaysWithContext() throws Exception { - KeyStore firstStore = loadStore("node01"); + KeyStore firstStore = issueStore("first"); StoreFactory factory = new StoreFactory(firstStore); SSLContext first = factory.create(); - assertArrayEquals(chain(firstStore, "node01"), chain(first)); + assertArrayEquals(chain(firstStore, "first"), chain(first)); + assertEquals(new X500Principal("CN=first"), chain(first)[0].getSubjectX500Principal()); assertFalse(privateKeyMissing(first)); assertEquals(1, factory.loads); - factory.store = loadStore("node02"); + factory.store = issueStore("next"); SSLContext next = factory.createNew(); assertNotSame(first, next); assertSame(first, factory.create()); assertEquals(2, factory.loads); - assertArrayEquals(chain(firstStore, "node01"), chain(first)); - assertArrayEquals(chain(factory.store, "node02"), chain(next)); + assertArrayEquals(chain(firstStore, "first"), chain(first)); + assertArrayEquals(chain(factory.store, "next"), chain(next)); + assertEquals(new X500Principal("CN=next"), chain(next)[0].getSubjectX500Principal()); assertFalse(privateKeyMissing(next)); } /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = loadStore("node01"); + KeyStore store = issueStore("node"); StoreFactory factory = new StoreFactory(store); String[] protocols = {"TLSv1.2"}; - String[] ciphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; + String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; factory.setProtocols(protocols); factory.setCipherSuites(ciphers); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertArrayEquals(chain(store, "node01"), chain(ctx)); + assertArrayEquals(chain(store, "node"), chain(ctx)); SSLEngine engine = ctx.createSSLEngine(); @@ -93,7 +103,12 @@ public void testMetadataWithTlsParameters() throws Exception { /** Multiple private keys leave the chain unknown but are not reported as missing. */ @Test public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { - StoreFactory factory = new StoreFactory(loadStore("node0102")); + KeyStore store = issueStore("first"); + KeyStore other = issueStore("second"); + + store.setKeyEntry("second", other.getKey("second", PASSWORD), PASSWORD, other.getCertificateChain("second")); + + StoreFactory factory = new StoreFactory(store); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { assertNull(chain(ctx)); @@ -104,11 +119,11 @@ public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { /** Trusted certificates and secret keys do not count as private keys. */ @Test public void testOnlyPrivateKeyEntriesCount() throws Exception { - KeyStore keys = loadStore("node01"); + KeyStore keys = issueStore("node"); KeyStore store = KeyStore.getInstance("JCEKS"); store.load(null, PASSWORD); - store.setCertificateEntry("trusted", keys.getCertificate("node01")); + store.setCertificateEntry("trusted", keys.getCertificate("node")); store.setEntry("secret", new KeyStore.SecretKeyEntry(new SecretKeySpec(new byte[16], "AES")), new KeyStore.PasswordProtection(PASSWORD)); @@ -119,18 +134,23 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { assertNull(chain(withoutKey)); assertTrue(privateKeyMissing(withoutKey)); - store.setKeyEntry("key", keys.getKey("node01", PASSWORD), PASSWORD, keys.getCertificateChain("node01")); + store.setKeyEntry("key", keys.getKey("node", PASSWORD), PASSWORD, keys.getCertificateChain("node")); SSLContext withKey = factory.createNew(); - assertArrayEquals(chain(keys, "node01"), chain(withKey)); + assertArrayEquals(chain(keys, "node"), chain(withKey)); assertFalse(privateKeyMissing(withKey)); } /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ @Test public void testTrustOnlyContextCreation() throws Exception { - StoreFactory factory = new StoreFactory(loadStore("trustboth")); + KeyStore store = KeyStore.getInstance("PKCS12"); + + store.load(null, PASSWORD); + store.setCertificateEntry("trusted", issueStore("trusted").getCertificate("trusted")); + + StoreFactory factory = new StoreFactory(store); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { assertNull(chain(ctx)); @@ -155,6 +175,7 @@ public void testLegacyHooksRunOncePerBuiltContext() { SSLContext first = factory.create(); assertNull(chain(first)); + assertFalse(privateKeyMissing(first)); assertEquals(1, factory.checks); assertEquals(1, factory.keys); assertEquals(1, factory.trust); @@ -169,6 +190,7 @@ public void testLegacyHooksRunOncePerBuiltContext() { assertNotSame(first, next); assertNull(chain(next)); + assertFalse(privateKeyMissing(next)); assertEquals(2, factory.checks); assertEquals(2, factory.keys); assertEquals(2, factory.trust); @@ -177,7 +199,7 @@ public void testLegacyHooksRunOncePerBuiltContext() { /** A subclass can still obtain key managers from the file factory's legacy hook. */ @Test public void testLegacyKeyManagerAdapter() throws Exception { - StoreFactory factory = new StoreFactory(loadStore("node01")); + StoreFactory factory = new StoreFactory(issueStore("node")); KeyManager[] mgrs = factory.legacyKeyManagers(); assertNotNull(mgrs); @@ -216,7 +238,7 @@ public void testMissingKeyMaterialImplementation() { public void testKeyMaterialDefensiveCopies() throws Exception { KeyManager mgr = new KeyManager() { }; KeyManager[] mgrs = {mgr}; - X509Certificate[] expected = chain(loadStore("node01"), "node01"); + X509Certificate[] expected = chain(issueStore("node"), "node"); X509Certificate[] input = expected.clone(); AbstractSslContextFactory.KeyMaterial material = new AbstractSslContextFactory.KeyMaterial(mgrs, input); @@ -250,7 +272,7 @@ public void testKeyMaterialDefensiveCopies() throws Exception { /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ @Test public void testWrapperPreservesAndCopiesChain() throws Exception { - X509Certificate[] expected = chain(loadStore("node01"), "node01"); + X509Certificate[] expected = chain(issueStore("node"), "node"); X509Certificate[] input = expected.clone(); SSLContext delegate = SSLContext.getInstance("TLS"); @@ -276,7 +298,7 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { /** Explicit metadata takes precedence over the wrapped context's metadata. */ @Test public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { - X509Certificate[] expectedChain = chain(loadStore("node01"), "node01"); + X509Certificate[] expectedChain = chain(issueStore("node"), "node"); SSLContext delegate = SSLContext.getInstance("TLS"); delegate.init(null, null, null); @@ -305,7 +327,7 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { delegate.init(null, null, null); - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(loadStore("node01"), "node01")); + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(issueStore("node"), "node")); SSLEngine engine = wrapper.createSSLEngine("peer.example", 10800); SSLEngine delegateEngine = delegate.createSSLEngine(); @@ -330,15 +352,11 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { } } - /** @return Loaded test key store. */ - private static KeyStore loadStore(String name) throws Exception { - KeyStore store = KeyStore.getInstance("JKS"); - - try (InputStream in = Files.newInputStream(Path.of(GridTestUtils.keyStorePath(name)))) { - store.load(in, PASSWORD); - } + /** @return In-memory store with one private key and its certificate chain. */ + private KeyStore issueStore(String name) throws Exception { + long now = System.currentTimeMillis(); - return store; + return ca.issue(name, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); } /** @return Chain stored for the alias. */ diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java new file mode 100644 index 0000000000000..15bf792574824 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -0,0 +1,247 @@ +/* + * 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. + */ + +package org.apache.ignite.testframework; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.security.auth.x500.X500Principal; +import org.apache.ignite.ssl.SSLContextWrapper; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * Certificate authority for tests that need explicit keys, trust relationships and certificate validity periods. + */ +public class TestCertificateAuthority { + /** Password of every store made here. */ + public static final char[] PWD = "123456".toCharArray(); + + /** */ + private static final SecureRandom RND = new SecureRandom(); + + /** */ + private final X500Principal name; + + /** */ + private final KeyPair keys; + + /** */ + private final X509Certificate cert; + + /** @param cn Common name of the authority. */ + public TestCertificateAuthority(String cn) throws Exception { + this(cn, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365)); + } + + /** + * @param cn Common name of the authority. + * @param notBefore Time the authority's certificate becomes valid; rounded down to a second. + * @param notAfter Time the authority's certificate expires; rounded down to a second. + */ + public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws Exception { + name = new X500Principal("CN=" + cn); + keys = keyPair(); + + cert = sign(name, keys, notBefore, notAfter, true); + } + + /** + * @param cn Common name of the certificate. + * @param notBefore Time it becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + public KeyStore issue(String cn, long notBefore, long notAfter) throws Exception { + return issue(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); + } + + /** + * @param alias Alias for the private key and certificate chain. + * @param subject Subject of the certificate. + * @param pair Key pair of the certificate owner. + * @param notBefore Time it becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + public KeyStore issue(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) throws Exception { + X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); + + KeyStore store = emptyStore(); + + store.setKeyEntry(alias, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); + + return store; + } + + /** @return Trust store with this authority alone. */ + public KeyStore trustStore() throws Exception { + return trustStore(this); + } + + /** + * @param authorities Authorities to trust. + * @return Trust store with them. + */ + public static KeyStore trustStore(TestCertificateAuthority... authorities) throws Exception { + KeyStore store = emptyStore(); + + for (TestCertificateAuthority a : authorities) + store.setCertificateEntry(a.name.getName(), a.cert); + + return store; + } + + /** + * @param keyStore Store with the certificate to present. + * @param trustStore Store with the authorities to trust. + * @return Context using the supplied key and trust stores, with certificate metadata attached. + */ + public static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); + + return new SSLContextWrapper(ctx, null, certificateChain(keyStore)); + } + + /** + * @param keyStore Store with at most one private key. + * @return Its certificate chain, or {@code null} if the store has no private key. + */ + public static X509Certificate[] certificateChain(KeyStore keyStore) throws Exception { + X509Certificate[] chain = null; + + for (String alias : Collections.list(keyStore.aliases())) { + if (!keyStore.isKeyEntry(alias)) + continue; + + if (chain != null) + throw new IllegalArgumentException("The test key store must contain at most one private key"); + + Certificate[] certs = keyStore.getCertificateChain(alias); + + chain = Arrays.copyOf(certs, certs.length, X509Certificate[].class); + } + + return chain; + } + + /** + * @param keyStore Store with the certificate to present. + * @return Key managers that present it. + */ + public static KeyManager[] keyManagers(KeyStore keyStore) throws Exception { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + kmf.init(keyStore, PWD); + + return kmf.getKeyManagers(); + } + + /** + * @param trustStore Store with the authorities to trust. + * @return Trust managers that trust them. + */ + public static TrustManager[] trustManagers(KeyStore trustStore) throws Exception { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + tmf.init(trustStore); + + return tmf.getTrustManagers(); + } + + /** + * @param store Store. + * @param path File to write or replace. + */ + public static void save(KeyStore store, Path path) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + store.store(out, PWD); + } + } + + /** + * @param subj Subject. + * @param subjKeys Key pair of the subject. + * @param notBefore Time the certificate becomes valid. + * @param notAfter Time it expires. + * @param ca Whether the certificate is the authority's own. + * @return Certificate signed by this authority. + */ + private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, + new BigInteger(Long.SIZE, RND).add(BigInteger.ONE), + new Date(notBefore), + new Date(notAfter), + subj, + subjKeys.getPublic()); + + if (ca) + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + else + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()); + + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + /** */ + private static KeyPair keyPair() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + + gen.initialize(new ECGenParameterSpec("secp256r1")); + + return gen.generateKeyPair(); + } + + /** */ + private static KeyStore emptyStore() throws Exception { + KeyStore store = KeyStore.getInstance("PKCS12"); + + store.load(null, PWD); + + return store; + } +} From 5ca41309cdfb7ccc55e315b308bdf5141ee0a226 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:08:47 +0300 Subject: [PATCH 022/242] IGNITE-29210 Reuse generated certificates and verify expired issuers. --- .../internal/ssl/SslContextProviderTest.java | 21 ++ .../ssl/TestCertificateAuthority.java | 240 ------------------ 2 files changed, 21 insertions(+), 240 deletions(-) delete mode 100644 modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index a28413ed09697..d7456dfbeaca7 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -32,6 +32,7 @@ import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -217,6 +218,26 @@ public void testNotYetValidCertificateNotApplied() throws Exception { assertEquals(1, p.reloadOutcome().failures()); } + /** A valid leaf does not make a replacement usable when its issuer has expired. */ + @Test + public void testExpiredIssuerNotApplied() throws Exception { + long now = System.currentTimeMillis(); + TestCertificateAuthority expiredCa = new TestCertificateAuthority("expired-ca", now - 2 * HOUR, now - HOUR); + KeyStore candidate = expiredCa.issue("valid-leaf", now - MINUTE, now + HOUR); + + ((X509Certificate)candidate.getCertificate("valid-leaf")).checkValidity(); + + SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), () -> candidate)); + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + + GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "subject=CN=expired-ca"); + + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + assertEquals(1, p.reloadOutcome().failures()); + } + /** An unsupported protocol in a replacement context leaves the working context in use. */ @Test public void testUnsupportedProtocolNotApplied() { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java deleted file mode 100644 index f95f9689be3eb..0000000000000 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/TestCertificateAuthority.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.internal.ssl; - -import java.io.OutputStream; -import java.math.BigInteger; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.SecureRandom; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.security.spec.ECGenParameterSpec; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.concurrent.TimeUnit; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.security.auth.x500.X500Principal; -import org.apache.ignite.ssl.SSLContextWrapper; -import org.bouncycastle.asn1.x509.BasicConstraints; -import org.bouncycastle.asn1.x509.ExtendedKeyUsage; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.KeyPurposeId; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; - -/** - * Certificate authority for tests that need certificates valid for a chosen time, which the stores checked into the repository cannot give. - */ -class TestCertificateAuthority { - /** Password of every store made here. */ - static final char[] PWD = "123456".toCharArray(); - - /** */ - private static final SecureRandom RND = new SecureRandom(); - - /** */ - private final X500Principal name; - - /** */ - private final KeyPair keys; - - /** */ - private final X509Certificate cert; - - /** @param cn Common name of the authority. */ - TestCertificateAuthority(String cn) throws Exception { - name = new X500Principal("CN=" + cn); - keys = keyPair(); - - long now = System.currentTimeMillis(); - - cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); - } - - /** - * @param cn Common name of the certificate. - * @param notBefore Time it becomes valid; rounded down to a second. - * @param notAfter Time it expires; rounded down to a second. - * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. - */ - KeyStore issue(String cn, long notBefore, long notAfter) throws Exception { - return issue(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); - } - - /** - * @param alias Alias for the private key and certificate chain. - * @param subject Subject of the certificate. - * @param pair Key pair of the certificate owner. - * @param notBefore Time it becomes valid; rounded down to a second. - * @param notAfter Time it expires; rounded down to a second. - * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. - */ - KeyStore issue(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) throws Exception { - X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); - - KeyStore store = emptyStore(); - - store.setKeyEntry(alias, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); - - return store; - } - - /** @return Trust store with this authority alone. */ - KeyStore trustStore() throws Exception { - return trustStore(this); - } - - /** - * @param authorities Authorities to trust. - * @return Trust store with them. - */ - static KeyStore trustStore(TestCertificateAuthority... authorities) throws Exception { - KeyStore store = emptyStore(); - - for (TestCertificateAuthority a : authorities) - store.setCertificateEntry(a.name.getName(), a.cert); - - return store; - } - - /** - * @param keyStore Store with the certificate to present. - * @param trustStore Store with the authorities to trust. - * @return Context using the supplied key and trust stores, with certificate metadata attached. - */ - static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { - SSLContext ctx = SSLContext.getInstance("TLS"); - - ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); - - return new SSLContextWrapper(ctx, null, certificateChain(keyStore)); - } - - /** - * @param keyStore Store with at most one private key. - * @return Its certificate chain, or {@code null} if the store has no private key. - */ - static X509Certificate[] certificateChain(KeyStore keyStore) throws Exception { - X509Certificate[] chain = null; - - for (String alias : Collections.list(keyStore.aliases())) { - if (!keyStore.isKeyEntry(alias)) - continue; - - if (chain != null) - throw new IllegalArgumentException("The test key store must contain at most one private key"); - - Certificate[] certs = keyStore.getCertificateChain(alias); - - chain = Arrays.copyOf(certs, certs.length, X509Certificate[].class); - } - - return chain; - } - - /** - * @param keyStore Store with the certificate to present. - * @return Key managers that present it. - */ - static KeyManager[] keyManagers(KeyStore keyStore) throws Exception { - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - - kmf.init(keyStore, PWD); - - return kmf.getKeyManagers(); - } - - /** - * @param trustStore Store with the authorities to trust. - * @return Trust managers that trust them. - */ - static TrustManager[] trustManagers(KeyStore trustStore) throws Exception { - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - tmf.init(trustStore); - - return tmf.getTrustManagers(); - } - - /** - * @param store Store. - * @param path File to write or replace. - */ - static void save(KeyStore store, Path path) throws Exception { - try (OutputStream out = Files.newOutputStream(path)) { - store.store(out, PWD); - } - } - - /** - * @param subj Subject. - * @param subjKeys Key pair of the subject. - * @param notBefore Time the certificate becomes valid. - * @param notAfter Time it expires. - * @param ca Whether the certificate is the authority's own. - * @return Certificate signed by this authority. - */ - private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { - JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( - name, - new BigInteger(Long.SIZE, RND).add(BigInteger.ONE), - new Date(notBefore), - new Date(notAfter), - subj, - subjKeys.getPublic()); - - if (ca) - builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); - else - builder.addExtension(Extension.extendedKeyUsage, false, - new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})); - - ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()); - - return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); - } - - /** */ - private static KeyPair keyPair() throws Exception { - KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); - - gen.initialize(new ECGenParameterSpec("secp256r1")); - - return gen.generateKeyPair(); - } - - /** */ - private static KeyStore emptyStore() throws Exception { - KeyStore store = KeyStore.getInstance("PKCS12"); - - store.load(null, PWD); - - return store; - } -} From 9716fd9fb24217feee1afea3f2046cf457343d46 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:20:45 +0300 Subject: [PATCH 023/242] IGNITE-29209 Use JKS for generated certificate stores. --- .../apache/ignite/testframework/TestCertificateAuthority.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 15bf792574824..693006fc8f558 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -238,7 +238,7 @@ private static KeyPair keyPair() throws Exception { /** */ private static KeyStore emptyStore() throws Exception { - KeyStore store = KeyStore.getInstance("PKCS12"); + KeyStore store = KeyStore.getInstance("JKS"); store.load(null, PWD); From 8067bf1977aaf50900d6b975b258115f9036081f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:23:34 +0300 Subject: [PATCH 024/242] IGNITE-29211 Keep renewal state unchanged after interruption. --- .../internal/ssl/SslContextProvider.java | 3 + .../ignite/internal/ssl/SslRenewalTest.java | 86 ++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 5e72ec5f969e6..1a8f18ffde400 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -182,6 +182,9 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { try { SSLContext rebuilt = build(); + if (Thread.currentThread().isInterrupted()) + throw new IgniteInterruptedCheckedException("TLS certificate renewal was interrupted"); + X509Certificate[] next = certificateChain(rebuilt); X509Certificate[] chain = certificateChain(ctx); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 5cc2c7d331e58..87ee109a8ae80 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -34,6 +34,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import javax.net.ssl.KeyManager; @@ -45,6 +46,7 @@ import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; import org.apache.ignite.internal.thread.context.Scope; @@ -56,6 +58,7 @@ import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.MemorizingAppender; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; @@ -131,19 +134,28 @@ public class SslRenewalTest extends GridCommonAbstractTest { @Test public void testRenewsBeforeExpiry() throws Exception { long now = System.currentTimeMillis(); - long notAfter = (now + 40_000) / 1000 * 1000; - long notBefore = notAfter - 233_000; - long due = notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + AtomicLong due = new AtomicLong(); + + Issuer issuer = new Issuer(() -> { + long issued = System.currentTimeMillis(); + long notAfter = (issued + 40_000) / 1000 * 1000; + long notBefore = notAfter - 233_000; - assertTrue("The window must open seconds after the node starts, well before expiry", due - now > 3_000 && notAfter - due > 30_000); + due.set(notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION)); - IgniteEx g = startNode(new Issuer(valid(notBefore, notAfter), fresh(HOUR))); + assertTrue("The window must open after the first load and well before expiry", + due.get() - issued > 3_000 && notAfter - due.get() > 30_000); + + return ca.issue("node", notBefore, notAfter); + }, fresh(HOUR)); + + IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); assertTrue("The certificate must have been renewed", chainNotAfter(g) > now + 30 * MINUTE); assertTrue("The renewal must not come before the window opens", - provider(g).reloadOutcome().successTime() >= due - CLOCK_SLACK); + issuer.calls.get(1).time >= due.get() - CLOCK_SLACK); assertEquals("New connections must get the renewed certificate", chainNotAfter(g), servedCertificate(probe(), discoveryPort(g)).getNotAfter().getTime()); @@ -723,7 +735,7 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { CountDownLatch entered = new CountDownLatch(1); - Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), () -> { + Issuer issuer = new Issuer(fresh(HOUR), valid(now - HOUR, now + 9 * MINUTE), () -> { entered.countDown(); Thread.sleep(Long.MAX_VALUE); @@ -732,18 +744,72 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { }); IgniteEx g = startNode(issuer); + SslContextProvider p = provider(g); - assertTrue(entered.await(30, TimeUnit.SECONDS)); + p.reload(); - SslContextProvider p = provider(g); + SslContextProvider.ReloadOutcome outcome = p.reloadOutcome(); + + assertTrue(outcome.successTime() > 0); + assertTrue(entered.await(30, TimeUnit.SECONDS)); stopGrid(0); assertEquals(0, p.renewalFailures()); - assertEquals(0, p.reloadOutcome().failures()); + assertEquals(outcome, p.reloadOutcome()); assertTrue(appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); } + /** A loader returning after interruption must not replace the context or change recorded reload and renewal results. */ + @Test + public void testInterruptedRenewalReturnPreservesState() throws Exception { + long now = System.currentTimeMillis(); + KeyStore original = ca.issue("original", now - HOUR, now + HOUR); + KeyStore replacement = ca.issue("replacement", now - MINUTE, now + 2 * HOUR); + + for (KeyStore candidate : List.of(original, replacement)) { + Issuer issuer = new Issuer(() -> original, () -> original, () -> original, failure(), () -> { + Thread.currentThread().interrupt(); + + return candidate; + }); + SslContextProvider p = new SslContextProvider(issuer); + + p.reload(); + assertNull(p.renew(p.context())); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); + + SSLContext inUse = p.context(); + X509Certificate[] chain = p.certificateChain(); + SslContextProvider.ReloadOutcome outcome = p.reloadOutcome(); + int failures = p.renewalFailures(); + + assertTrue(outcome.successTime() > 0); + assertEquals(1, outcome.failures()); + assertEquals(1, failures); + + IgniteInterruptedCheckedException err = null; + boolean interrupted; + + try { + p.renew(inUse); + } + catch (IgniteInterruptedCheckedException e) { + err = e; + } + finally { + interrupted = Thread.interrupted(); + } + + assertTrue(interrupted); + assertSame(inUse, p.context()); + assertArrayEquals(chain, p.certificateChain()); + assertEquals(outcome, p.reloadOutcome()); + assertEquals(failures, p.renewalFailures()); + assertNotNull(err); + } + } + /** Cached and expired unchanged chains stay in use and count as unsuccessful renewal attempts. */ @Test public void testSameCertificatesUnchanged() throws Exception { From 3d4b4ae61f978e5e2e5c2db61809828c536623af Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:25:01 +0300 Subject: [PATCH 025/242] IGNITE-29210 Make reload test keys and trust explicit. --- .../ignite/ssl/SslContextReloadTest.java | 264 ++++++++++++------ .../org/apache/ignite/ssl/SslTestUtils.java | 70 +++-- 2 files changed, 224 insertions(+), 110 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 77530021f8b2a..fa5963cc786af 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -17,12 +17,13 @@ package org.apache.ignite.ssl; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.KeyStore; import java.time.Instant; import java.util.List; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -54,6 +55,7 @@ import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.ListeningTestLogger; import org.apache.ignite.testframework.LogListener; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; import org.mockito.Mockito; @@ -66,31 +68,50 @@ import static org.apache.ignite.ssl.SslTestUtils.assertClientAuthentication; import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; import static org.apache.ignite.ssl.SslTestUtils.node; -import static org.apache.ignite.ssl.SslTestUtils.place; import static org.apache.ignite.ssl.SslTestUtils.reload; import static org.apache.ignite.ssl.SslTestUtils.reloadFailure; -import static org.apache.ignite.ssl.SslTestUtils.servedSubject; +import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; /** * Tests {@code --ssl reload} and {@code --ssl status} on running nodes. Each node has separate key and trust stores so that tests - * can replace or corrupt them independently. node01 is signed by the old CA (oneca); node02, node03 and the expired node02old - * are signed by the new CA (twoca). + * can replace or corrupt them independently. The tests generate the keys, trust roots and validity periods explicitly. */ public class SslContextReloadTest extends GridCommonAbstractTest { /** Transports of a node whose client connector shares the factory of the node. */ private static final String ALL_TRANSPORTS = CLIENT_CONNECTOR + ", " + COMMUNICATION + ", " + DISCOVERY; + /** Validity margin for generated certificates. */ + private static final long HOUR = TimeUnit.HOURS.toMillis(1); + /** Directory the stores of the nodes are placed in. */ private Path dir; /** Whether the client connector runs on a factory of its own. */ private boolean ownClientConnectorFactory; - /** Test trust store every node starts on, unless one is placed for it. */ - private String initTrustStore = "trustboth"; + /** Time used to generate certificates for this test. */ + private long now; + + /** Initial authority. */ + private TestCertificateAuthority oldCa; + + /** Replacement authority. */ + private TestCertificateAuthority newCa; + + /** Initial node key and certificate. */ + private KeyStore oldKeys; + + /** Replacement node key and certificate. */ + private KeyStore newKeys; + + /** Trust roots for probes and the transitional rotation step. */ + private KeyStore trustBoth; + + /** Trust store every node starts on, unless one is placed for it. */ + private KeyStore initTrustStore; /** */ private final ListeningTestLogger nodeLog = new ListeningTestLogger(log); @@ -100,10 +121,10 @@ public class SslContextReloadTest extends GridCommonAbstractTest { IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName).setGridLogger(nodeLog); if (!Files.exists(keyStore(igniteInstanceName))) - place("node01", keyStore(igniteInstanceName)); + TestCertificateAuthority.save(oldKeys, keyStore(igniteInstanceName)); if (!Files.exists(trustStore(igniteInstanceName))) - place(initTrustStore, trustStore(igniteInstanceName)); + TestCertificateAuthority.save(initTrustStore, trustStore(igniteInstanceName)); ClientConnectorConfiguration cliCfg = new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) .setUseIgniteSslContextFactory(!ownClientConnectorFactory); @@ -117,6 +138,14 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { dir = Files.createTempDirectory("ignite-ssl-reload-"); + now = System.currentTimeMillis(); + oldCa = new TestCertificateAuthority("old-ca"); + // The replacement issuer expires before its leaf, so status must show the chain's earlier expiry. + newCa = new TestCertificateAuthority("new-ca", now - HOUR, now + HOUR); + oldKeys = oldCa.issue("old-node", now - HOUR, now + HOUR); + newKeys = newCa.issue("new-node", now - HOUR, now + 2 * HOUR); + trustBoth = TestCertificateAuthority.trustStore(oldCa, newCa); + initTrustStore = trustBoth; } /** {@inheritDoc} */ @@ -136,16 +165,18 @@ public void testReloadOnRunningCluster() throws Exception { IgniteEx g1 = startGrid(1); LogListener logged = LogListener.matches(Pattern.compile("TLS certificates reloaded \\[transports=" + ALL_TRANSPORTS + - ", subject=CN=node02, .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); + ", subject=CN=new-node, .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); nodeLog.registerListener(logged); // The client trusts only the old CA, so it cannot reconnect after the node switches to a certificate from the new CA. + SSLContext clientCtx = TestCertificateAuthority.context(oldKeys, oldCa.trustStore()); + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() .setAddresses("127.0.0.1:" + g0.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory("node01", "trustone")) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> clientCtx) .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { - placeKeys("node02"); + placeKeys(newKeys); String res = reload(g0, g1); @@ -156,14 +187,14 @@ public void testReloadOnRunningCluster() throws Exception { assertTrue(logged.check()); } - assertEquals("CN=node02", servedSubject(g0.context().clientListener().port())); - assertEquals("CN=node02", servedSubject(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); - assertEquals("CN=node02", servedSubject(discoveryPort(g0))); + assertEquals("CN=new-node", servedSubject(g0.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); + assertEquals("CN=new-node", servedSubject(discoveryPort(g0))); SslContextProvider p = g0.context().sslContexts().providers().iterator().next(); - assertEquals("CN=node02", p.certificate().getSubjectX500Principal().getName()); - assertContains(log, p.certificate().getIssuerX500Principal().getName(), "CN=twoca"); + assertEquals("CN=new-node", p.certificate().getSubjectX500Principal().getName()); + assertContains(log, p.certificate().getIssuerX500Principal().getName(), "CN=new-ca"); assertTrue(p.reloadOutcome().successTime() > 0); String status = status(g0); @@ -175,8 +206,8 @@ public void testReloadOnRunningCluster() throws Exception { IgniteEx restarted = startGrid(0); - assertEquals("CN=node02", servedSubject(restarted.context().clientListener().port())); - assertEquals("CN=node02", servedSubject(discoveryPort(restarted))); + assertEquals("CN=new-node", servedSubject(restarted.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(discoveryPort(restarted))); } /** One node fails: the others still take the new certificate and appear in the report; the command fails. */ @@ -185,7 +216,7 @@ public void testPartialFailureAcrossNodes() throws Exception { IgniteEx g0 = startGrid(0); IgniteEx g1 = startGrid(1); - place("node02", keyStore(g0.name())); + TestCertificateAuthority.save(newKeys, keyStore(g0.name())); Files.write(keyStore(g1.name()), "not a key store".getBytes()); String res = reloadFailure(g0, g1); @@ -200,14 +231,14 @@ public void testPartialFailureAcrossNodes() throws Exception { assertTrue("The failed node must be listed first, in a separate block: " + res, res.contains(failed) && res.indexOf(succeeded) > res.indexOf(failed)); - assertEquals("CN=node02", servedSubject(g0.context().clientListener().port())); - assertEquals("CN=node01", servedSubject(g1.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(g0.context().clientListener().port())); + assertEquals("CN=old-node", servedSubject(g1.context().clientListener().port())); String status = status(g1); assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "Failed to initialize key store"); - assertContains(log, status, "loaded subject=CN=node01,"); + assertContains(log, status, "loaded subject=CN=old-node,"); } /** @@ -222,14 +253,14 @@ public void testCachingFactoryNotReloaded() throws Exception { cfg.setSslContextFactory(() -> ctx); }); - placeKeys("node02"); + placeKeys(newKeys); String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); assertContains(log, res, "returns the context in use"); - assertEquals("CN=node01", servedSubject(g.context().clientListener().port())); + assertEquals("CN=old-node", servedSubject(g.context().clientListener().port())); } /** A shared custom factory loads once at startup and once per reload. Status does not load it. */ @@ -242,6 +273,12 @@ public void testCustomFactoryReloaded() throws Exception { cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true)); }); + String transports = BINARY_REST + ", " + ALL_TRANSPORTS; + + assertEquals("Startup must load the shared factory once", 1, factory.calls); + assertContains(log, status(g), node(g) + ": " + transports); + assertEquals("Status must not load the factory", 1, factory.calls); + SslContextRegistry registry = g.context().sslContexts(); SSLContext ctx = registry.register(factory, COMMUNICATION); @@ -251,22 +288,17 @@ public void testCustomFactoryReloaded() throws Exception { assertEquals(1, registry.providers().size()); assertEquals(1, factory.calls); - String transports = BINARY_REST + ", " + ALL_TRANSPORTS; - - assertContains(log, status(g), node(g) + ": " + transports); - assertEquals("Status must not load the factory", 1, factory.calls); - - placeKeys("node02"); + placeKeys(newKeys); assertReloaded(reload(g), g, transports); assertEquals("Reload must load the shared factory once", 2, factory.calls); assertSame(ctx, registry.register(factory, COMMUNICATION)); - assertContains(log, status(g), "loaded subject=CN=node02,"); + assertContains(log, status(g), "loaded subject=CN=new-node,"); assertEquals("Status must not load the factory", 2, factory.calls); - assertEquals("CN=node02", servedSubject(discoveryPort(g))); - assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); - assertEquals("CN=node02", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); + assertEquals("CN=new-node", servedSubject(discoveryPort(g))); + assertEquals("CN=new-node", servedSubject(g.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); } /** Equal but distinct factories reload independently, including when one of them fails. */ @@ -291,7 +323,7 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertEquals(1, nodeFactory.calls); assertEquals(1, clientFactory.calls); - placeKeys("node02"); + placeKeys(newKeys); String res = reload(g); @@ -301,7 +333,7 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertEquals(2, clientFactory.calls); nodeFactory.fail = true; - placeKeys("node03"); + placeKeys(newCa.issue("new-peer", now - HOUR, now + HOUR)); res = reloadFailure(g); @@ -309,32 +341,32 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertReloaded(res, g, CLIENT_CONNECTOR); assertEquals(3, nodeFactory.calls); assertEquals(3, clientFactory.calls); - assertEquals("CN=node02", servedSubject(discoveryPort(g))); - assertEquals("CN=node03", servedSubject(g.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(discoveryPort(g))); + assertEquals("CN=new-peer", servedSubject(g.context().clientListener().port())); } /** An expired certificate stays out of use, and the report, log and status identify the rejected chain. */ @Test public void testExpiredCertificateNotApplied() throws Exception { LogListener expired = LogListener.matches(Pattern.compile("Failed to reload TLS certificates, the ones in use stay .*" + - "reason=The new certificate chain is not valid now \\[subject=CN=node02old,")).build(); + "reason=The new certificate chain is not valid now \\[subject=CN=expired-node,")).build(); nodeLog.registerListener(expired); IgniteEx g = startGrid(0); - placeKeys("node02old"); + placeKeys(expiredKeys()); String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "The new certificate chain is not valid now [subject=CN=node02old,"); + assertContains(log, res, "The new certificate chain is not valid now [subject=CN=expired-node,"); assertTrue(expired.check()); - assertEquals("CN=node01", servedSubject(discoveryPort(g))); + assertEquals("CN=old-node", servedSubject(discoveryPort(g))); String status = status(g); - assertContains(log, status, "loaded subject=CN=node01, issuer="); + assertContains(log, status, "loaded subject=CN=old-node, issuer="); assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "The new certificate chain is not valid now"); } @@ -344,29 +376,49 @@ public void testExpiredCertificateNotApplied() throws Exception { public void testKeyStoreWithoutKeyNotApplied() throws Exception { IgniteEx g = startGrid(0); - placeKeys("trustboth"); + placeKeys(trustBoth); String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); assertContains(log, res, "The new key material has no private key, so the node would present no certificate"); - assertEquals("CN=node01", servedSubject(g.context().clientListener().port())); - assertEquals("CN=node01", servedSubject(discoveryPort(g))); - assertClientConnected(g, "node01"); + assertEquals("CN=old-node", servedSubject(g.context().clientListener().port())); + assertEquals("CN=old-node", servedSubject(discoveryPort(g))); + assertClientConnected(g, oldKeys); } /** A client connector and binary REST with factories of their own reload separately from the node-wide factory. */ @Test public void testClientTransportsWithSeparateFactories() throws Exception { - initTrustStore = "trustone"; - ownClientConnectorFactory = true; + initTrustStore = oldCa.trustStore(); + KeyStore clientKeys = oldCa.issue("client-connector", now - HOUR, now + HOUR); + KeyStore restKeys = oldCa.issue("binary-rest", now - HOUR, now + HOUR); + KeyStore nextRestKeys = newCa.issue("binary-rest-next", now - HOUR, now + HOUR); + Path clientPath = dir.resolve("client-key.jks"); + Path restPath = dir.resolve("binary-rest-key.jks"); + + TestCertificateAuthority.save(clientKeys, clientPath); + TestCertificateAuthority.save(restKeys, restPath); IgniteEx g = startGrid(0, cfg -> { + Path trustPath = trustStore(cfg.getIgniteInstanceName()); + + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) + .setSslContextFactory(storeFactory(clientPath, trustPath)); cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true) - .setSslFactory(storeFactory(cfg.getIgniteInstanceName()))); + .setSslFactory(storeFactory(restPath, trustPath))); }); - placeKeys("node02"); + int clientPort = g.context().clientListener().port(); + int restPort = g.localNode().attribute(ATTR_REST_TCP_PORT); + int commPort = ((TcpCommunicationSpi)g.configuration().getCommunicationSpi()).boundPort(); + + assertEquals("CN=old-node", servedSubject(discoveryPort(g))); + assertEquals("CN=old-node", servedSubject(commPort)); + assertEquals("CN=client-connector", servedSubject(clientPort)); + assertEquals("CN=binary-rest", servedSubject(restPort)); + + TestCertificateAuthority.save(nextRestKeys, restPath); String res = reload(g); @@ -374,9 +426,10 @@ public void testClientTransportsWithSeparateFactories() throws Exception { assertReloaded(res, g, BINARY_REST); assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); - assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); - assertEquals("CN=node02", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); - assertEquals("CN=node02", servedSubject(discoveryPort(g))); + assertEquals("CN=old-node", servedSubject(discoveryPort(g))); + assertEquals("CN=old-node", servedSubject(commPort)); + assertEquals("CN=client-connector", servedSubject(clientPort)); + assertEquals("CN=binary-rest-next", servedSubject(restPort)); } /** Manual reload accepts several keys, reports unavailable metadata and keeps existing and new TLS connections working. */ @@ -385,12 +438,20 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti ownClientConnectorFactory = true; IgniteEx g = startGrid(0); + SSLContext clientCtx = TestCertificateAuthority.context(oldKeys, trustBoth); + KeyStore multipleKeys = KeyStore.getInstance("JKS"); + + multipleKeys.load(null, TestCertificateAuthority.PWD); + multipleKeys.setKeyEntry("old-node", oldKeys.getKey("old-node", TestCertificateAuthority.PWD), + TestCertificateAuthority.PWD, oldKeys.getCertificateChain("old-node")); + multipleKeys.setKeyEntry("new-node", newKeys.getKey("new-node", TestCertificateAuthority.PWD), + TestCertificateAuthority.PWD, newKeys.getCertificateChain("new-node")); try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() .setAddresses("127.0.0.1:" + g.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory("node01", "trustboth")) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> clientCtx) .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { - placeKeys("node0102"); + placeKeys(multipleKeys); String res = reload(g); @@ -402,7 +463,7 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti String subj = servedSubject(g.context().clientListener().port()); - assertTrue(subj, List.of("CN=node01", "CN=node02").contains(subj)); + assertTrue(subj, List.of("CN=old-node", "CN=new-node").contains(subj)); String status = status(g); @@ -418,7 +479,7 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti */ @Test public void testCertificateAuthorityReplaced() throws Exception { - initTrustStore = "trustone"; + initTrustStore = oldCa.trustStore(); IgniteEx g0 = startGrid(0, cfg -> { cfg.getClientConnectorConfiguration().setSslClientAuth(true); @@ -429,27 +490,27 @@ public void testCertificateAuthorityReplaced() throws Exception { cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); }); - assertTransportClientAuthentication(g0, "node01", "node02"); + assertTransportClientAuthentication(g0, oldKeys, newKeys); - placeTrust("trustboth"); + placeTrust(trustBoth); reload(g0, g1); - assertClientConnected(g0, "node01"); - assertClientConnected(g0, "node02"); + assertClientConnected(g0, oldKeys); + assertClientConnected(g0, newKeys); - placeKeys("node02"); + placeKeys(newKeys); reload(g0, g1); - placeTrust("trusttwo"); + placeTrust(newCa.trustStore()); reload(g0, g1); String reloadedStatus = status(g0, g1); - assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*CN=twoca").matcher(reloadedStatus).find()); + assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*CN=new-ca").matcher(reloadedStatus).find()); for (IgniteEx node : List.of(g0, g1)) { - assertClientConnected(node, "node02"); - assertTransportClientAuthentication(node, "node02", "node01"); + assertClientConnected(node, newKeys); + assertTransportClientAuthentication(node, newKeys, oldKeys); } ConnectionClientPool pool = U.field(g0.configuration().getCommunicationSpi(), "clientPool"); @@ -458,8 +519,8 @@ public void testCertificateAuthorityReplaced() throws Exception { assertEquals((Integer)1, g0.compute(g0.cluster().forNodeId(g1.localNode().id())).call(() -> 1)); - place("node03", keyStore(getTestIgniteInstanceName(2))); - place("trusttwo", trustStore(getTestIgniteInstanceName(2))); + TestCertificateAuthority.save(newCa.issue("new-peer", now - HOUR, now + HOUR), keyStore(getTestIgniteInstanceName(2))); + TestCertificateAuthority.save(newCa.trustStore(), trustStore(getTestIgniteInstanceName(2))); IgniteEx g2 = startGrid(2); @@ -475,23 +536,23 @@ public void testCertificateAuthorityReplaced() throws Exception { public void testStatus() throws Exception { ownClientConnectorFactory = true; - place("node02old", keyStore(getTestIgniteInstanceName(0))); + TestCertificateAuthority.save(expiredKeys(), keyStore(getTestIgniteInstanceName(0))); IgniteEx g = startGrid(0); String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); String id = node(g); - assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=CN=node02old, issuer="); - assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=CN=node02old, issuer="); - assertContains(log, res, "CN=twoca"); + assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=CN=expired-node, issuer="); + assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=CN=expired-node, issuer="); + assertContains(log, res, "CN=new-ca"); assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); } /** A node that starts on an expired certificate logs a warning. */ @Test public void testExpiredCertificateWarnedAtStart() throws Exception { - place("node02old", keyStore(getTestIgniteInstanceName(0))); + TestCertificateAuthority.save(expiredKeys(), keyStore(getTestIgniteInstanceName(0))); LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now").build(); @@ -525,7 +586,7 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { IgniteEx g = startGrid(0); UUID unavailable = UUID.randomUUID(); - placeKeys("node02"); + placeKeys(newKeys); VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); @@ -535,10 +596,10 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + ": node was unavailable to run the command"); assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); - assertContains(log, err.getMessage(), "loaded subject=CN=node02,"); + assertContains(log, err.getMessage(), "loaded subject=CN=new-node,"); } - assertEquals("CN=node02", servedSubject(g.context().clientListener().port())); + assertEquals("CN=new-node", servedSubject(g.context().clientListener().port())); } /** Both commands report that no SSL context factory is configured. */ @@ -552,21 +613,23 @@ public void testWithoutSsl() throws Exception { } /** Checks client authentication on discovery, communication, the client connector and binary REST. */ - private void assertTransportClientAuthentication(IgniteEx node, String trustedKey, String untrustedKey) throws Exception { + private void assertTransportClientAuthentication(IgniteEx node, KeyStore trustedKeys, KeyStore untrustedKeys) throws Exception { for (int port : new int[] { discoveryPort(node), ((TcpCommunicationSpi)node.configuration().getCommunicationSpi()).boundPort(), node.context().clientListener().port(), node.localNode().attribute(ATTR_REST_TCP_PORT) }) - assertClientAuthentication(port, trustedKey, untrustedKey); + assertClientAuthentication(port, trustedKeys, untrustedKeys, trustBoth); } /** Opens a new authenticated client connection; its own trust store accepts both test authorities. */ - private void assertClientConnected(IgniteEx node, String keyStore) { + private void assertClientConnected(IgniteEx node, KeyStore keys) throws Exception { + SSLContext ctx = TestCertificateAuthority.context(keys, trustBoth); + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED).setSslContextFactory(GridTestUtils.sslTrustedFactory(keyStore, "trustboth")) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> ctx) .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { cli.cacheNames(); } @@ -574,12 +637,19 @@ private void assertClientConnected(IgniteEx node, String keyStore) { /** @return Factory reading the stores of the node. */ private Factory storeFactory(String igniteInstanceName) { + return storeFactory(keyStore(igniteInstanceName), trustStore(igniteInstanceName)); + } + + /** @return Factory reading the specified JKS files. */ + private SslContextFactory storeFactory(Path keys, Path trust) { SslContextFactory factory = new SslContextFactory(); - factory.setKeyStoreFilePath(keyStore(igniteInstanceName).toString()); - factory.setKeyStorePassword(GridTestUtils.keyStorePassword().toCharArray()); - factory.setTrustStoreFilePath(trustStore(igniteInstanceName).toString()); - factory.setTrustStorePassword(GridTestUtils.keyStorePassword().toCharArray()); + factory.setKeyStoreType("JKS"); + factory.setTrustStoreType("JKS"); + factory.setKeyStoreFilePath(keys.toString()); + factory.setKeyStorePassword(TestCertificateAuthority.PWD); + factory.setTrustStoreFilePath(trust.toString()); + factory.setTrustStorePassword(TestCertificateAuthority.PWD); return factory; } @@ -594,16 +664,26 @@ private Path trustStore(String igniteInstanceName) { return dir.resolve(igniteInstanceName + "-trust.jks"); } - /** @param name Test key store to place for every running node. */ - private void placeKeys(String name) throws IOException { + /** @param store Key store to write for every running node. */ + private void placeKeys(KeyStore store) throws Exception { for (Ignite node : G.allGrids()) - place(name, keyStore(node.name())); + TestCertificateAuthority.save(store, keyStore(node.name())); } - /** @param name Test trust store to place for every running node. */ - private void placeTrust(String name) throws IOException { + /** @param store Trust store to write for every running node. */ + private void placeTrust(KeyStore store) throws Exception { for (Ignite node : G.allGrids()) - place(name, trustStore(node.name())); + TestCertificateAuthority.save(store, trustStore(node.name())); + } + + /** @return Certificate subject served on a fresh connection, using a probe that trusts both authorities. */ + private String servedSubject(int port) throws Exception { + return servedCertificate(TestCertificateAuthority.context(oldKeys, trustBoth), port).getSubjectX500Principal().getName(); + } + + /** @return Store with an expired leaf issued by the replacement authority. */ + private KeyStore expiredKeys() throws Exception { + return newCa.issue("expired-node", now - 2 * HOUR, now - HOUR); } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java index b1cceaa5008c6..ff7a2a80720a7 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java @@ -52,6 +52,7 @@ import org.apache.ignite.internal.visor.VisorTaskArgument; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.jetbrains.annotations.Nullable; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; @@ -59,7 +60,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; -/** Runs the {@code --ssl} commands the way control.sh does, probes what the transports serve, and places the test stores. */ +/** Executes SSL management tasks, probes transport certificates and prepares test stores. */ public class SslTestUtils { /** Timeout for a probe connection or TLS handshake. */ private static final int SOCKET_TIMEOUT = 30_000; @@ -127,15 +128,35 @@ public static String servedSubject(int port) throws Exception { * @param untrustedKey Test key store not trusted by the server. */ public static void assertClientAuthentication(int port, String trustedKey, String untrustedKey) throws Exception { - try (SSLSocket sock = tls12Socket(clientContext(trustedKey), port)) { + assertClientAuthentication(port, clientContext(trustedKey), clientContext(untrustedKey), clientContext(null)); + } + + /** + * Checks trusted, untrusted and absent client certificates using generated stores. + * + * @param port Server port. + * @param trustedKeys Single-key store trusted by the server. + * @param untrustedKeys Single-key store not trusted by the server. + * @param trustStore Authorities trusted by all three clients. + */ + public static void assertClientAuthentication(int port, KeyStore trustedKeys, KeyStore untrustedKeys, KeyStore trustStore) + throws Exception { + assertClientAuthentication(port, clientContext(trustedKeys, trustStore), clientContext(untrustedKeys, trustStore), + clientContext(null, trustStore)); + } + + /** Checks all three clients using fresh contexts, so a resumed session cannot hide an authentication failure. */ + private static void assertClientAuthentication(int port, SSLContext trusted, SSLContext untrusted, SSLContext withoutKey) + throws Exception { + try (SSLSocket sock = tls12Socket(trusted, port)) { sock.startHandshake(); } catch (IOException e) { - throw new AssertionError("Trusted client must connect [port=" + port + ", client=" + trustedKey + ']', e); + throw new AssertionError("Trusted client must connect [port=" + port + ']', e); } - assertHandshakeRefused(untrustedKey, port); - assertHandshakeRefused(null, port); + assertHandshakeRefused(untrusted, port, "untrusted certificate"); + assertHandshakeRefused(withoutKey, port, "no certificate"); } /** @@ -146,28 +167,42 @@ public static void assertClientAuthentication(int port, String trustedKey, Strin * @return Fresh client context. */ public static SSLContext clientContext(@Nullable String keyStore) throws Exception { - KeyManager[] keyManagers = new KeyManager[0]; + return clientContext(keyStore == null ? null : store(keyStore), keyStorePassword().toCharArray(), store("trustboth")); + } - if (keyStore != null) { - KeyStore keys = store(keyStore); + /** + * Creates a client from generated stores, forcing the sole private-key alias even if its issuer is not advertised. + * + * @param keys Single-key store, or {@code null} to present no certificate. + * @param trustStore Authorities trusted by the client. + * @return Fresh client context. + */ + public static SSLContext clientContext(@Nullable KeyStore keys, KeyStore trustStore) throws Exception { + return clientContext(keys, TestCertificateAuthority.PWD, trustStore); + } + /** Creates the client while retaining the selected private-key alias and the supplied trust roots. */ + private static SSLContext clientContext(@Nullable KeyStore keys, char[] pwd, KeyStore trustStore) throws Exception { + KeyManager[] keyManagers = new KeyManager[0]; + + if (keys != null) { KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keys, keyStorePassword().toCharArray()); + kmf.init(keys, pwd); X509KeyManager km = (X509KeyManager)kmf.getKeyManagers()[0]; String alias = keys.aliases().nextElement(); - assertNotNull("No private key in test store " + keyStore, km.getPrivateKey(alias)); + assertNotNull("No private key for test alias " + alias, km.getPrivateKey(alias)); X509KeyManager always = new X509KeyManager() { @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket sock) { - return alias; + return km.chooseClientAlias(keyType, null, sock); } @Override public String[] getClientAliases(String keyType, Principal[] issuers) { - return new String[] {alias}; + return km.getClientAliases(keyType, null); } @Override public String[] getServerAliases(String keyType, Principal[] issuers) { @@ -192,7 +227,7 @@ public static SSLContext clientContext(@Nullable String keyStore) throws Excepti TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(store("trustboth")); + tmf.init(trustStore); SSLContext ctx = SSLContext.getInstance("TLS"); @@ -213,13 +248,12 @@ private static KeyStore store(String name) throws Exception { } /** - * @param keyStore Untrusted test store, or {@code null} for a client without a certificate. + * @param ctx Client context. * @param port Server port. + * @param client Description of the refused client. */ - private static void assertHandshakeRefused(@Nullable String keyStore, int port) throws Exception { - String client = keyStore == null ? "no certificate" : keyStore; - - try (SSLSocket sock = tls12Socket(clientContext(keyStore), port)) { + private static void assertHandshakeRefused(SSLContext ctx, int port, String client) throws Exception { + try (SSLSocket sock = tls12Socket(ctx, port)) { try { sock.startHandshake(); From 893a520f8808f3e23d4cced42bd5d51519142311 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:04:23 +0300 Subject: [PATCH 026/242] IGNITE-29209 Make metadata test premises and expectations explicit. --- .../ignite/ssl/SslContextMetadataTest.java | 94 ++++++++++--------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 9ca68bbabb3d9..57c672b62b852 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -31,7 +31,6 @@ import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; -import javax.security.auth.x500.X500Principal; import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; @@ -53,36 +52,38 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** {@code createNew()} loads the store once, preserves the cached context and attaches the new chain to the new context. */ + /** A fresh context loads the replacement store once and leaves the cached context's certificate metadata unchanged. */ @Test - public void testMetadataUsesOneLoadAndStaysWithContext() throws Exception { - KeyStore firstStore = issueStore("first"); - StoreFactory factory = new StoreFactory(firstStore); + public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() throws Exception { + KeyStore originalKeys = keyStoreWithPrivateKey("original"); + X509Certificate[] originalChain = storedChain(originalKeys, "original"); + StoreFactory factory = new StoreFactory(originalKeys); - SSLContext first = factory.create(); + SSLContext cached = factory.create(); - assertArrayEquals(chain(firstStore, "first"), chain(first)); - assertEquals(new X500Principal("CN=first"), chain(first)[0].getSubjectX500Principal()); - assertFalse(privateKeyMissing(first)); assertEquals(1, factory.loads); + assertArrayEquals(originalChain, reportedChain(cached)); - factory.store = issueStore("next"); + KeyStore replacementKeys = keyStoreWithPrivateKey("replacement"); + X509Certificate[] replacementChain = storedChain(replacementKeys, "replacement"); - SSLContext next = factory.createNew(); + factory.store = replacementKeys; - assertNotSame(first, next); - assertSame(first, factory.create()); + SSLContext fresh = factory.createNew(); + + assertNotSame(cached, fresh); assertEquals(2, factory.loads); - assertArrayEquals(chain(firstStore, "first"), chain(first)); - assertArrayEquals(chain(factory.store, "next"), chain(next)); - assertEquals(new X500Principal("CN=next"), chain(next)[0].getSubjectX500Principal()); - assertFalse(privateKeyMissing(next)); + assertArrayEquals(replacementChain, reportedChain(fresh)); + + assertSame(cached, factory.create()); + assertArrayEquals(originalChain, reportedChain(cached)); + assertEquals("Returning the cached context must not load the store again", 2, factory.loads); } /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = issueStore("node"); + KeyStore store = keyStoreWithPrivateKey("node"); StoreFactory factory = new StoreFactory(store); String[] protocols = {"TLSv1.2"}; String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; @@ -91,7 +92,8 @@ public void testMetadataWithTlsParameters() throws Exception { factory.setCipherSuites(ciphers); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertArrayEquals(chain(store, "node"), chain(ctx)); + assertArrayEquals(storedChain(store, "node"), reportedChain(ctx)); + assertFalse("The loaded store contains a private key", reportsMissingPrivateKey(ctx)); SSLEngine engine = ctx.createSSLEngine(); @@ -103,23 +105,23 @@ public void testMetadataWithTlsParameters() throws Exception { /** Multiple private keys leave the chain unknown but are not reported as missing. */ @Test public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { - KeyStore store = issueStore("first"); - KeyStore other = issueStore("second"); + KeyStore store = keyStoreWithPrivateKey("first"); + KeyStore other = keyStoreWithPrivateKey("second"); store.setKeyEntry("second", other.getKey("second", PASSWORD), PASSWORD, other.getCertificateChain("second")); StoreFactory factory = new StoreFactory(store); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertNull(chain(ctx)); - assertFalse(privateKeyMissing(ctx)); + assertNull(reportedChain(ctx)); + assertFalse("Two private keys are present even though the served chain is unknown", reportsMissingPrivateKey(ctx)); } } /** Trusted certificates and secret keys do not count as private keys. */ @Test public void testOnlyPrivateKeyEntriesCount() throws Exception { - KeyStore keys = issueStore("node"); + KeyStore keys = keyStoreWithPrivateKey("node"); KeyStore store = KeyStore.getInstance("JCEKS"); store.load(null, PASSWORD); @@ -131,15 +133,15 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { SSLContext withoutKey = factory.create(); - assertNull(chain(withoutKey)); - assertTrue(privateKeyMissing(withoutKey)); + assertNull(reportedChain(withoutKey)); + assertTrue("Neither the trusted certificate nor the secret key is a private key", reportsMissingPrivateKey(withoutKey)); store.setKeyEntry("key", keys.getKey("node", PASSWORD), PASSWORD, keys.getCertificateChain("node")); SSLContext withKey = factory.createNew(); - assertArrayEquals(chain(keys, "node"), chain(withKey)); - assertFalse(privateKeyMissing(withKey)); + assertArrayEquals(storedChain(keys, "node"), reportedChain(withKey)); + assertFalse("The store now contains a private-key entry", reportsMissingPrivateKey(withKey)); } /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ @@ -148,12 +150,12 @@ public void testTrustOnlyContextCreation() throws Exception { KeyStore store = KeyStore.getInstance("PKCS12"); store.load(null, PASSWORD); - store.setCertificateEntry("trusted", issueStore("trusted").getCertificate("trusted")); + store.setCertificateEntry("trusted", keyStoreWithPrivateKey("trusted").getCertificate("trusted")); StoreFactory factory = new StoreFactory(store); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertNull(chain(ctx)); + assertNull(reportedChain(ctx)); assertTrue(((SSLContextWrapper)ctx).isPrivateKeyMissing()); SSLParameters params = new SSLParameters(); @@ -174,8 +176,8 @@ public void testLegacyHooksRunOncePerBuiltContext() { SSLContext first = factory.create(); - assertNull(chain(first)); - assertFalse(privateKeyMissing(first)); + assertNull(reportedChain(first)); + assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(first)); assertEquals(1, factory.checks); assertEquals(1, factory.keys); assertEquals(1, factory.trust); @@ -189,8 +191,8 @@ public void testLegacyHooksRunOncePerBuiltContext() { SSLContext next = factory.createNew(); assertNotSame(first, next); - assertNull(chain(next)); - assertFalse(privateKeyMissing(next)); + assertNull(reportedChain(next)); + assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(next)); assertEquals(2, factory.checks); assertEquals(2, factory.keys); assertEquals(2, factory.trust); @@ -199,7 +201,7 @@ public void testLegacyHooksRunOncePerBuiltContext() { /** A subclass can still obtain key managers from the file factory's legacy hook. */ @Test public void testLegacyKeyManagerAdapter() throws Exception { - StoreFactory factory = new StoreFactory(issueStore("node")); + StoreFactory factory = new StoreFactory(keyStoreWithPrivateKey("node")); KeyManager[] mgrs = factory.legacyKeyManagers(); assertNotNull(mgrs); @@ -238,7 +240,7 @@ public void testMissingKeyMaterialImplementation() { public void testKeyMaterialDefensiveCopies() throws Exception { KeyManager mgr = new KeyManager() { }; KeyManager[] mgrs = {mgr}; - X509Certificate[] expected = chain(issueStore("node"), "node"); + X509Certificate[] expected = storedChain(keyStoreWithPrivateKey("node"), "node"); X509Certificate[] input = expected.clone(); AbstractSslContextFactory.KeyMaterial material = new AbstractSslContextFactory.KeyMaterial(mgrs, input); @@ -265,14 +267,14 @@ public void testKeyMaterialDefensiveCopies() throws Exception { } }; - assertArrayEquals(expected, chain(factory.create())); - assertArrayEquals(expected, chain(factory.createNew())); + assertArrayEquals(expected, reportedChain(factory.create())); + assertArrayEquals(expected, reportedChain(factory.createNew())); } /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ @Test public void testWrapperPreservesAndCopiesChain() throws Exception { - X509Certificate[] expected = chain(issueStore("node"), "node"); + X509Certificate[] expected = storedChain(keyStoreWithPrivateKey("node"), "node"); X509Certificate[] input = expected.clone(); SSLContext delegate = SSLContext.getInstance("TLS"); @@ -298,7 +300,7 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { /** Explicit metadata takes precedence over the wrapped context's metadata. */ @Test public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { - X509Certificate[] expectedChain = chain(issueStore("node"), "node"); + X509Certificate[] expectedChain = storedChain(keyStoreWithPrivateKey("node"), "node"); SSLContext delegate = SSLContext.getInstance("TLS"); delegate.init(null, null, null); @@ -327,7 +329,7 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { delegate.init(null, null, null); - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, chain(issueStore("node"), "node")); + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, storedChain(keyStoreWithPrivateKey("node"), "node")); SSLEngine engine = wrapper.createSSLEngine("peer.example", 10800); SSLEngine delegateEngine = delegate.createSSLEngine(); @@ -353,26 +355,26 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { } /** @return In-memory store with one private key and its certificate chain. */ - private KeyStore issueStore(String name) throws Exception { + private KeyStore keyStoreWithPrivateKey(String name) throws Exception { long now = System.currentTimeMillis(); return ca.issue(name, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); } /** @return Chain stored for the alias. */ - private static X509Certificate[] chain(KeyStore store, String alias) throws Exception { + private static X509Certificate[] storedChain(KeyStore store, String alias) throws Exception { Certificate[] chain = store.getCertificateChain(alias); return Arrays.copyOf(chain, chain.length, X509Certificate[].class); } /** @return Chain attached to the context, if known. */ - private static X509Certificate[] chain(SSLContext ctx) { + private static X509Certificate[] reportedChain(SSLContext ctx) { return ctx instanceof SSLContextWrapper ? ((SSLContextWrapper)ctx).getCertificateChain() : null; } - /** @return Whether the context reports that its key material has no private key. */ - private static boolean privateKeyMissing(SSLContext ctx) { + /** @return Whether the context reports that no private key was loaded. Unknown metadata does not set this marker. */ + private static boolean reportsMissingPrivateKey(SSLContext ctx) { return ctx instanceof SSLContextWrapper && ((SSLContextWrapper)ctx).isPrivateKeyMissing(); } From 856866a1f8bdd6c83782f79fd0c00cac59b1862a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:27:28 +0300 Subject: [PATCH 027/242] IGNITE-29209 Make certificate test inputs explicit and reusable --- .../ignite/ssl/SslContextMetadataTest.java | 95 ++++++++----------- .../TestCertificateAuthority.java | 39 ++++++-- 2 files changed, 72 insertions(+), 62 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 57c672b62b852..56d28044cdf38 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -18,11 +18,8 @@ package org.apache.ignite.ssl; import java.security.KeyStore; -import java.security.cert.Certificate; import java.security.cert.X509Certificate; -import java.util.Arrays; -import java.util.concurrent.TimeUnit; -import javax.crypto.spec.SecretKeySpec; +import javax.crypto.KeyGenerator; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; @@ -37,6 +34,7 @@ import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; +import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; import static org.junit.Assert.assertArrayEquals; /** Tests that certificate metadata belongs to the key material used to create a context. */ @@ -55,8 +53,8 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { /** A fresh context loads the replacement store once and leaves the cached context's certificate metadata unchanged. */ @Test public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() throws Exception { - KeyStore originalKeys = keyStoreWithPrivateKey("original"); - X509Certificate[] originalChain = storedChain(originalKeys, "original"); + KeyStore originalKeys = ca.issue(); + X509Certificate[] originalChain = certificateChain(originalKeys); StoreFactory factory = new StoreFactory(originalKeys); SSLContext cached = factory.create(); @@ -64,8 +62,8 @@ public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() thro assertEquals(1, factory.loads); assertArrayEquals(originalChain, reportedChain(cached)); - KeyStore replacementKeys = keyStoreWithPrivateKey("replacement"); - X509Certificate[] replacementChain = storedChain(replacementKeys, "replacement"); + KeyStore replacementKeys = ca.issue(); + X509Certificate[] replacementChain = certificateChain(replacementKeys); factory.store = replacementKeys; @@ -83,7 +81,7 @@ public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() thro /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = keyStoreWithPrivateKey("node"); + KeyStore store = ca.issue(); StoreFactory factory = new StoreFactory(store); String[] protocols = {"TLSv1.2"}; String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; @@ -92,7 +90,7 @@ public void testMetadataWithTlsParameters() throws Exception { factory.setCipherSuites(ciphers); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertArrayEquals(storedChain(store, "node"), reportedChain(ctx)); + assertArrayEquals(certificateChain(store), reportedChain(ctx)); assertFalse("The loaded store contains a private key", reportsMissingPrivateKey(ctx)); SSLEngine engine = ctx.createSSLEngine(); @@ -105,10 +103,11 @@ public void testMetadataWithTlsParameters() throws Exception { /** Multiple private keys leave the chain unknown but are not reported as missing. */ @Test public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { - KeyStore store = keyStoreWithPrivateKey("first"); - KeyStore other = keyStoreWithPrivateKey("second"); + String secondKey = "second-key"; + KeyStore store = ca.issue(); + KeyStore other = ca.issue(secondKey); - store.setKeyEntry("second", other.getKey("second", PASSWORD), PASSWORD, other.getCertificateChain("second")); + store.setKeyEntry(secondKey, other.getKey(secondKey, PASSWORD), PASSWORD, other.getCertificateChain(secondKey)); StoreFactory factory = new StoreFactory(store); @@ -121,12 +120,15 @@ public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { /** Trusted certificates and secret keys do not count as private keys. */ @Test public void testOnlyPrivateKeyEntriesCount() throws Exception { - KeyStore keys = keyStoreWithPrivateKey("node"); + String keyAlias = "private-key"; + KeyStore keys = ca.issue(keyAlias); + + // JCEKS can hold both private keys and secret keys. KeyStore store = KeyStore.getInstance("JCEKS"); store.load(null, PASSWORD); - store.setCertificateEntry("trusted", keys.getCertificate("node")); - store.setEntry("secret", new KeyStore.SecretKeyEntry(new SecretKeySpec(new byte[16], "AES")), + store.setCertificateEntry("trusted", keys.getCertificate(keyAlias)); + store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), new KeyStore.PasswordProtection(PASSWORD)); StoreFactory factory = new StoreFactory(store); @@ -136,36 +138,31 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { assertNull(reportedChain(withoutKey)); assertTrue("Neither the trusted certificate nor the secret key is a private key", reportsMissingPrivateKey(withoutKey)); - store.setKeyEntry("key", keys.getKey("node", PASSWORD), PASSWORD, keys.getCertificateChain("node")); + store.setKeyEntry(keyAlias, keys.getKey(keyAlias, PASSWORD), PASSWORD, keys.getCertificateChain(keyAlias)); SSLContext withKey = factory.createNew(); - assertArrayEquals(storedChain(keys, "node"), reportedChain(withKey)); + assertArrayEquals(certificateChain(keys), reportedChain(withKey)); assertFalse("The store now contains a private-key entry", reportsMissingPrivateKey(withKey)); } /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ @Test public void testTrustOnlyContextCreation() throws Exception { - KeyStore store = KeyStore.getInstance("PKCS12"); - - store.load(null, PASSWORD); - store.setCertificateEntry("trusted", keyStoreWithPrivateKey("trusted").getCertificate("trusted")); + StoreFactory factory = new StoreFactory(ca.trustStore()); + String[] protocols = {"TLSv1.2"}; + SSLParameters params = new SSLParameters(); - StoreFactory factory = new StoreFactory(store); + params.setProtocols(protocols); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { assertNull(reportedChain(ctx)); assertTrue(((SSLContextWrapper)ctx).isPrivateKeyMissing()); - SSLParameters params = new SSLParameters(); - - params.setProtocols(new String[] {"TLSv1.2"}); - SSLContextWrapper outer = new SSLContextWrapper(ctx, params); assertTrue(outer.isPrivateKeyMissing()); - assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); + assertArrayEquals(protocols, outer.createSSLEngine().getEnabledProtocols()); } } @@ -201,7 +198,7 @@ public void testLegacyHooksRunOncePerBuiltContext() { /** A subclass can still obtain key managers from the file factory's legacy hook. */ @Test public void testLegacyKeyManagerAdapter() throws Exception { - StoreFactory factory = new StoreFactory(keyStoreWithPrivateKey("node")); + StoreFactory factory = new StoreFactory(ca.issue()); KeyManager[] mgrs = factory.legacyKeyManagers(); assertNotNull(mgrs); @@ -238,9 +235,10 @@ public void testMissingKeyMaterialImplementation() { /** A subclass can supply material directly, and callers cannot replace its recorded managers or chain through arrays. */ @Test public void testKeyMaterialDefensiveCopies() throws Exception { - KeyManager mgr = new KeyManager() { }; - KeyManager[] mgrs = {mgr}; - X509Certificate[] expected = storedChain(keyStoreWithPrivateKey("node"), "node"); + KeyStore keys = ca.issue(); + KeyManager[] mgrs = TestCertificateAuthority.keyManagers(keys); + KeyManager mgr = mgrs[0]; + X509Certificate[] expected = certificateChain(keys); X509Certificate[] input = expected.clone(); AbstractSslContextFactory.KeyMaterial material = new AbstractSslContextFactory.KeyMaterial(mgrs, input); @@ -274,7 +272,7 @@ public void testKeyMaterialDefensiveCopies() throws Exception { /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ @Test public void testWrapperPreservesAndCopiesChain() throws Exception { - X509Certificate[] expected = storedChain(keyStoreWithPrivateKey("node"), "node"); + X509Certificate[] expected = certificateChain(ca.issue()); X509Certificate[] input = expected.clone(); SSLContext delegate = SSLContext.getInstance("TLS"); @@ -287,20 +285,21 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { assertArrayEquals(expected, wrapper.getCertificateChain()); + String[] protocols = {"TLSv1.2"}; SSLParameters params = new SSLParameters(); - params.setProtocols(new String[] {"TLSv1.2"}); + params.setProtocols(protocols); SSLContextWrapper outer = new SSLContextWrapper(wrapper, params); assertArrayEquals(expected, outer.getCertificateChain()); - assertArrayEquals(new String[] {"TLSv1.2"}, outer.createSSLEngine().getEnabledProtocols()); + assertArrayEquals(protocols, outer.createSSLEngine().getEnabledProtocols()); } /** Explicit metadata takes precedence over the wrapped context's metadata. */ @Test public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { - X509Certificate[] expectedChain = storedChain(keyStoreWithPrivateKey("node"), "node"); + X509Certificate[] expectedChain = certificateChain(ca.issue()); SSLContext delegate = SSLContext.getInstance("TLS"); delegate.init(null, null, null); @@ -329,12 +328,14 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { delegate.init(null, null, null); - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, storedChain(keyStoreWithPrivateKey("node"), "node")); - SSLEngine engine = wrapper.createSSLEngine("peer.example", 10800); + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, certificateChain(ca.issue())); + String host = "peer.example"; + int port = 10800; + SSLEngine engine = wrapper.createSSLEngine(host, port); SSLEngine delegateEngine = delegate.createSSLEngine(); - assertEquals("peer.example", engine.getPeerHost()); - assertEquals(10800, engine.getPeerPort()); + assertEquals(host, engine.getPeerHost()); + assertEquals(port, engine.getPeerPort()); assertArrayEquals(delegateEngine.getEnabledProtocols(), engine.getEnabledProtocols()); assertArrayEquals(delegateEngine.getEnabledCipherSuites(), engine.getEnabledCipherSuites()); @@ -354,20 +355,6 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { } } - /** @return In-memory store with one private key and its certificate chain. */ - private KeyStore keyStoreWithPrivateKey(String name) throws Exception { - long now = System.currentTimeMillis(); - - return ca.issue(name, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); - } - - /** @return Chain stored for the alias. */ - private static X509Certificate[] storedChain(KeyStore store, String alias) throws Exception { - Certificate[] chain = store.getCertificateChain(alias); - - return Arrays.copyOf(chain, chain.length, X509Certificate[].class); - } - /** @return Chain attached to the context, if known. */ private static X509Certificate[] reportedChain(SSLContext ctx) { return ctx instanceof SSLContextWrapper ? ((SSLContextWrapper)ctx).getCertificateChain() : null; diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 693006fc8f558..4bc0d2754ae3b 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -24,7 +24,6 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; -import java.security.SecureRandom; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.security.spec.ECGenParameterSpec; @@ -32,6 +31,7 @@ import java.util.Collections; import java.util.Date; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -49,14 +49,16 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; /** - * Certificate authority for tests that need explicit keys, trust relationships and certificate validity periods. + * Issues JKS stores protected by {@link #PWD}, each with an EC P-256 private key and its certificate chain. + * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, + * so the same material works for node-to-node TLS. Tests choose the issuer and validity dates when those are part of the scenario. */ public class TestCertificateAuthority { /** Password of every store made here. */ public static final char[] PWD = "123456".toCharArray(); - /** */ - private static final SecureRandom RND = new SecureRandom(); + /** Certificate serial numbers must be positive and unique within this authority. */ + private final AtomicLong serial = new AtomicLong(); /** */ private final X500Principal name; @@ -67,7 +69,11 @@ public class TestCertificateAuthority { /** */ private final X509Certificate cert; - /** @param cn Common name of the authority. */ + /** + * Creates an authority valid from yesterday until a year from now. + * + * @param cn Common name of the authority. + */ public TestCertificateAuthority(String cn) throws Exception { this(cn, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365)); } @@ -84,8 +90,25 @@ public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws cert = sign(name, keys, notBefore, notAfter, true); } + /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ + public KeyStore issue() throws Exception { + return issue("test-certificate"); + } + + /** + * Issues a certificate valid from an hour ago until a day from now, for tests that do not check expiry. + * + * @param cn Common name, also used as the key alias. + * @return Store with the private key and certificate chain. + */ + public KeyStore issue(String cn) throws Exception { + long now = System.currentTimeMillis(); + + return issue(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); + } + /** - * @param cn Common name of the certificate. + * @param cn Common name of the certificate, also used as the key alias. * @param notBefore Time it becomes valid; rounded down to a second. * @param notAfter Time it expires; rounded down to a second. * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. @@ -151,7 +174,7 @@ public static X509Certificate[] certificateChain(KeyStore keyStore) throws Excep X509Certificate[] chain = null; for (String alias : Collections.list(keyStore.aliases())) { - if (!keyStore.isKeyEntry(alias)) + if (!keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) continue; if (chain != null) @@ -210,7 +233,7 @@ public static void save(KeyStore store, Path path) throws Exception { private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( name, - new BigInteger(Long.SIZE, RND).add(BigInteger.ONE), + BigInteger.valueOf(serial.incrementAndGet()), new Date(notBefore), new Date(notAfter), subj, From 06c6b85506fd5619fc18b2c01e141b8ea17358ae Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:30:59 +0300 Subject: [PATCH 028/242] IGNITE-29210 Keep reload test inputs tied to their guarantees --- .../internal/ssl/SslContextProviderTest.java | 69 +++---- .../ignite/ssl/SslContextReloadTest.java | 190 +++++++++++------- 2 files changed, 152 insertions(+), 107 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index d7456dfbeaca7..4937e788ba81e 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -71,7 +71,7 @@ public class SslContextProviderTest extends GridCommonAbstractTest { /** Changing a returned chain must not change the provider's certificate or chain. */ @Test public void testCertificateChainCannotChangeProvider() { - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR))); + SslContextProvider p = new SslContextProvider(new Issuer(ca::issue)); X509Certificate[] chain = p.certificateChain(); @@ -100,12 +100,11 @@ public void testReloadWithDifferentAuthorities() throws Exception { /** Reload installs the administrator's material; an actual TLS connection still rejects an authority the peer does not trust. */ @Test public void testReloadDoesNotBypassPeerTrust() throws Exception { - long now = System.currentTimeMillis(); - + String untrustedCaName = "untrusted-ca"; TestCertificateAuthority peerCa = new TestCertificateAuthority("peerca"); - TestCertificateAuthority untrusted = new TestCertificateAuthority("untrustedca"); - AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - MINUTE, now + HOUR)); - KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + TestCertificateAuthority untrusted = new TestCertificateAuthority(untrustedCaName); + AtomicReference keys = new AtomicReference<>(ca.issue()); + KeyStore peerKeys = peerCa.issue(); Issuer issuer = issuer(keys::get, peerCa.trustStore()); Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); @@ -117,19 +116,19 @@ public void testReloadDoesNotBypassPeerTrust() throws Exception { assertEquals(provider(g0).certificate(), servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); - keys.set(untrusted.issue("node0-new", now, now + 2 * HOUR)); + keys.set(untrusted.issue()); assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); assertTrue(provider(g0).reloadOutcome().successTime() > 0); - assertEquals("CN=untrustedca", provider(g0).certificate().getIssuerX500Principal().getName()); + assertEquals("CN=" + untrustedCaName, provider(g0).certificate().getIssuerX500Principal().getName()); assertEquals(peerCert, provider(g1).certificate()); assertThrowsWithCause(() -> servedCertificate(peerIssuer.createNew(), discoveryPort(g0)), SSLException.class); } - /** A factory with no certificate metadata can be reloaded, and status reports the missing metadata. */ + /** Commands reload a store with multiple private keys and report that its certificate metadata is unavailable. */ @Test - public void testUnknownCertificate() throws Exception { + public void testCommandsReportUnknownMetadataForMultipleKeys() throws Exception { Issuer issuer = new Issuer(this::twoKeys); IgniteEx g = startClientConnectorOn(issuer); @@ -144,7 +143,7 @@ public void testUnknownCertificate() throws Exception { /** Replacing one key with several keys must not prevent manual reload. */ @Test public void testReloadCanLoseMetadata() throws Exception { - Issuer issuer = new Issuer(fresh(HOUR), this::twoKeys); + Issuer issuer = new Issuer(ca::issue, this::twoKeys); SslContextProvider p = new SslContextProvider(issuer); SSLContext inUse = p.context(); @@ -162,8 +161,9 @@ public void testReloadCanLoseMetadata() throws Exception { /** Reload failures keep the active context and last success time. A successful retry clears the failure history. */ @Test public void testReloadFailureRetainsContextUntilRecovery() throws Exception { + String failureReason = "Certificate source is unavailable"; Callable unavailable = () -> { - throw new IgniteException("Certificate source is unavailable"); + throw new IgniteException(failureReason); }; SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), fresh(HOUR), unavailable, unavailable, fresh(2 * HOUR))); @@ -179,14 +179,14 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { for (int failures = 1; failures <= 2; failures++) { long attemptStart = System.currentTimeMillis(); - GridTestUtils.assertThrows(log, p::reload, IgniteException.class, "Certificate source is unavailable"); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, failureReason); assertSame(inUse, p.context()); assertArrayEquals(chain, p.certificateChain()); assertEquals(lastSuccess, p.reloadOutcome().successTime()); assertEquals(failures, p.reloadOutcome().failures()); assertTrue(p.reloadOutcome().failureTime() >= attemptStart); - assertContains(log, p.reloadOutcome().failureReason(), "Certificate source is unavailable"); + assertContains(log, p.reloadOutcome().failureReason(), failureReason); } long recoveryStart = System.currentTimeMillis(); @@ -206,7 +206,7 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { @Test public void testNotYetValidCertificateNotApplied() throws Exception { long now = System.currentTimeMillis(); - Issuer issuer = new Issuer(fresh(HOUR), () -> ca.issue("future", now + HOUR, now + 2 * HOUR)); + Issuer issuer = new Issuer(ca::issue, () -> ca.issue("future", now + HOUR, now + 2 * HOUR)); SslContextProvider p = new SslContextProvider(issuer); SSLContext inUse = p.context(); X509Certificate[] chain = p.certificateChain(); @@ -222,16 +222,17 @@ public void testNotYetValidCertificateNotApplied() throws Exception { @Test public void testExpiredIssuerNotApplied() throws Exception { long now = System.currentTimeMillis(); - TestCertificateAuthority expiredCa = new TestCertificateAuthority("expired-ca", now - 2 * HOUR, now - HOUR); - KeyStore candidate = expiredCa.issue("valid-leaf", now - MINUTE, now + HOUR); + String expiredCaName = "expired-ca"; + TestCertificateAuthority expiredCa = new TestCertificateAuthority(expiredCaName, now - 2 * HOUR, now - HOUR); + KeyStore candidate = expiredCa.issue(); - ((X509Certificate)candidate.getCertificate("valid-leaf")).checkValidity(); + TestCertificateAuthority.certificateChain(candidate)[0].checkValidity(); - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), () -> candidate)); + SslContextProvider p = new SslContextProvider(new Issuer(ca::issue, () -> candidate)); SSLContext inUse = p.context(); X509Certificate[] chain = p.certificateChain(); - GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "subject=CN=expired-ca"); + GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "subject=CN=" + expiredCaName); assertSame(inUse, p.context()); assertArrayEquals(chain, p.certificateChain()); @@ -241,13 +242,14 @@ public void testExpiredIssuerNotApplied() throws Exception { /** An unsupported protocol in a replacement context leaves the working context in use. */ @Test public void testUnsupportedProtocolNotApplied() { - Issuer issuer = new Issuer(fresh(HOUR)); + String invalidProtocol = "SSLvDoesNotExist"; + Issuer issuer = new Issuer(ca::issue); SslContextProvider p = new SslContextProvider(issuer); SSLContext inUse = p.context(); - issuer.setProtocols("SSLvDoesNotExist"); + issuer.setProtocols(invalidProtocol); - GridTestUtils.assertThrows(log, p::reload, IllegalArgumentException.class, "SSLvDoesNotExist"); + GridTestUtils.assertThrows(log, p::reload, IllegalArgumentException.class, invalidProtocol); assertSame(inUse, p.context()); assertEquals(1, p.reloadOutcome().failures()); @@ -255,12 +257,13 @@ public void testUnsupportedProtocolNotApplied() { /** @return Store with two valid private keys and therefore no single chain for certificate metadata. */ private KeyStore twoKeys() throws Exception { - long now = System.currentTimeMillis(); - KeyStore keys = ca.issue("node", now - MINUTE, now + HOUR); - KeyStore other = ca.issue("other", now - MINUTE, now + HOUR); + String firstAlias = "first-key"; + String secondAlias = "second-key"; + KeyStore keys = ca.issue(firstAlias); + KeyStore other = ca.issue(secondAlias); - keys.setKeyEntry("other", other.getKey("other", TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - other.getCertificateChain("other")); + keys.setKeyEntry(secondAlias, other.getKey(secondAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + other.getCertificateChain(secondAlias)); return keys; } @@ -303,10 +306,8 @@ private Issuer issuer(Callable source, KeyStore trust) { * @param peerCa Authority of the peer whose certificate stays unchanged. */ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception { - long now = System.currentTimeMillis(); - - AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - HOUR, now + 9 * MINUTE)); - KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + AtomicReference keys = new AtomicReference<>(ca.issue()); + KeyStore peerKeys = peerCa.issue(); Issuer issuer = issuer(keys::get, peerCa.trustStore()); Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); @@ -318,8 +319,8 @@ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception assertFalse(provider(g0).certificate().equals(peerCert)); assertComputeWorks(g0, g1); - KeyStore replacement = ca.issue("node0-new", now, now + 2 * HOUR); - X509Certificate replacementCert = (X509Certificate)replacement.getCertificate("node0-new"); + KeyStore replacement = ca.issue(); + X509Certificate replacementCert = TestCertificateAuthority.certificateChain(replacement)[0]; keys.set(replacement); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index fa5963cc786af..df79666629e89 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; +import java.security.cert.X509Certificate; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -42,7 +43,6 @@ import org.apache.ignite.internal.management.ssl.AbstractSslTask; import org.apache.ignite.internal.management.ssl.SslReloadTask; import org.apache.ignite.internal.management.ssl.SslStatusTask; -import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.ssl.SslContextProvider; import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.util.typedef.G; @@ -74,6 +74,7 @@ import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; +import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; /** * Tests {@code --ssl reload} and {@code --ssl status} on running nodes. Each node has separate key and trust stores so that tests @@ -86,6 +87,12 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** Validity margin for generated certificates. */ private static final long HOUR = TimeUnit.HOURS.toMillis(1); + /** Alias and subject of the initial node key. */ + private static final String OLD_NODE = "old-node"; + + /** Alias and subject of the replacement node key. */ + private static final String NEW_NODE = "new-node"; + /** Directory the stores of the nodes are placed in. */ private Path dir; @@ -107,6 +114,12 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** Replacement node key and certificate. */ private KeyStore newKeys; + /** Certificate initially served by the node. */ + private X509Certificate oldCert; + + /** Certificate installed by a successful reload. */ + private X509Certificate newCert; + /** Trust roots for probes and the transitional rotation step. */ private KeyStore trustBoth; @@ -140,10 +153,16 @@ public class SslContextReloadTest extends GridCommonAbstractTest { dir = Files.createTempDirectory("ignite-ssl-reload-"); now = System.currentTimeMillis(); oldCa = new TestCertificateAuthority("old-ca"); + // The replacement issuer expires before its leaf, so status must show the chain's earlier expiry. - newCa = new TestCertificateAuthority("new-ca", now - HOUR, now + HOUR); - oldKeys = oldCa.issue("old-node", now - HOUR, now + HOUR); - newKeys = newCa.issue("new-node", now - HOUR, now + 2 * HOUR); + long issuerExpiry = now + HOUR; + long leafExpiry = now + 2 * HOUR; + + newCa = new TestCertificateAuthority("new-ca", now - HOUR, issuerExpiry); + oldKeys = oldCa.issue(OLD_NODE); + newKeys = newCa.issue(NEW_NODE, now - HOUR, leafExpiry); + oldCert = certificateChain(oldKeys)[0]; + newCert = certificateChain(newKeys)[0]; trustBoth = TestCertificateAuthority.trustStore(oldCa, newCa); initTrustStore = trustBoth; } @@ -161,11 +180,17 @@ public class SslContextReloadTest extends GridCommonAbstractTest { */ @Test public void testReloadOnRunningCluster() throws Exception { + X509Certificate[] replacementChain = certificateChain(newKeys); + long issuerExpiry = replacementChain[1].getNotAfter().getTime(); + + assertTrue("The issuer must expire before its leaf", issuerExpiry < newCert.getNotAfter().getTime()); + IgniteEx g0 = startGrid(0); IgniteEx g1 = startGrid(1); LogListener logged = LogListener.matches(Pattern.compile("TLS certificates reloaded \\[transports=" + ALL_TRANSPORTS + - ", subject=CN=new-node, .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); + ", subject=" + Pattern.quote(newCert.getSubjectX500Principal().getName()) + + ", .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); nodeLog.registerListener(logged); @@ -187,27 +212,26 @@ public void testReloadOnRunningCluster() throws Exception { assertTrue(logged.check()); } - assertEquals("CN=new-node", servedSubject(g0.context().clientListener().port())); - assertEquals("CN=new-node", servedSubject(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); - assertEquals("CN=new-node", servedSubject(discoveryPort(g0))); + assertEquals(newCert, certificateAt(g0.context().clientListener().port())); + assertEquals(newCert, certificateAt(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); + assertEquals(newCert, certificateAt(discoveryPort(g0))); SslContextProvider p = g0.context().sslContexts().providers().iterator().next(); - assertEquals("CN=new-node", p.certificate().getSubjectX500Principal().getName()); - assertContains(log, p.certificate().getIssuerX500Principal().getName(), "CN=new-ca"); + assertEquals(newCert, p.certificate()); assertTrue(p.reloadOutcome().successTime() > 0); String status = status(g0); - assertContains(log, status, "chainNotAfter=" + Instant.ofEpochMilli(SslCertificates.chainNotAfter(p.certificateChain()))); + assertContains(log, status, "chainNotAfter=" + Instant.ofEpochMilli(issuerExpiry)); assertContains(log, status, "last reload succeeded at " + Instant.ofEpochMilli(p.reloadOutcome().successTime())); stopGrid(0); IgniteEx restarted = startGrid(0); - assertEquals("CN=new-node", servedSubject(restarted.context().clientListener().port())); - assertEquals("CN=new-node", servedSubject(discoveryPort(restarted))); + assertEquals(newCert, certificateAt(restarted.context().clientListener().port())); + assertEquals(newCert, certificateAt(discoveryPort(restarted))); } /** One node fails: the others still take the new certificate and appear in the report; the command fails. */ @@ -231,14 +255,14 @@ public void testPartialFailureAcrossNodes() throws Exception { assertTrue("The failed node must be listed first, in a separate block: " + res, res.contains(failed) && res.indexOf(succeeded) > res.indexOf(failed)); - assertEquals("CN=new-node", servedSubject(g0.context().clientListener().port())); - assertEquals("CN=old-node", servedSubject(g1.context().clientListener().port())); + assertEquals(newCert, certificateAt(g0.context().clientListener().port())); + assertEquals(oldCert, certificateAt(g1.context().clientListener().port())); String status = status(g1); assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "Failed to initialize key store"); - assertContains(log, status, "loaded subject=CN=old-node,"); + assertContains(log, status, "loaded subject=" + oldCert.getSubjectX500Principal().getName() + ','); } /** @@ -260,13 +284,15 @@ public void testCachingFactoryNotReloaded() throws Exception { assertFailed(res, g, ALL_TRANSPORTS); assertContains(log, res, "returns the context in use"); - assertEquals("CN=old-node", servedSubject(g.context().clientListener().port())); + assertEquals(oldCert, certificateAt(g.context().clientListener().port())); } /** A shared custom factory loads once at startup and once per reload. Status does not load it. */ @Test public void testCustomFactoryReloaded() throws Exception { TestFactory factory = new TestFactory(); + int loadsAfterStart = 1; + int loadsAfterReload = loadsAfterStart + 1; IgniteEx g = startGrid(0, cfg -> { cfg.setSslContextFactory(factory); @@ -275,9 +301,9 @@ public void testCustomFactoryReloaded() throws Exception { String transports = BINARY_REST + ", " + ALL_TRANSPORTS; - assertEquals("Startup must load the shared factory once", 1, factory.calls); + assertEquals("Startup must load the shared factory once", loadsAfterStart, factory.calls); assertContains(log, status(g), node(g) + ": " + transports); - assertEquals("Status must not load the factory", 1, factory.calls); + assertEquals("Status must not load the factory", loadsAfterStart, factory.calls); SslContextRegistry registry = g.context().sslContexts(); SSLContext ctx = registry.register(factory, COMMUNICATION); @@ -286,19 +312,19 @@ public void testCustomFactoryReloaded() throws Exception { assertSame(ctx, registry.register(factory, CLIENT_CONNECTOR)); assertSame(ctx, registry.register(factory, BINARY_REST)); assertEquals(1, registry.providers().size()); - assertEquals(1, factory.calls); + assertEquals(loadsAfterStart, factory.calls); placeKeys(newKeys); assertReloaded(reload(g), g, transports); - assertEquals("Reload must load the shared factory once", 2, factory.calls); + assertEquals("Reload must load the shared factory once", loadsAfterReload, factory.calls); assertSame(ctx, registry.register(factory, COMMUNICATION)); - assertContains(log, status(g), "loaded subject=CN=new-node,"); - assertEquals("Status must not load the factory", 2, factory.calls); + assertContains(log, status(g), "loaded subject=" + newCert.getSubjectX500Principal().getName() + ','); + assertEquals("Status must not load the factory", loadsAfterReload, factory.calls); - assertEquals("CN=new-node", servedSubject(discoveryPort(g))); - assertEquals("CN=new-node", servedSubject(g.context().clientListener().port())); - assertEquals("CN=new-node", servedSubject(g.localNode().attribute(ATTR_REST_TCP_PORT))); + assertEquals(newCert, certificateAt(discoveryPort(g))); + assertEquals(newCert, certificateAt(g.context().clientListener().port())); + assertEquals(newCert, certificateAt(g.localNode().attribute(ATTR_REST_TCP_PORT))); } /** Equal but distinct factories reload independently, including when one of them fails. */ @@ -306,6 +332,9 @@ public void testCustomFactoryReloaded() throws Exception { public void testEqualFactoriesReloadIndependently() throws Exception { TestFactory nodeFactory = new TestFactory(); TestFactory clientFactory = new TestFactory(); + int loadsAfterStart = 1; + int loadsAfterReload = loadsAfterStart + 1; + int loadsAfterFailure = loadsAfterReload + 1; assertNotSame(nodeFactory, clientFactory); assertEquals(nodeFactory, clientFactory); @@ -320,8 +349,8 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertEquals(2, registry.providers().size()); assertNotSame(registry.register(nodeFactory, COMMUNICATION), registry.register(clientFactory, CLIENT_CONNECTOR)); - assertEquals(1, nodeFactory.calls); - assertEquals(1, clientFactory.calls); + assertEquals(loadsAfterStart, nodeFactory.calls); + assertEquals(loadsAfterStart, clientFactory.calls); placeKeys(newKeys); @@ -329,44 +358,50 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); assertReloaded(res, g, CLIENT_CONNECTOR); - assertEquals(2, nodeFactory.calls); - assertEquals(2, clientFactory.calls); + assertEquals(loadsAfterReload, nodeFactory.calls); + assertEquals(loadsAfterReload, clientFactory.calls); nodeFactory.fail = true; - placeKeys(newCa.issue("new-peer", now - HOUR, now + HOUR)); + + KeyStore nextKeys = newCa.issue(); + + placeKeys(nextKeys); res = reloadFailure(g); assertFailed(res, g, COMMUNICATION + ", " + DISCOVERY); assertReloaded(res, g, CLIENT_CONNECTOR); - assertEquals(3, nodeFactory.calls); - assertEquals(3, clientFactory.calls); - assertEquals("CN=new-node", servedSubject(discoveryPort(g))); - assertEquals("CN=new-peer", servedSubject(g.context().clientListener().port())); + assertEquals(loadsAfterFailure, nodeFactory.calls); + assertEquals(loadsAfterFailure, clientFactory.calls); + assertEquals(newCert, certificateAt(discoveryPort(g))); + assertEquals(certificateChain(nextKeys)[0], certificateAt(g.context().clientListener().port())); } /** An expired certificate stays out of use, and the report, log and status identify the rejected chain. */ @Test public void testExpiredCertificateNotApplied() throws Exception { + KeyStore expiredStore = expiredKeys(); + String expiredSubject = certificateChain(expiredStore)[0].getSubjectX500Principal().getName(); + LogListener expired = LogListener.matches(Pattern.compile("Failed to reload TLS certificates, the ones in use stay .*" + - "reason=The new certificate chain is not valid now \\[subject=CN=expired-node,")).build(); + "reason=The new certificate chain is not valid now \\[subject=" + Pattern.quote(expiredSubject) + ',')).build(); nodeLog.registerListener(expired); IgniteEx g = startGrid(0); - placeKeys(expiredKeys()); + placeKeys(expiredStore); String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "The new certificate chain is not valid now [subject=CN=expired-node,"); + assertContains(log, res, "The new certificate chain is not valid now [subject=" + expiredSubject + ','); assertTrue(expired.check()); - assertEquals("CN=old-node", servedSubject(discoveryPort(g))); + assertEquals(oldCert, certificateAt(discoveryPort(g))); String status = status(g); - assertContains(log, status, "loaded subject=CN=old-node, issuer="); + assertContains(log, status, "loaded subject=" + oldCert.getSubjectX500Principal().getName() + ", issuer="); assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "The new certificate chain is not valid now"); } @@ -382,8 +417,8 @@ public void testKeyStoreWithoutKeyNotApplied() throws Exception { assertFailed(res, g, ALL_TRANSPORTS); assertContains(log, res, "The new key material has no private key, so the node would present no certificate"); - assertEquals("CN=old-node", servedSubject(g.context().clientListener().port())); - assertEquals("CN=old-node", servedSubject(discoveryPort(g))); + assertEquals(oldCert, certificateAt(g.context().clientListener().port())); + assertEquals(oldCert, certificateAt(discoveryPort(g))); assertClientConnected(g, oldKeys); } @@ -391,9 +426,9 @@ public void testKeyStoreWithoutKeyNotApplied() throws Exception { @Test public void testClientTransportsWithSeparateFactories() throws Exception { initTrustStore = oldCa.trustStore(); - KeyStore clientKeys = oldCa.issue("client-connector", now - HOUR, now + HOUR); - KeyStore restKeys = oldCa.issue("binary-rest", now - HOUR, now + HOUR); - KeyStore nextRestKeys = newCa.issue("binary-rest-next", now - HOUR, now + HOUR); + KeyStore clientKeys = oldCa.issue(); + KeyStore restKeys = oldCa.issue(); + KeyStore nextRestKeys = newCa.issue(); Path clientPath = dir.resolve("client-key.jks"); Path restPath = dir.resolve("binary-rest-key.jks"); @@ -413,10 +448,10 @@ public void testClientTransportsWithSeparateFactories() throws Exception { int restPort = g.localNode().attribute(ATTR_REST_TCP_PORT); int commPort = ((TcpCommunicationSpi)g.configuration().getCommunicationSpi()).boundPort(); - assertEquals("CN=old-node", servedSubject(discoveryPort(g))); - assertEquals("CN=old-node", servedSubject(commPort)); - assertEquals("CN=client-connector", servedSubject(clientPort)); - assertEquals("CN=binary-rest", servedSubject(restPort)); + assertEquals(oldCert, certificateAt(discoveryPort(g))); + assertEquals(oldCert, certificateAt(commPort)); + assertEquals(certificateChain(clientKeys)[0], certificateAt(clientPort)); + assertEquals(certificateChain(restKeys)[0], certificateAt(restPort)); TestCertificateAuthority.save(nextRestKeys, restPath); @@ -426,10 +461,10 @@ public void testClientTransportsWithSeparateFactories() throws Exception { assertReloaded(res, g, BINARY_REST); assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); - assertEquals("CN=old-node", servedSubject(discoveryPort(g))); - assertEquals("CN=old-node", servedSubject(commPort)); - assertEquals("CN=client-connector", servedSubject(clientPort)); - assertEquals("CN=binary-rest-next", servedSubject(restPort)); + assertEquals(oldCert, certificateAt(discoveryPort(g))); + assertEquals(oldCert, certificateAt(commPort)); + assertEquals(certificateChain(clientKeys)[0], certificateAt(clientPort)); + assertEquals(certificateChain(nextRestKeys)[0], certificateAt(restPort)); } /** Manual reload accepts several keys, reports unavailable metadata and keeps existing and new TLS connections working. */ @@ -442,10 +477,10 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti KeyStore multipleKeys = KeyStore.getInstance("JKS"); multipleKeys.load(null, TestCertificateAuthority.PWD); - multipleKeys.setKeyEntry("old-node", oldKeys.getKey("old-node", TestCertificateAuthority.PWD), - TestCertificateAuthority.PWD, oldKeys.getCertificateChain("old-node")); - multipleKeys.setKeyEntry("new-node", newKeys.getKey("new-node", TestCertificateAuthority.PWD), - TestCertificateAuthority.PWD, newKeys.getCertificateChain("new-node")); + multipleKeys.setKeyEntry(OLD_NODE, oldKeys.getKey(OLD_NODE, TestCertificateAuthority.PWD), + TestCertificateAuthority.PWD, oldKeys.getCertificateChain(OLD_NODE)); + multipleKeys.setKeyEntry(NEW_NODE, newKeys.getKey(NEW_NODE, TestCertificateAuthority.PWD), + TestCertificateAuthority.PWD, newKeys.getCertificateChain(NEW_NODE)); try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() .setAddresses("127.0.0.1:" + g.context().clientListener().port()) @@ -461,9 +496,10 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti cli.cacheNames(); } - String subj = servedSubject(g.context().clientListener().port()); + X509Certificate presented = certificateAt(g.context().clientListener().port()); - assertTrue(subj, List.of("CN=old-node", "CN=new-node").contains(subj)); + assertTrue("The connection must use one of the stored keys", + List.of(oldCert, newCert).contains(presented)); String status = status(g); @@ -505,8 +541,9 @@ public void testCertificateAuthorityReplaced() throws Exception { reload(g0, g1); String reloadedStatus = status(g0, g1); + String issuer = newCert.getIssuerX500Principal().getName(); - assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*CN=new-ca").matcher(reloadedStatus).find()); + assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*" + Pattern.quote(issuer)).matcher(reloadedStatus).find()); for (IgniteEx node : List.of(g0, g1)) { assertClientConnected(node, newKeys); @@ -519,7 +556,7 @@ public void testCertificateAuthorityReplaced() throws Exception { assertEquals((Integer)1, g0.compute(g0.cluster().forNodeId(g1.localNode().id())).call(() -> 1)); - TestCertificateAuthority.save(newCa.issue("new-peer", now - HOUR, now + HOUR), keyStore(getTestIgniteInstanceName(2))); + TestCertificateAuthority.save(newCa.issue(), keyStore(getTestIgniteInstanceName(2))); TestCertificateAuthority.save(newCa.trustStore(), trustStore(getTestIgniteInstanceName(2))); IgniteEx g2 = startGrid(2); @@ -536,16 +573,20 @@ public void testCertificateAuthorityReplaced() throws Exception { public void testStatus() throws Exception { ownClientConnectorFactory = true; - TestCertificateAuthority.save(expiredKeys(), keyStore(getTestIgniteInstanceName(0))); + KeyStore expiredStore = expiredKeys(); + X509Certificate expired = certificateChain(expiredStore)[0]; + String expiredSubject = expired.getSubjectX500Principal().getName(); + + TestCertificateAuthority.save(expiredStore, keyStore(getTestIgniteInstanceName(0))); IgniteEx g = startGrid(0); String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); String id = node(g); - assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=CN=expired-node, issuer="); - assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=CN=expired-node, issuer="); - assertContains(log, res, "CN=new-ca"); + assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=" + expiredSubject + ", issuer="); + assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=" + expiredSubject + ", issuer="); + assertContains(log, res, expired.getIssuerX500Principal().getName()); assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); } @@ -567,17 +608,19 @@ public void testExpiredCertificateWarnedAtStart() throws Exception { @Test public void testFailedJobNamesNode() { GridTestNode n = new GridTestNode(UUID.randomUUID()); + String consistentId = "departed-node"; + String reason = "node left"; - n.setConsistentId("cid"); + n.setConsistentId(consistentId); ComputeJobResult res = Mockito.mock(ComputeJobResult.class); Mockito.when(res.getNode()).thenReturn(n); - Mockito.when(res.getException()).thenReturn(new IgniteException("node left")); + Mockito.when(res.getException()).thenReturn(new IgniteException(reason)); Throwable e = GridTestUtils.assertThrows(log, () -> new SslReloadTask().reduce(List.of(res)).result(), IgniteException.class, null); - assertContains(log, e.getMessage(), "Failed on 1 node(s):\n" + n.id() + " [consistentId=cid]: node left"); + assertContains(log, e.getMessage(), "Failed on 1 node(s):\n" + n.id() + " [consistentId=" + consistentId + "]: " + reason); } /** A target missing at mapping fails both commands without preventing work on the remaining node. */ @@ -588,6 +631,7 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { placeKeys(newKeys); + String expectedSubject = newCert.getSubjectX500Principal().getName(); VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); for (Class task : List.of(SslReloadTask.class, SslStatusTask.class)) { @@ -596,10 +640,10 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + ": node was unavailable to run the command"); assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); - assertContains(log, err.getMessage(), "loaded subject=CN=new-node,"); + assertContains(log, err.getMessage(), "loaded subject=" + expectedSubject + ','); } - assertEquals("CN=new-node", servedSubject(g.context().clientListener().port())); + assertEquals(newCert, certificateAt(g.context().clientListener().port())); } /** Both commands report that no SSL context factory is configured. */ @@ -676,9 +720,9 @@ private void placeTrust(KeyStore store) throws Exception { TestCertificateAuthority.save(store, trustStore(node.name())); } - /** @return Certificate subject served on a fresh connection, using a probe that trusts both authorities. */ - private String servedSubject(int port) throws Exception { - return servedCertificate(TestCertificateAuthority.context(oldKeys, trustBoth), port).getSubjectX500Principal().getName(); + /** @return Certificate served on a fresh connection, using a probe that trusts both authorities. */ + private X509Certificate certificateAt(int port) throws Exception { + return servedCertificate(TestCertificateAuthority.context(oldKeys, trustBoth), port); } /** @return Store with an expired leaf issued by the replacement authority. */ From c0b239fbb71161d4f40508bd21c240f37a5d59f6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:32:05 +0300 Subject: [PATCH 029/242] IGNITE-29210 Follow the project subject abbreviation --- .../apache/ignite/ssl/SslContextReloadTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index df79666629e89..09e9477c6ecd5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -381,10 +381,10 @@ public void testEqualFactoriesReloadIndependently() throws Exception { @Test public void testExpiredCertificateNotApplied() throws Exception { KeyStore expiredStore = expiredKeys(); - String expiredSubject = certificateChain(expiredStore)[0].getSubjectX500Principal().getName(); + String expiredSubj = certificateChain(expiredStore)[0].getSubjectX500Principal().getName(); LogListener expired = LogListener.matches(Pattern.compile("Failed to reload TLS certificates, the ones in use stay .*" + - "reason=The new certificate chain is not valid now \\[subject=" + Pattern.quote(expiredSubject) + ',')).build(); + "reason=The new certificate chain is not valid now \\[subject=" + Pattern.quote(expiredSubj) + ',')).build(); nodeLog.registerListener(expired); @@ -395,7 +395,7 @@ public void testExpiredCertificateNotApplied() throws Exception { String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "The new certificate chain is not valid now [subject=" + expiredSubject + ','); + assertContains(log, res, "The new certificate chain is not valid now [subject=" + expiredSubj + ','); assertTrue(expired.check()); assertEquals(oldCert, certificateAt(discoveryPort(g))); @@ -575,7 +575,7 @@ public void testStatus() throws Exception { KeyStore expiredStore = expiredKeys(); X509Certificate expired = certificateChain(expiredStore)[0]; - String expiredSubject = expired.getSubjectX500Principal().getName(); + String expiredSubj = expired.getSubjectX500Principal().getName(); TestCertificateAuthority.save(expiredStore, keyStore(getTestIgniteInstanceName(0))); @@ -584,8 +584,8 @@ public void testStatus() throws Exception { String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); String id = node(g); - assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=" + expiredSubject + ", issuer="); - assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=" + expiredSubject + ", issuer="); + assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=" + expiredSubj + ", issuer="); + assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=" + expiredSubj + ", issuer="); assertContains(log, res, expired.getIssuerX500Principal().getName()); assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); } @@ -631,7 +631,7 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { placeKeys(newKeys); - String expectedSubject = newCert.getSubjectX500Principal().getName(); + String expectedSubj = newCert.getSubjectX500Principal().getName(); VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); for (Class task : List.of(SslReloadTask.class, SslStatusTask.class)) { @@ -640,7 +640,7 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + ": node was unavailable to run the command"); assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); - assertContains(log, err.getMessage(), "loaded subject=" + expectedSubject + ','); + assertContains(log, err.getMessage(), "loaded subject=" + expectedSubj + ','); } assertEquals(newCert, certificateAt(g.context().clientListener().port())); From fbf5c510da4facf05dfa75a63221ee63e2e0d0f5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:33:13 +0300 Subject: [PATCH 030/242] IGNITE-29209 Let expiry tests specify dates without an unrelated subject --- .../testframework/TestCertificateAuthority.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 4bc0d2754ae3b..85aed26b504d3 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -57,6 +57,9 @@ public class TestCertificateAuthority { /** Password of every store made here. */ public static final char[] PWD = "123456".toCharArray(); + /** Name used when certificate identity is not part of the test. */ + private static final String DEFAULT_NAME = "test-certificate"; + /** Certificate serial numbers must be positive and unique within this authority. */ private final AtomicLong serial = new AtomicLong(); @@ -92,7 +95,7 @@ public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ public KeyStore issue() throws Exception { - return issue("test-certificate"); + return issue(DEFAULT_NAME); } /** @@ -107,6 +110,15 @@ public KeyStore issue(String cn) throws Exception { return issue(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); } + /** + * @param notBefore Time the certificate becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Store with one private key and a certificate with the requested validity dates. + */ + public KeyStore issue(long notBefore, long notAfter) throws Exception { + return issue(DEFAULT_NAME, notBefore, notAfter); + } + /** * @param cn Common name of the certificate, also used as the key alias. * @param notBefore Time it becomes valid; rounded down to a second. From 0e324167b4e3c6013d1ef344f1f89a0d1e87f626 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:33:37 +0300 Subject: [PATCH 031/242] IGNITE-29211 Derive renewal fixtures from the timing guarantees --- .../ignite/internal/ssl/SslRenewalTest.java | 173 ++++++++++-------- 1 file changed, 100 insertions(+), 73 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 87ee109a8ae80..07260aa99bc93 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -81,7 +81,10 @@ /** Tests that a node renews the certificates of a factory with renewal enabled before they expire. */ public class SslRenewalTest extends GridCommonAbstractTest { /** */ - private static final long MINUTE = 60_000L; + private static final long SECOND = 1_000L; + + /** */ + private static final long MINUTE = 60 * SECOND; /** */ private static final long HOUR = 60 * MINUTE; @@ -134,19 +137,21 @@ public class SslRenewalTest extends GridCommonAbstractTest { @Test public void testRenewsBeforeExpiry() throws Exception { long now = System.currentTimeMillis(); + long renewalDelay = 5 * SECOND; + long lifetime = 5 * MINUTE; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); AtomicLong due = new AtomicLong(); Issuer issuer = new Issuer(() -> { long issued = System.currentTimeMillis(); - long notAfter = (issued + 40_000) / 1000 * 1000; - long notBefore = notAfter - 233_000; - due.set(notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION)); + // Match certificate timestamp precision while keeping the renewal window ahead of this first load. + long notAfter = (issued + renewalDelay + window) / SECOND * SECOND; + long notBefore = notAfter - lifetime; - assertTrue("The window must open after the first load and well before expiry", - due.get() - issued > 3_000 && notAfter - due.get() > 30_000); + due.set(notAfter - window); - return ca.issue("node", notBefore, notAfter); + return ca.issue(notBefore, notAfter); }, fresh(HOUR)); IgniteEx g = startNode(issuer); @@ -173,18 +178,15 @@ public void testRenewsBeforeExpiry() throws Exception { /** Plans renewal from the authority's expiry when it expires before the leaf certificate. */ @Test public void testRenewalPlannedBeforeAuthorityExpiry() throws Exception { - long now = System.currentTimeMillis(); - - KeyStore keys = ca.issue("node", now - HOUR, now + TimeUnit.DAYS.toMillis(400)); + long now = System.currentTimeMillis() / SECOND * SECOND; + long notBefore = now - HOUR; + long authorityExpiry = now + HOUR; - X509Certificate leaf = (X509Certificate)keys.getCertificate("node"); - X509Certificate authority = (X509Certificate)keys.getCertificateChain("node")[1]; + ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); - long notBefore = leaf.getNotBefore().getTime(); - long notAfter = authority.getNotAfter().getTime(); - long due = notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + KeyStore keys = ca.issue(notBefore, authorityExpiry + HOUR); - assertTrue("The authority must expire before the leaf", notAfter < leaf.getNotAfter().getTime()); + long due = authorityExpiry - (long)((authorityExpiry - notBefore) * DFLT_RENEW_BEFORE_FRACTION); IgniteEx g = startNode(new Issuer(() -> keys)); @@ -228,10 +230,11 @@ public void testRenewalWithDifferentAuthorities() throws Exception { @Test public void testExpiredCertificateRenewed() throws Exception { long now = System.currentTimeMillis(); + String authorityName = "otherca"; - TestCertificateAuthority other = new TestCertificateAuthority("otherca"); + TestCertificateAuthority other = new TestCertificateAuthority(authorityName); - Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.issue("node", now, now + HOUR)); + Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.issue(now, now + HOUR)); SslContextProvider p = new SslContextProvider(issuer); @@ -240,16 +243,18 @@ public void testExpiredCertificateRenewed() throws Exception { issuer.trust = other.trustStore(); assertNotNull(p.renew(p.context())); - assertEquals("CN=otherca", p.certificate().getIssuerX500Principal().getName()); + assertEquals("CN=" + authorityName, p.certificate().getIssuerX500Principal().getName()); } /** A renewal logs the certificate it installed even if a manual reload replaces it before the log is written. */ @Test public void testRenewalLogsAppliedCertificate() throws Exception { long now = System.currentTimeMillis(); + String renewedName = "renewed"; + String manualName = "manual"; Issuer issuer = new Issuer(valid(now - HOUR, now + MINUTE), - () -> ca.issue("renewed", now, now + HOUR), () -> ca.issue("manual", now, now + 2 * HOUR)); + () -> ca.issue(renewedName, now, now + HOUR), () -> ca.issue(manualName, now, now + 2 * HOUR)); SslContextProvider p = new SslContextProvider(issuer) { @Override X509Certificate renew(SSLContext expected) throws IgniteCheckedException { @@ -270,8 +275,9 @@ public void testRenewalLogsAppliedCertificate() throws Exception { p.startRenewal(exec, log.getLogger(SslRenewal.class)); assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 10_000)); - assertEquals("CN=manual", p.certificate().getSubjectX500Principal().getName()); - assertEquals(1, logged(Level.INFO, "TLS certificates reloaded [transports=communication, subject=CN=renewed,")); + assertEquals("CN=" + manualName, p.certificate().getSubjectX500Principal().getName()); + assertEquals(1, logged(Level.INFO, + "TLS certificates reloaded [transports=" + COMMUNICATION + ", subject=CN=" + renewedName + ',')); } finally { exec.shutdownNow(); @@ -296,9 +302,7 @@ public void testRenewalNotValidYetRejected() { */ @Test public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { - long now = System.currentTimeMillis(); - - KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + KeyStore inUse = ca.issue(); Issuer issuer = new Issuer(() -> inUse); @@ -447,18 +451,19 @@ public void testReloadByCommandReplans() throws Exception { @Test public void testReloadContextNotCarriedIntoRenewals() throws Exception { long now = System.currentTimeMillis(); + String operator = "operator"; Issuer issuer = new Issuer(fresh(HOUR), valid(now - HOUR, now + 9 * MINUTE), fresh(HOUR)); IgniteEx g = startNode(issuer); - try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { + try (Scope ignored = OperationContext.set(OPERATOR, operator)) { provider(g).reload(); } assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); - assertEquals("operator", issuer.calls.get(1).operator); + assertEquals(operator, issuer.calls.get(1).operator); assertNull("The renewal must not run as the operator", issuer.calls.get(2).operator); } @@ -469,17 +474,19 @@ public void testReloadContextNotCarriedIntoRenewals() throws Exception { @Test public void testFailedRenewalRetried() throws Exception { long now = System.currentTimeMillis(); + long minPause = 100; + long maxPause = 4 * minPause; Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), failure(), failure(), failure(), failure(), failure(), failure(), fresh(HOUR)); - pauses(100, 400); + pauses(minPause, maxPause); IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); - long[] pauses = {100, 200, 400, 400, 400}; + long[] pauses = {minPause, 2 * minPause, maxPause, maxPause, maxPause}; for (int i = 0; i < pauses.length; i++) { long pause = issuer.calls.get(i + 2).time - issuer.calls.get(i + 1).time; @@ -489,7 +496,7 @@ public void testFailedRenewalRetried() throws Exception { LogEvent fifth = events(Level.WARN, ISSUER_DOWN).get(4); - assertTrue("The longest pause must hold, a doubling one would be 1600 ms by now", plannedPause(fifth) <= 600); + assertTrue("The retry must stay within the maximum pause plus jitter", plannedPause(fifth) <= maxPause + maxPause / 2); assertEquals(8, issuer.calls.size()); assertEquals(6, logged(Level.WARN, "initiator=automatic renewal, reason=" + ISSUER_DOWN)); @@ -502,11 +509,16 @@ public void testFailedRenewalRetried() throws Exception { @Test public void testFailureBeforeExpiringRetryLoggedAsError() throws Exception { long now = System.currentTimeMillis(); + long notBefore = now - HOUR; + long timeLeft = 2 * MINUTE; + long notAfter = now + timeLeft; + long window = timeLeft + timeLeft / 2; + long retryPause = 2 * timeLeft; - Issuer issuer = new Issuer(valid(now - HOUR, now + 2 * MINUTE), failure()); + Issuer issuer = new Issuer(valid(notBefore, notAfter), failure()); - issuer.setRenewBeforeFraction(0.05); - pauses(5 * MINUTE, 5 * MINUTE); + issuer.setRenewBeforeFraction((double)window / (notAfter - notBefore)); + pauses(retryPause, retryPause); startNode(issuer); @@ -516,18 +528,19 @@ public void testFailureBeforeExpiringRetryLoggedAsError() throws Exception { /** A certificate due for renewal as soon as it is issued must not make the node renew more often than allowed. */ @Test public void testRenewalsNoMoreOftenThanMinPause() throws Exception { + long minPause = 300; AtomicInteger issued = new AtomicInteger(); Callable dueOnIssue = () -> { long t = System.currentTimeMillis(); // A second later each time: notAfter is rounded down to a second, and a renewal that does not move it is refused. - return ca.issue("node", t - HOUR, t + 5 * MINUTE + issued.incrementAndGet() * 1_000L); + return ca.issue(t - HOUR, t + 5 * MINUTE + issued.incrementAndGet() * SECOND); }; Issuer issuer = new Issuer(dueOnIssue); - pauses(300, 300); + pauses(minPause, minPause); startNode(issuer); @@ -538,7 +551,7 @@ public void testRenewalsNoMoreOftenThanMinPause() throws Exception { for (int i = 2; i < 5; i++) { long pause = issuer.calls.get(i).time - issuer.calls.get(i - 1).time; - assertTrue("Renewals must be 300 ms apart at least: " + pause, pause >= 300 - CLOCK_SLACK); + assertTrue("Renewals must be at least " + minPause + " ms apart: " + pause, pause >= minPause - CLOCK_SLACK); } } @@ -601,7 +614,7 @@ public void testRenewalFailuresMetric() throws Exception { long now = System.currentTimeMillis(); - KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + KeyStore inUse = ca.issue(now - HOUR, now + HOUR); Issuer nodeIssuer = new Issuer(() -> inUse, () -> inUse, fresh(2 * HOUR)); Issuer clientIssuer = new Issuer(() -> inUse, () -> inUse, () -> inUse, fresh(2 * HOUR)); @@ -649,8 +662,12 @@ public void testRenewalFailuresMetric() throws Exception { @Test public void testWaitsForNewerCertificate() throws Exception { long now = System.currentTimeMillis(); + long minPause = 100; + long fourthPause = minPause * 2 * 2 * 2; - KeyStore inUse = ca.issue("node", now - HOUR, now + 9 * MINUTE); + pauses(minPause, 10 * minPause); + + KeyStore inUse = ca.issue(now - HOUR, now + 9 * MINUTE); Issuer issuer = new Issuer(() -> inUse); @@ -660,7 +677,8 @@ public void testWaitsForNewerCertificate() throws Exception { long grown = issuer.calls.get(5).time - issuer.calls.get(4).time; - assertTrue("The pause must double while waiting, up to 800 ms by the fifth one: " + grown, grown >= 800 - CLOCK_SLACK); + assertTrue("The fifth attempt must follow three doublings of the initial pause: " + grown, + grown >= fourthPause - CLOCK_SLACK); assertEquals(1, logged(Level.WARN, WAITING)); assertEquals(0, logged(Level.ERROR, WAITING)); @@ -683,7 +701,7 @@ public void testWaitsForNewerCertificate() throws Exception { LogEvent afterReload = events(Level.WARN, WAITING).get(1); assertContains(log, afterReload.getMessage().getFormattedMessage(), WAITING + " [" + NODE_TRANSPORTS); - assertTrue("The pause must be the shortest one again, not 800 ms or more", plannedPause(afterReload) < 400); + assertTrue("The pause must reset to the initial pause plus jitter", plannedPause(afterReload) <= minPause + minPause / 2); } /** @@ -692,20 +710,23 @@ public void testWaitsForNewerCertificate() throws Exception { */ @Test public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { + long lifetime = 5 * MINUTE; + long timeLeft = 20 * SECOND; + long window = timeLeft + 2 * SECOND; AtomicReference inUse = new AtomicReference<>(); // Issued on the first request, so that the time left does not depend on how long the node takes to start. Issuer issuer = new Issuer(() -> { if (inUse.get() == null) { - long t = System.currentTimeMillis(); + long notAfter = System.currentTimeMillis() + timeLeft; - inUse.set(ca.issue("node", t - 80_000, t + 20_000)); + inUse.set(ca.issue(notAfter - lifetime, notAfter)); } return inUse.get(); }); - issuer.setRenewBeforeFraction(0.22); + issuer.setRenewBeforeFraction((double)window / lifetime); startNode(issuer); @@ -815,7 +836,7 @@ public void testInterruptedRenewalReturnPreservesState() throws Exception { public void testSameCertificatesUnchanged() throws Exception { long now = System.currentTimeMillis(); - SSLContext cached = TestCertificateAuthority.context(ca.issue("node", now - HOUR, now + HOUR), ca.trustStore()); + SSLContext cached = TestCertificateAuthority.context(ca.issue(), ca.trustStore()); SslContextProvider p = new SslContextProvider(() -> cached); @@ -826,7 +847,7 @@ public void testSameCertificatesUnchanged() throws Exception { assertEquals(2, p.renewalFailures()); assertEquals(0, p.reloadOutcome().failures()); - KeyStore expired = ca.issue("node", now - 2 * HOUR, now - HOUR); + KeyStore expired = ca.issue(now - 2 * HOUR, now - HOUR); SslContextProvider expiredProvider = new SslContextProvider(new Issuer(() -> expired)); @@ -842,7 +863,7 @@ public void testSameCertificatesUnchanged() throws Exception { public void testUnsuccessfulRenewalsCounted() throws Exception { long now = System.currentTimeMillis(); - KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + KeyStore inUse = ca.issue(now - HOUR, now + HOUR); SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, fresh(2 * HOUR))); @@ -864,11 +885,14 @@ public void testUnsuccessfulRenewalsCounted() throws Exception { @Test public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exception { long now = System.currentTimeMillis(); + long notBefore = now - HOUR; + long notAfter = now + HOUR; + String alias = "node"; - KeyStore inUse = ca.issue("node", now - HOUR, now + HOUR); + KeyStore inUse = ca.issue(alias, notBefore, notAfter); Issuer issuer = new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, () -> inUse, - valid(now - HOUR, now + HOUR), fresh(2 * HOUR)); + valid(notBefore, notAfter), fresh(2 * HOUR)); SslContextProvider p = new SslContextProvider(issuer); @@ -898,7 +922,7 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio // A different certificate with the same expiry still does not extend the chain's lifetime. p.reload(); - assertFalse(inUse.getCertificate("node").equals(p.certificate())); + assertFalse(inUse.getCertificate(alias).equals(p.certificate())); assertEquals(1, p.renewalFailures()); // Only the final certificate extends the chain's lifetime. @@ -910,22 +934,26 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio /** Extending only the leaf does not clear failures when the authority still expires first. */ @Test public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { - long now = System.currentTimeMillis(); + long now = System.currentTimeMillis() / SECOND * SECOND; + long notBefore = now - HOUR; + long authorityExpiry = now + HOUR; + long leafExpiry = authorityExpiry + HOUR; + long laterLeafExpiry = leafExpiry + HOUR; + + ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); - KeyStore inUse = ca.issue("node", now - HOUR, now + TimeUnit.DAYS.toMillis(400)); + KeyStore inUse = ca.issue(notBefore, leafExpiry); - SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, - valid(now - HOUR, now + TimeUnit.DAYS.toMillis(450)))); + SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, valid(notBefore, laterLeafExpiry))); - long chainExpiry = SslCertificates.chainNotAfter(p.certificateChain()); - long leafExpiry = p.certificate().getNotAfter().getTime(); + assertEquals(authorityExpiry, SslCertificates.chainNotAfter(p.certificateChain())); assertNull(p.renew(p.context())); p.reload(); - assertTrue(p.certificate().getNotAfter().getTime() > leafExpiry); - assertEquals(chainExpiry, SslCertificates.chainNotAfter(p.certificateChain())); + assertEquals(laterLeafExpiry, p.certificate().getNotAfter().getTime()); + assertEquals(authorityExpiry, SslCertificates.chainNotAfter(p.certificateChain())); assertEquals(1, p.renewalFailures()); } @@ -934,16 +962,16 @@ public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { public void testReplacedKeyStoreFileRenewed() throws Exception { long now = System.currentTimeMillis(); - Path keyStore = Files.createTempFile("ignite-ssl-renewal-", ".p12"); + Path keyStore = Files.createTempFile("ignite-ssl-renewal-", ".jks"); try { - TestCertificateAuthority.save(ca.issue("node", now - HOUR, now + 9 * MINUTE), keyStore); + TestCertificateAuthority.save(ca.issue(now - HOUR, now + 9 * MINUTE), keyStore); SslContextFactory factory = new SslContextFactory(); factory.setKeyStoreFilePath(keyStore.toString()); factory.setKeyStorePassword(TestCertificateAuthority.PWD); - factory.setKeyStoreType("PKCS12"); + factory.setKeyStoreType("JKS"); factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); factory.setRenewalEnabled(true); @@ -951,7 +979,7 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 1, 30_000)); - TestCertificateAuthority.save(ca.issue("node", now, now + HOUR), keyStore); + TestCertificateAuthority.save(ca.issue(now, now + HOUR), keyStore); assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); assertTrue(chainNotAfter(g) > now + 30 * MINUTE); @@ -1024,12 +1052,12 @@ public void testReloadCannotLoseMetadataWithRenewal() { /** @return Store with two valid private keys and therefore no single chain for renewal metadata. */ private KeyStore twoKeys() throws Exception { - long now = System.currentTimeMillis(); - KeyStore keys = ca.issue("node", now - MINUTE, now + HOUR); - KeyStore other = ca.issue("other", now - MINUTE, now + HOUR); + String otherAlias = "other"; + KeyStore keys = ca.issue(); + KeyStore other = ca.issue(otherAlias); - keys.setKeyEntry("other", other.getKey("other", TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - other.getCertificateChain("other")); + keys.setKeyEntry(otherAlias, other.getKey(otherAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + other.getCertificateChain(otherAlias)); return keys; } @@ -1106,8 +1134,9 @@ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception assertFalse(provider(g0).certificate().equals(peerCert)); assertComputeWorks(g0, g1); - KeyStore replacement = ca.issue("node0-new", now, now + 2 * HOUR); - X509Certificate renewed = (X509Certificate)replacement.getCertificate("node0-new"); + String renewedAlias = "node0-new"; + KeyStore replacement = ca.issue(renewedAlias, now, now + 2 * HOUR); + X509Certificate renewed = (X509Certificate)replacement.getCertificate(renewedAlias); keys.set(replacement); @@ -1154,7 +1183,7 @@ private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { * @return Step that issues a certificate valid for that time. */ private Callable valid(long notBefore, long notAfter) { - return () -> ca.issue("node", notBefore, notAfter); + return () -> ca.issue(notBefore, notAfter); } /** @@ -1165,7 +1194,7 @@ private Callable fresh(long lifetime) { return () -> { long now = System.currentTimeMillis(); - return ca.issue("node", now, now + lifetime); + return ca.issue(now, now + lifetime); }; } @@ -1178,9 +1207,7 @@ private static Callable failure() { /** @return Context to connect to the nodes with; a new one each time, so that no session is resumed with an old certificate. */ private SSLContext probe() throws Exception { - long now = System.currentTimeMillis(); - - return TestCertificateAuthority.context(ca.issue("probe", now - MINUTE, now + HOUR), ca.trustStore()); + return TestCertificateAuthority.context(ca.issue(), ca.trustStore()); } /** From cbef7aa5fe4aadf586a8808674cf2a64a9512ad4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:52:24 +0300 Subject: [PATCH 032/242] IGNITE-29209 Expose key-store contents and hook calls in tests --- .../ignite/ssl/SslContextMetadataTest.java | 82 ++++++++++--------- .../TestCertificateAuthority.java | 19 +++-- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 56d28044cdf38..408df05840862 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -36,6 +36,7 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotEquals; /** Tests that certificate metadata belongs to the key material used to create a context. */ public class SslContextMetadataTest extends GridCommonAbstractTest { @@ -53,36 +54,38 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { /** A fresh context loads the replacement store once and leaves the cached context's certificate metadata unchanged. */ @Test public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() throws Exception { - KeyStore originalKeys = ca.issue(); + KeyStore originalKeys = ca.keyStoreWithPrivateKey(); X509Certificate[] originalChain = certificateChain(originalKeys); - StoreFactory factory = new StoreFactory(originalKeys); + InMemoryStoreFactory factory = new InMemoryStoreFactory(originalKeys); SSLContext cached = factory.create(); - assertEquals(1, factory.loads); + assertEquals(1, factory.loadKeyStoreCalls); assertArrayEquals(originalChain, reportedChain(cached)); - KeyStore replacementKeys = ca.issue(); + KeyStore replacementKeys = ca.keyStoreWithPrivateKey(); X509Certificate[] replacementChain = certificateChain(replacementKeys); + assertNotEquals(originalChain[0], replacementChain[0]); + factory.store = replacementKeys; SSLContext fresh = factory.createNew(); assertNotSame(cached, fresh); - assertEquals(2, factory.loads); + assertEquals(2, factory.loadKeyStoreCalls); assertArrayEquals(replacementChain, reportedChain(fresh)); assertSame(cached, factory.create()); assertArrayEquals(originalChain, reportedChain(cached)); - assertEquals("Returning the cached context must not load the store again", 2, factory.loads); + assertEquals("Returning the cached context must not load the store again", 2, factory.loadKeyStoreCalls); } /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = ca.issue(); - StoreFactory factory = new StoreFactory(store); + KeyStore store = ca.keyStoreWithPrivateKey(); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); String[] protocols = {"TLSv1.2"}; String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; @@ -103,13 +106,14 @@ public void testMetadataWithTlsParameters() throws Exception { /** Multiple private keys leave the chain unknown but are not reported as missing. */ @Test public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { + String firstKey = "first-key"; String secondKey = "second-key"; - KeyStore store = ca.issue(); - KeyStore other = ca.issue(secondKey); + KeyStore store = ca.keyStoreWithPrivateKey(firstKey); + KeyStore other = ca.keyStoreWithPrivateKey(secondKey); store.setKeyEntry(secondKey, other.getKey(secondKey, PASSWORD), PASSWORD, other.getCertificateChain(secondKey)); - StoreFactory factory = new StoreFactory(store); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { assertNull(reportedChain(ctx)); @@ -121,7 +125,7 @@ public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { @Test public void testOnlyPrivateKeyEntriesCount() throws Exception { String keyAlias = "private-key"; - KeyStore keys = ca.issue(keyAlias); + KeyStore keys = ca.keyStoreWithPrivateKey(keyAlias); // JCEKS can hold both private keys and secret keys. KeyStore store = KeyStore.getInstance("JCEKS"); @@ -131,7 +135,7 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), new KeyStore.PasswordProtection(PASSWORD)); - StoreFactory factory = new StoreFactory(store); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); SSLContext withoutKey = factory.create(); @@ -149,7 +153,7 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ @Test public void testTrustOnlyContextCreation() throws Exception { - StoreFactory factory = new StoreFactory(ca.trustStore()); + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.trustStore()); String[] protocols = {"TLSv1.2"}; SSLParameters params = new SSLParameters(); @@ -175,35 +179,35 @@ public void testLegacyHooksRunOncePerBuiltContext() { assertNull(reportedChain(first)); assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(first)); - assertEquals(1, factory.checks); - assertEquals(1, factory.keys); - assertEquals(1, factory.trust); + assertEquals(1, factory.parameterChecks); + assertEquals(1, factory.keyManagerCalls); + assertEquals(1, factory.trustManagerCalls); assertSame(first, factory.create()); - assertEquals(1, factory.checks); - assertEquals(1, factory.keys); - assertEquals(1, factory.trust); + assertEquals(1, factory.parameterChecks); + assertEquals(1, factory.keyManagerCalls); + assertEquals(1, factory.trustManagerCalls); SSLContext next = factory.createNew(); assertNotSame(first, next); assertNull(reportedChain(next)); assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(next)); - assertEquals(2, factory.checks); - assertEquals(2, factory.keys); - assertEquals(2, factory.trust); + assertEquals(2, factory.parameterChecks); + assertEquals(2, factory.keyManagerCalls); + assertEquals(2, factory.trustManagerCalls); } /** A subclass can still obtain key managers from the file factory's legacy hook. */ @Test public void testLegacyKeyManagerAdapter() throws Exception { - StoreFactory factory = new StoreFactory(ca.issue()); + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); KeyManager[] mgrs = factory.legacyKeyManagers(); assertNotNull(mgrs); assertTrue(mgrs.length > 0); - assertEquals(1, factory.loads); + assertEquals(1, factory.loadKeyStoreCalls); } /** A factory that implements neither key-material hook reports the missing implementation. */ @@ -235,7 +239,7 @@ public void testMissingKeyMaterialImplementation() { /** A subclass can supply material directly, and callers cannot replace its recorded managers or chain through arrays. */ @Test public void testKeyMaterialDefensiveCopies() throws Exception { - KeyStore keys = ca.issue(); + KeyStore keys = ca.keyStoreWithPrivateKey(); KeyManager[] mgrs = TestCertificateAuthority.keyManagers(keys); KeyManager mgr = mgrs[0]; X509Certificate[] expected = certificateChain(keys); @@ -272,7 +276,7 @@ public void testKeyMaterialDefensiveCopies() throws Exception { /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ @Test public void testWrapperPreservesAndCopiesChain() throws Exception { - X509Certificate[] expected = certificateChain(ca.issue()); + X509Certificate[] expected = certificateChain(ca.keyStoreWithPrivateKey()); X509Certificate[] input = expected.clone(); SSLContext delegate = SSLContext.getInstance("TLS"); @@ -299,7 +303,7 @@ public void testWrapperPreservesAndCopiesChain() throws Exception { /** Explicit metadata takes precedence over the wrapped context's metadata. */ @Test public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { - X509Certificate[] expectedChain = certificateChain(ca.issue()); + X509Certificate[] expectedChain = certificateChain(ca.keyStoreWithPrivateKey()); SSLContext delegate = SSLContext.getInstance("TLS"); delegate.init(null, null, null); @@ -328,7 +332,7 @@ public void testMetadataWrapperPreservesContextBehavior() throws Exception { delegate.init(null, null, null); - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, certificateChain(ca.issue())); + SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, certificateChain(ca.keyStoreWithPrivateKey())); String host = "peer.example"; int port = 10800; SSLEngine engine = wrapper.createSSLEngine(host, port); @@ -366,15 +370,15 @@ private static boolean reportsMissingPrivateKey(SSLContext ctx) { } /** A file factory whose existing store-loading hook supplies the test material. */ - private static class StoreFactory extends SslContextFactory { + private static class InMemoryStoreFactory extends SslContextFactory { /** */ private KeyStore store; /** */ - private int loads; + private int loadKeyStoreCalls; /** */ - StoreFactory(KeyStore store) { + InMemoryStoreFactory(KeyStore store) { this.store = store; setKeyStoreType(store.getType()); @@ -385,7 +389,7 @@ private static class StoreFactory extends SslContextFactory { /** {@inheritDoc} */ @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { - loads++; + loadKeyStoreCalls++; return store; } @@ -400,29 +404,29 @@ private KeyManager[] legacyKeyManagers() throws SSLException { /** A subclass that implements the manager hooks without overriding createKeyMaterial(). */ private static class LegacyFactory extends AbstractSslContextFactory { /** */ - private int checks; + private int parameterChecks; /** */ - private int keys; + private int keyManagerCalls; /** */ - private int trust; + private int trustManagerCalls; /** {@inheritDoc} */ @Override protected void checkParameters() { - checks++; + parameterChecks++; } /** {@inheritDoc} */ @Override protected KeyManager[] createKeyManagers() { - keys++; + keyManagerCalls++; return null; } /** {@inheritDoc} */ @Override protected TrustManager[] createTrustManagers() { - trust++; + trustManagerCalls++; return null; } diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 85aed26b504d3..29bcfd74aad48 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -94,8 +94,8 @@ public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws } /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ - public KeyStore issue() throws Exception { - return issue(DEFAULT_NAME); + public KeyStore keyStoreWithPrivateKey() throws Exception { + return keyStoreWithPrivateKey(DEFAULT_NAME); } /** @@ -104,10 +104,10 @@ public KeyStore issue() throws Exception { * @param cn Common name, also used as the key alias. * @return Store with the private key and certificate chain. */ - public KeyStore issue(String cn) throws Exception { + public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { long now = System.currentTimeMillis(); - return issue(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); + return keyStoreWithPrivateKey(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); } /** @@ -115,8 +115,8 @@ public KeyStore issue(String cn) throws Exception { * @param notAfter Time it expires; rounded down to a second. * @return Store with one private key and a certificate with the requested validity dates. */ - public KeyStore issue(long notBefore, long notAfter) throws Exception { - return issue(DEFAULT_NAME, notBefore, notAfter); + public KeyStore keyStoreWithPrivateKey(long notBefore, long notAfter) throws Exception { + return keyStoreWithPrivateKey(DEFAULT_NAME, notBefore, notAfter); } /** @@ -125,8 +125,8 @@ public KeyStore issue(long notBefore, long notAfter) throws Exception { * @param notAfter Time it expires; rounded down to a second. * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. */ - public KeyStore issue(String cn, long notBefore, long notAfter) throws Exception { - return issue(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); + public KeyStore keyStoreWithPrivateKey(String cn, long notBefore, long notAfter) throws Exception { + return keyStoreWithPrivateKey(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); } /** @@ -137,7 +137,8 @@ public KeyStore issue(String cn, long notBefore, long notAfter) throws Exception * @param notAfter Time it expires; rounded down to a second. * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. */ - public KeyStore issue(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) throws Exception { + public KeyStore keyStoreWithPrivateKey(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) + throws Exception { X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); KeyStore store = emptyStore(); From 8f9d3216848e3a267a39f6af034de9cafc78fe78 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:52:25 +0300 Subject: [PATCH 033/242] IGNITE-29210 Make reload test premises and scripted steps visible --- .../internal/ssl/SslContextProviderTest.java | 102 +++++++++--------- .../ignite/ssl/SslContextReloadTest.java | 20 ++-- 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 4937e788ba81e..79254a06b99fe 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -71,7 +71,7 @@ public class SslContextProviderTest extends GridCommonAbstractTest { /** Changing a returned chain must not change the provider's certificate or chain. */ @Test public void testCertificateChainCannotChangeProvider() { - SslContextProvider p = new SslContextProvider(new Issuer(ca::issue)); + SslContextProvider p = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey)); X509Certificate[] chain = p.certificateChain(); @@ -88,13 +88,13 @@ public void testCertificateChainCannotChangeProvider() { /** Nodes with different certificates from one authority keep communicating when only one node reloads. */ @Test public void testReloadWithDifferentCertificatesFromSameAuthority() throws Exception { - checkNodeRotation(ca); + assertNodesReconnectAfterRotationAndRestart(ca); } /** Nodes need to trust each other's authorities, not their own, when one node reloads its certificate. */ @Test public void testReloadWithDifferentAuthorities() throws Exception { - checkNodeRotation(new TestCertificateAuthority("peerca")); + assertNodesReconnectAfterRotationAndRestart(new TestCertificateAuthority("peerca")); } /** Reload installs the administrator's material; an actual TLS connection still rejects an authority the peer does not trust. */ @@ -103,35 +103,35 @@ public void testReloadDoesNotBypassPeerTrust() throws Exception { String untrustedCaName = "untrusted-ca"; TestCertificateAuthority peerCa = new TestCertificateAuthority("peerca"); TestCertificateAuthority untrusted = new TestCertificateAuthority(untrustedCaName); - AtomicReference keys = new AtomicReference<>(ca.issue()); - KeyStore peerKeys = peerCa.issue(); + AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey()); + KeyStore peerKeys = peerCa.keyStoreWithPrivateKey(); - Issuer issuer = issuer(keys::get, peerCa.trustStore()); - Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); + ScriptedSslContextFactory factory = factory(keys::get, peerCa.trustStore()); + ScriptedSslContextFactory peerFactory = factory(() -> peerKeys, ca.trustStore()); - IgniteEx g0 = startNode(0, issuer); - IgniteEx g1 = startNode(1, peerIssuer); + IgniteEx g0 = startNode(0, factory); + IgniteEx g1 = startNode(1, peerFactory); X509Certificate peerCert = provider(g1).certificate(); - assertEquals(provider(g0).certificate(), servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); + assertEquals(provider(g0).certificate(), servedCertificate(peerFactory.createNew(), discoveryPort(g0))); - keys.set(untrusted.issue()); + keys.set(untrusted.keyStoreWithPrivateKey()); assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); assertTrue(provider(g0).reloadOutcome().successTime() > 0); assertEquals("CN=" + untrustedCaName, provider(g0).certificate().getIssuerX500Principal().getName()); assertEquals(peerCert, provider(g1).certificate()); - assertThrowsWithCause(() -> servedCertificate(peerIssuer.createNew(), discoveryPort(g0)), SSLException.class); + assertThrowsWithCause(() -> servedCertificate(peerFactory.createNew(), discoveryPort(g0)), SSLException.class); } /** Commands reload a store with multiple private keys and report that its certificate metadata is unavailable. */ @Test public void testCommandsReportUnknownMetadataForMultipleKeys() throws Exception { - Issuer issuer = new Issuer(this::twoKeys); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(this::twoKeys); - IgniteEx g = startClientConnectorOn(issuer); + IgniteEx g = startClientConnectorOn(factory); SslContextProvider p = provider(g, CLIENT_CONNECTOR); @@ -143,9 +143,9 @@ public void testCommandsReportUnknownMetadataForMultipleKeys() throws Exception /** Replacing one key with several keys must not prevent manual reload. */ @Test public void testReloadCanLoseMetadata() throws Exception { - Issuer issuer = new Issuer(ca::issue, this::twoKeys); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, this::twoKeys); - SslContextProvider p = new SslContextProvider(issuer); + SslContextProvider p = new SslContextProvider(factory); SSLContext inUse = p.context(); assertNotNull(p.certificateChain()); @@ -166,7 +166,12 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { throw new IgniteException(failureReason); }; - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), fresh(HOUR), unavailable, unavailable, fresh(2 * HOUR))); + Callable initialKeys = freshKeysValidFor(HOUR); + Callable successfulReloadKeys = freshKeysValidFor(HOUR); + Callable recoveryKeys = freshKeysValidFor(2 * HOUR); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory( + initialKeys, successfulReloadKeys, unavailable, unavailable, recoveryKeys); + SslContextProvider p = new SslContextProvider(factory); p.reload(); @@ -206,8 +211,9 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { @Test public void testNotYetValidCertificateNotApplied() throws Exception { long now = System.currentTimeMillis(); - Issuer issuer = new Issuer(ca::issue, () -> ca.issue("future", now + HOUR, now + 2 * HOUR)); - SslContextProvider p = new SslContextProvider(issuer); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, + () -> ca.keyStoreWithPrivateKey("future", now + HOUR, now + 2 * HOUR)); + SslContextProvider p = new SslContextProvider(factory); SSLContext inUse = p.context(); X509Certificate[] chain = p.certificateChain(); @@ -224,11 +230,11 @@ public void testExpiredIssuerNotApplied() throws Exception { long now = System.currentTimeMillis(); String expiredCaName = "expired-ca"; TestCertificateAuthority expiredCa = new TestCertificateAuthority(expiredCaName, now - 2 * HOUR, now - HOUR); - KeyStore candidate = expiredCa.issue(); + KeyStore candidate = expiredCa.keyStoreWithPrivateKey(); TestCertificateAuthority.certificateChain(candidate)[0].checkValidity(); - SslContextProvider p = new SslContextProvider(new Issuer(ca::issue, () -> candidate)); + SslContextProvider p = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, () -> candidate)); SSLContext inUse = p.context(); X509Certificate[] chain = p.certificateChain(); @@ -243,11 +249,11 @@ public void testExpiredIssuerNotApplied() throws Exception { @Test public void testUnsupportedProtocolNotApplied() { String invalidProtocol = "SSLvDoesNotExist"; - Issuer issuer = new Issuer(ca::issue); - SslContextProvider p = new SslContextProvider(issuer); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey); + SslContextProvider p = new SslContextProvider(factory); SSLContext inUse = p.context(); - issuer.setProtocols(invalidProtocol); + factory.setProtocols(invalidProtocol); GridTestUtils.assertThrows(log, p::reload, IllegalArgumentException.class, invalidProtocol); @@ -259,8 +265,8 @@ public void testUnsupportedProtocolNotApplied() { private KeyStore twoKeys() throws Exception { String firstAlias = "first-key"; String secondAlias = "second-key"; - KeyStore keys = ca.issue(firstAlias); - KeyStore other = ca.issue(secondAlias); + KeyStore keys = ca.keyStoreWithPrivateKey(firstAlias); + KeyStore other = ca.keyStoreWithPrivateKey(secondAlias); keys.setKeyEntry(secondAlias, other.getKey(secondAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, other.getCertificateChain(secondAlias)); @@ -290,14 +296,14 @@ private IgniteEx startNode(int idx, AbstractSslContextFactory factory) throws Ex /** * @param source Key store to use on each request. * @param trust Authorities the node accepts. - * @return Configured issuer. + * @return Configured factory. */ - private Issuer issuer(Callable source, KeyStore trust) { - Issuer issuer = new Issuer(source); + private ScriptedSslContextFactory factory(Callable source, KeyStore trust) { + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(source); - issuer.trust = trust; + factory.trust = trust; - return issuer; + return factory; } /** @@ -305,21 +311,21 @@ private Issuer issuer(Callable source, KeyStore trust) { * * @param peerCa Authority of the peer whose certificate stays unchanged. */ - private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception { - AtomicReference keys = new AtomicReference<>(ca.issue()); - KeyStore peerKeys = peerCa.issue(); - Issuer issuer = issuer(keys::get, peerCa.trustStore()); - Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore()); + private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthority peerCa) throws Exception { + AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey()); + KeyStore peerKeys = peerCa.keyStoreWithPrivateKey(); + ScriptedSslContextFactory factory = factory(keys::get, peerCa.trustStore()); + ScriptedSslContextFactory peerFactory = factory(() -> peerKeys, ca.trustStore()); - IgniteEx g0 = startNode(0, issuer); - IgniteEx g1 = startNode(1, peerIssuer); + IgniteEx g0 = startNode(0, factory); + IgniteEx g1 = startNode(1, peerFactory); X509Certificate peerCert = provider(g1).certificate(); assertFalse(provider(g0).certificate().equals(peerCert)); assertComputeWorks(g0, g1); - KeyStore replacement = ca.issue(); + KeyStore replacement = ca.keyStoreWithPrivateKey(); X509Certificate replacementCert = TestCertificateAuthority.certificateChain(replacement)[0]; keys.set(replacement); @@ -329,14 +335,14 @@ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception assertEquals(replacementCert, provider(g0).certificate()); assertEquals(peerCert, provider(g1).certificate()); assertEquals(0, provider(g1).reloadOutcome().successTime()); - assertEquals(replacementCert, servedCertificate(peerIssuer.createNew(), discoveryPort(g0))); - assertEquals(peerCert, servedCertificate(issuer.createNew(), discoveryPort(g1))); + assertEquals(replacementCert, servedCertificate(peerFactory.createNew(), discoveryPort(g0))); + assertEquals(peerCert, servedCertificate(factory.createNew(), discoveryPort(g1))); assertComputeWorks(g0, g1); stopGrid(1); - g1 = startNode(1, issuer(() -> peerKeys, ca.trustStore())); + g1 = startNode(1, factory(() -> peerKeys, ca.trustStore())); assertEquals(replacementCert, provider(g0).certificate()); assertEquals(peerCert, provider(g1).certificate()); @@ -344,10 +350,10 @@ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception stopGrid(0); - IgniteEx restarted = startNode(0, issuer(keys::get, peerCa.trustStore())); + IgniteEx restarted = startNode(0, factory(keys::get, peerCa.trustStore())); assertEquals(replacementCert, provider(restarted).certificate()); - assertEquals(replacementCert, servedCertificate(peerIssuer.createNew(), discoveryPort(restarted))); + assertEquals(replacementCert, servedCertificate(peerFactory.createNew(), discoveryPort(restarted))); assertEquals(peerCert, provider(g1).certificate()); assertComputeWorks(restarted, g1); } @@ -365,11 +371,11 @@ private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { * @param lifetime Lifetime of the certificate. * @return Step that issues a certificate valid from the moment it is issued. */ - private Callable fresh(long lifetime) { + private Callable freshKeysValidFor(long lifetime) { return () -> { long now = System.currentTimeMillis(); - return ca.issue("node", now, now + lifetime); + return ca.keyStoreWithPrivateKey("node", now, now + lifetime); }; } @@ -396,7 +402,7 @@ private static SslContextProvider provider(IgniteEx g, String transport) { } /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ - private class Issuer extends AbstractSslContextFactory { + private class ScriptedSslContextFactory extends AbstractSslContextFactory { /** */ @Serial private static final long serialVersionUID = 0L; @@ -412,7 +418,7 @@ private class Issuer extends AbstractSslContextFactory { /** @param steps Answers to the requests in order, the last one repeated for the rest. */ @SafeVarargs - private Issuer(Callable... steps) { + private ScriptedSslContextFactory(Callable... steps) { this.steps = List.of(steps); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 09e9477c6ecd5..6cbc1ac3b32dc 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -159,8 +159,8 @@ public class SslContextReloadTest extends GridCommonAbstractTest { long leafExpiry = now + 2 * HOUR; newCa = new TestCertificateAuthority("new-ca", now - HOUR, issuerExpiry); - oldKeys = oldCa.issue(OLD_NODE); - newKeys = newCa.issue(NEW_NODE, now - HOUR, leafExpiry); + oldKeys = oldCa.keyStoreWithPrivateKey(OLD_NODE); + newKeys = newCa.keyStoreWithPrivateKey(NEW_NODE, now - HOUR, leafExpiry); oldCert = certificateChain(oldKeys)[0]; newCert = certificateChain(newKeys)[0]; trustBoth = TestCertificateAuthority.trustStore(oldCa, newCa); @@ -180,6 +180,8 @@ public class SslContextReloadTest extends GridCommonAbstractTest { */ @Test public void testReloadOnRunningCluster() throws Exception { + initTrustStore = trustBoth; + X509Certificate[] replacementChain = certificateChain(newKeys); long issuerExpiry = replacementChain[1].getNotAfter().getTime(); @@ -363,7 +365,7 @@ public void testEqualFactoriesReloadIndependently() throws Exception { nodeFactory.fail = true; - KeyStore nextKeys = newCa.issue(); + KeyStore nextKeys = newCa.keyStoreWithPrivateKey(); placeKeys(nextKeys); @@ -426,9 +428,9 @@ public void testKeyStoreWithoutKeyNotApplied() throws Exception { @Test public void testClientTransportsWithSeparateFactories() throws Exception { initTrustStore = oldCa.trustStore(); - KeyStore clientKeys = oldCa.issue(); - KeyStore restKeys = oldCa.issue(); - KeyStore nextRestKeys = newCa.issue(); + KeyStore clientKeys = oldCa.keyStoreWithPrivateKey(); + KeyStore restKeys = oldCa.keyStoreWithPrivateKey(); + KeyStore nextRestKeys = newCa.keyStoreWithPrivateKey(); Path clientPath = dir.resolve("client-key.jks"); Path restPath = dir.resolve("binary-rest-key.jks"); @@ -467,7 +469,7 @@ public void testClientTransportsWithSeparateFactories() throws Exception { assertEquals(certificateChain(nextRestKeys)[0], certificateAt(restPort)); } - /** Manual reload accepts several keys, reports unavailable metadata and keeps existing and new TLS connections working. */ + /** Manual reload accepts several keys without metadata. Client operations and new TLS connections still work. */ @Test public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Exception { ownClientConnectorFactory = true; @@ -556,7 +558,7 @@ public void testCertificateAuthorityReplaced() throws Exception { assertEquals((Integer)1, g0.compute(g0.cluster().forNodeId(g1.localNode().id())).call(() -> 1)); - TestCertificateAuthority.save(newCa.issue(), keyStore(getTestIgniteInstanceName(2))); + TestCertificateAuthority.save(newCa.keyStoreWithPrivateKey(), keyStore(getTestIgniteInstanceName(2))); TestCertificateAuthority.save(newCa.trustStore(), trustStore(getTestIgniteInstanceName(2))); IgniteEx g2 = startGrid(2); @@ -727,7 +729,7 @@ private X509Certificate certificateAt(int port) throws Exception { /** @return Store with an expired leaf issued by the replacement authority. */ private KeyStore expiredKeys() throws Exception { - return newCa.issue("expired-node", now - 2 * HOUR, now - HOUR); + return newCa.keyStoreWithPrivateKey("expired-node", now - 2 * HOUR, now - HOUR); } /** */ From bc9542caff0b2bafb393bf1094e80c0dae6dec98 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:53:43 +0300 Subject: [PATCH 034/242] IGNITE-29210 Name the first scripted load after startup --- .../apache/ignite/internal/ssl/SslContextProviderTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 79254a06b99fe..6bb3a47e75c73 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -166,11 +166,11 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { throw new IgniteException(failureReason); }; - Callable initialKeys = freshKeysValidFor(HOUR); + Callable startupKeys = freshKeysValidFor(HOUR); Callable successfulReloadKeys = freshKeysValidFor(HOUR); Callable recoveryKeys = freshKeysValidFor(2 * HOUR); ScriptedSslContextFactory factory = new ScriptedSslContextFactory( - initialKeys, successfulReloadKeys, unavailable, unavailable, recoveryKeys); + startupKeys, successfulReloadKeys, unavailable, unavailable, recoveryKeys); SslContextProvider p = new SslContextProvider(factory); p.reload(); From af091ee136dad5a618a867f1726508665b3182d5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:55:49 +0300 Subject: [PATCH 035/242] IGNITE-29211 Make renewal conditions and source transitions visible in tests --- .../ignite/internal/ssl/SslRenewalTest.java | 192 +++++++++++++----- 1 file changed, 145 insertions(+), 47 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 07260aa99bc93..9fd0471e99742 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -151,7 +151,7 @@ public void testRenewsBeforeExpiry() throws Exception { due.set(notAfter - window); - return ca.issue(notBefore, notAfter); + return ca.keyStoreWithPrivateKey(notBefore, notAfter); }, fresh(HOUR)); IgniteEx g = startNode(issuer); @@ -184,7 +184,7 @@ public void testRenewalPlannedBeforeAuthorityExpiry() throws Exception { ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); - KeyStore keys = ca.issue(notBefore, authorityExpiry + HOUR); + KeyStore keys = ca.keyStoreWithPrivateKey(notBefore, authorityExpiry + HOUR); long due = authorityExpiry - (long)((authorityExpiry - notBefore) * DFLT_RENEW_BEFORE_FRACTION); @@ -198,8 +198,16 @@ public void testRenewalPlannedBeforeAuthorityExpiry() throws Exception { @Test public void testLateFailureLoggedAsError() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = window / 4; + long notAfter = now + timeLeft; - IgniteEx g = startNode(new Issuer(valid(now - HOUR, now + 2 * MINUTE), failure())); + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure()); + + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + + IgniteEx g = startNode(issuer); String late = "Failed to reload TLS certificates, the ones in use stay and expire soon [" + NODE_TRANSPORTS + ", initiator=automatic renewal, reason=" + ISSUER_DOWN; @@ -212,10 +220,14 @@ public void testLateFailureLoggedAsError() throws Exception { /** A certificate that expires no later than the one in use must not be put in use. */ @Test - public void testRenewalNotMovingExpiryRejected() { + public void testRenewalNotMovingExpiryRejected() throws Exception { long now = System.currentTimeMillis(); + long notBefore = now - HOUR; + long notAfter = now + HOUR; + KeyStore startupKeys = ca.keyStoreWithPrivateKey("startup", notBefore, notAfter); + KeyStore replacement = ca.keyStoreWithPrivateKey("replacement", notBefore, notAfter); - SslContextProvider p = new SslContextProvider(new Issuer(valid(now - HOUR, now + 5 * MINUTE))); + SslContextProvider p = new SslContextProvider(new Issuer(() -> startupKeys, () -> replacement)); assertRenewalRejected(p, "The new certificate chain expires no later than the one in use"); } @@ -223,7 +235,7 @@ public void testRenewalNotMovingExpiryRejected() { /** An automatic renewal preserves connections to a peer whose certificate and different authority stay unchanged. */ @Test public void testRenewalWithDifferentAuthorities() throws Exception { - checkNodeRotation(new TestCertificateAuthority("peerca")); + assertNodesReconnectAfterRotationAndRestart(new TestCertificateAuthority("peerca")); } /** An expired certificate can be replaced with a valid chain from another authority. */ @@ -234,7 +246,7 @@ public void testExpiredCertificateRenewed() throws Exception { TestCertificateAuthority other = new TestCertificateAuthority(authorityName); - Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.issue(now, now + HOUR)); + Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.keyStoreWithPrivateKey(now, now + HOUR)); SslContextProvider p = new SslContextProvider(issuer); @@ -250,11 +262,18 @@ public void testExpiredCertificateRenewed() throws Exception { @Test public void testRenewalLogsAppliedCertificate() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; String renewedName = "renewed"; String manualName = "manual"; - Issuer issuer = new Issuer(valid(now - HOUR, now + MINUTE), - () -> ca.issue(renewedName, now, now + HOUR), () -> ca.issue(manualName, now, now + 2 * HOUR)); + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), + () -> ca.keyStoreWithPrivateKey(renewedName, now, now + HOUR), + () -> ca.keyStoreWithPrivateKey(manualName, now, now + 2 * HOUR)); + + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); SslContextProvider p = new SslContextProvider(issuer) { @Override X509Certificate renew(SSLContext expected) throws IgniteCheckedException { @@ -302,7 +321,7 @@ public void testRenewalNotValidYetRejected() { */ @Test public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { - KeyStore inUse = ca.issue(); + KeyStore inUse = ca.keyStoreWithPrivateKey(); Issuer issuer = new Issuer(() -> inUse); @@ -328,8 +347,14 @@ public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { @Test public void testReloadDuringRenewalPlanning() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; + + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(2 * HOUR), fresh(3 * HOUR)); - Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), fresh(2 * HOUR), fresh(3 * HOUR)); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); CountDownLatch planRead = new CountDownLatch(1); CountDownLatch continuePlanning = new CountDownLatch(1); @@ -421,10 +446,18 @@ public void testReloadDuringRenewalPlanning() throws Exception { @Test public void testReloadByCommandReplans() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; pauses(MINUTE, MINUTE); - IgniteEx g = startNode(new Issuer(valid(now - HOUR, now + 9 * MINUTE), failure(), fresh(2 * HOUR), fresh(HOUR))); + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure(), fresh(2 * HOUR), fresh(HOUR)); + + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + + IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> logged(Level.WARN, ISSUER_DOWN) == 1, 30_000)); @@ -451,9 +484,15 @@ public void testReloadByCommandReplans() throws Exception { @Test public void testReloadContextNotCarriedIntoRenewals() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; String operator = "operator"; - Issuer issuer = new Issuer(fresh(HOUR), valid(now - HOUR, now + 9 * MINUTE), fresh(HOUR)); + Issuer issuer = new Issuer(fresh(HOUR), valid(notAfter - lifetime, notAfter), fresh(HOUR)); + + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); IgniteEx g = startNode(issuer); @@ -474,12 +513,18 @@ public void testReloadContextNotCarriedIntoRenewals() throws Exception { @Test public void testFailedRenewalRetried() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; long minPause = 100; long maxPause = 4 * minPause; - Issuer issuer = new Issuer(valid(now - HOUR, now + 9 * MINUTE), + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure(), failure(), failure(), failure(), failure(), failure(), fresh(HOUR)); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + pauses(minPause, maxPause); IgniteEx g = startNode(issuer); @@ -529,17 +574,22 @@ public void testFailureBeforeExpiringRetryLoggedAsError() throws Exception { @Test public void testRenewalsNoMoreOftenThanMinPause() throws Exception { long minPause = 300; + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; AtomicInteger issued = new AtomicInteger(); Callable dueOnIssue = () -> { - long t = System.currentTimeMillis(); - // A second later each time: notAfter is rounded down to a second, and a renewal that does not move it is refused. - return ca.issue(t - HOUR, t + 5 * MINUTE + issued.incrementAndGet() * SECOND); + long notAfter = System.currentTimeMillis() + timeLeft + issued.incrementAndGet() * SECOND; + + return ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter); }; Issuer issuer = new Issuer(dueOnIssue); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + pauses(minPause, minPause); startNode(issuer); @@ -568,8 +618,14 @@ public void testInvalidSettingsRefused() { @Test public void testClientConnectorRenewed() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; + + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(HOUR)); - Issuer issuer = new Issuer(valid(now - HOUR, now + 5 * MINUTE), fresh(HOUR)); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); IgniteEx g = startClientConnectorOn(issuer); @@ -577,12 +633,12 @@ public void testClientConnectorRenewed() throws Exception { SslContextProvider p = provider(g, CLIENT_CONNECTOR); - long notAfter = SslCertificates.chainNotAfter(p.certificateChain()); + long renewedExpiry = SslCertificates.chainNotAfter(p.certificateChain()); - assertTrue(notAfter > now + 30 * MINUTE); + assertTrue(renewedExpiry > now + 30 * MINUTE); assertTrue(waitForCondition(() -> p.nextRenewalTime() > now + 30 * MINUTE, 10_000)); - assertEquals("New connections must get the renewed certificate", notAfter, + assertEquals("New connections must get the renewed certificate", renewedExpiry, servedCertificate(probe(), g.context().clientListener().port()).getNotAfter().getTime()); } @@ -592,9 +648,14 @@ public void testRenewalOffByDefault() throws Exception { assertFalse(new SslContextFactory().isRenewalEnabled()); long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; - Issuer issuer = new Issuer(valid(now - HOUR, now + 5 * MINUTE)); + Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter)); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); issuer.setRenewalEnabled(false); IgniteEx g = startNode(issuer); @@ -614,7 +675,7 @@ public void testRenewalFailuresMetric() throws Exception { long now = System.currentTimeMillis(); - KeyStore inUse = ca.issue(now - HOUR, now + HOUR); + KeyStore inUse = ca.keyStoreWithPrivateKey(now - HOUR, now + HOUR); Issuer nodeIssuer = new Issuer(() -> inUse, () -> inUse, fresh(2 * HOUR)); Issuer clientIssuer = new Issuer(() -> inUse, () -> inUse, () -> inUse, fresh(2 * HOUR)); @@ -662,15 +723,21 @@ public void testRenewalFailuresMetric() throws Exception { @Test public void testWaitsForNewerCertificate() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; long minPause = 100; long fourthPause = minPause * 2 * 2 * 2; pauses(minPause, 10 * minPause); - KeyStore inUse = ca.issue(now - HOUR, now + 9 * MINUTE); + KeyStore inUse = ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter); Issuer issuer = new Issuer(() -> inUse); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> provider(g).renewalFailures() >= 5, 30_000)); @@ -720,7 +787,7 @@ public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { if (inUse.get() == null) { long notAfter = System.currentTimeMillis() + timeLeft; - inUse.set(ca.issue(notAfter - lifetime, notAfter)); + inUse.set(ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter)); } return inUse.get(); @@ -753,10 +820,14 @@ public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { @Test public void testStopDuringRenewalLogsNoFailure() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; CountDownLatch entered = new CountDownLatch(1); - Issuer issuer = new Issuer(fresh(HOUR), valid(now - HOUR, now + 9 * MINUTE), () -> { + Issuer issuer = new Issuer(fresh(HOUR), valid(notAfter - lifetime, notAfter), () -> { entered.countDown(); Thread.sleep(Long.MAX_VALUE); @@ -764,6 +835,8 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { return null; }); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + IgniteEx g = startNode(issuer); SslContextProvider p = provider(g); @@ -785,8 +858,8 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { @Test public void testInterruptedRenewalReturnPreservesState() throws Exception { long now = System.currentTimeMillis(); - KeyStore original = ca.issue("original", now - HOUR, now + HOUR); - KeyStore replacement = ca.issue("replacement", now - MINUTE, now + 2 * HOUR); + KeyStore original = ca.keyStoreWithPrivateKey("original", now - HOUR, now + HOUR); + KeyStore replacement = ca.keyStoreWithPrivateKey("replacement", now - MINUTE, now + 2 * HOUR); for (KeyStore candidate : List.of(original, replacement)) { Issuer issuer = new Issuer(() -> original, () -> original, () -> original, failure(), () -> { @@ -836,7 +909,7 @@ public void testInterruptedRenewalReturnPreservesState() throws Exception { public void testSameCertificatesUnchanged() throws Exception { long now = System.currentTimeMillis(); - SSLContext cached = TestCertificateAuthority.context(ca.issue(), ca.trustStore()); + SSLContext cached = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); SslContextProvider p = new SslContextProvider(() -> cached); @@ -847,7 +920,7 @@ public void testSameCertificatesUnchanged() throws Exception { assertEquals(2, p.renewalFailures()); assertEquals(0, p.reloadOutcome().failures()); - KeyStore expired = ca.issue(now - 2 * HOUR, now - HOUR); + KeyStore expired = ca.keyStoreWithPrivateKey(now - 2 * HOUR, now - HOUR); SslContextProvider expiredProvider = new SslContextProvider(new Issuer(() -> expired)); @@ -863,7 +936,7 @@ public void testSameCertificatesUnchanged() throws Exception { public void testUnsuccessfulRenewalsCounted() throws Exception { long now = System.currentTimeMillis(); - KeyStore inUse = ca.issue(now - HOUR, now + HOUR); + KeyStore inUse = ca.keyStoreWithPrivateKey(now - HOUR, now + HOUR); SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, fresh(2 * HOUR))); @@ -889,10 +962,9 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio long notAfter = now + HOUR; String alias = "node"; - KeyStore inUse = ca.issue(alias, notBefore, notAfter); - - Issuer issuer = new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, () -> inUse, - valid(notBefore, notAfter), fresh(2 * HOUR)); + KeyStore inUse = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); + AtomicReference> source = new AtomicReference<>(() -> inUse); + Issuer issuer = new Issuer(() -> source.get().call()); SslContextProvider p = new SslContextProvider(issuer); @@ -900,11 +972,15 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio assertEquals(1, p.renewalFailures()); // A failed manual reload is not an automatic renewal attempt. + source.set(failure()); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); assertEquals(1, p.renewalFailures()); // Reloading the same certificate clears the reload error, but not the renewal failures. + source.set(() -> inUse); + p.reload(); assertEquals(1, p.renewalFailures()); @@ -920,12 +996,18 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio assertEquals(1, p.renewalFailures()); // A different certificate with the same expiry still does not extend the chain's lifetime. + KeyStore sameExpiry = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); + + source.set(() -> sameExpiry); + p.reload(); assertFalse(inUse.getCertificate(alias).equals(p.certificate())); assertEquals(1, p.renewalFailures()); // Only the final certificate extends the chain's lifetime. + source.set(() -> ca.keyStoreWithPrivateKey(notBefore, notAfter + HOUR)); + p.reload(); assertEquals(0, p.renewalFailures()); @@ -942,7 +1024,7 @@ public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); - KeyStore inUse = ca.issue(notBefore, leafExpiry); + KeyStore inUse = ca.keyStoreWithPrivateKey(notBefore, leafExpiry); SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, valid(notBefore, laterLeafExpiry))); @@ -961,11 +1043,15 @@ public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { @Test public void testReplacedKeyStoreFileRenewed() throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; Path keyStore = Files.createTempFile("ignite-ssl-renewal-", ".jks"); try { - TestCertificateAuthority.save(ca.issue(now - HOUR, now + 9 * MINUTE), keyStore); + TestCertificateAuthority.save(ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter), keyStore); SslContextFactory factory = new SslContextFactory(); @@ -973,13 +1059,14 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { factory.setKeyStorePassword(TestCertificateAuthority.PWD); factory.setKeyStoreType("JKS"); factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); + factory.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); factory.setRenewalEnabled(true); IgniteEx g = startNode(factory); assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 1, 30_000)); - TestCertificateAuthority.save(ca.issue(now, now + HOUR), keyStore); + TestCertificateAuthority.save(ca.keyStoreWithPrivateKey(now, now + HOUR), keyStore); assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); assertTrue(chainNotAfter(g) > now + 30 * MINUTE); @@ -994,6 +1081,8 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { public void testUnknownCertificateRefusedAtStart() { Issuer issuer = new Issuer(SslRenewalTest::noKey); + issuer.setRenewalEnabled(true); + GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, "The SSL context factory provides no certificate chain"); } @@ -1028,6 +1117,9 @@ public void testUnknownCertificateWithoutRenewal() throws Exception { @Test public void testReloadCannotLoseMetadataWithRenewal() { Issuer issuer = new Issuer(fresh(HOUR), this::twoKeys); + + issuer.setRenewalEnabled(true); + SslContextProvider p = new SslContextProvider(issuer); issuer.setRenewalEnabled(false); @@ -1053,8 +1145,8 @@ public void testReloadCannotLoseMetadataWithRenewal() { /** @return Store with two valid private keys and therefore no single chain for renewal metadata. */ private KeyStore twoKeys() throws Exception { String otherAlias = "other"; - KeyStore keys = ca.issue(); - KeyStore other = ca.issue(otherAlias); + KeyStore keys = ca.keyStoreWithPrivateKey(); + KeyStore other = ca.keyStoreWithPrivateKey(otherAlias); keys.setKeyEntry(otherAlias, other.getKey(otherAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, other.getCertificateChain(otherAlias)); @@ -1118,14 +1210,20 @@ private Issuer issuer(Callable source, KeyStore trust, boolean renewal * * @param peerCa Authority of the peer whose certificate stays unchanged. */ - private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception { + private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthority peerCa) throws Exception { long now = System.currentTimeMillis(); + long lifetime = HOUR; + long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); + long timeLeft = 3 * window / 4; + long notAfter = now + timeLeft; - AtomicReference keys = new AtomicReference<>(ca.issue("node0", now - HOUR, now + 9 * MINUTE)); - KeyStore peerKeys = peerCa.issue("node1", now - MINUTE, now + HOUR); + AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey("node0", notAfter - lifetime, notAfter)); + KeyStore peerKeys = peerCa.keyStoreWithPrivateKey("node1", now - MINUTE, now + HOUR); Issuer issuer = issuer(keys::get, peerCa.trustStore(), true); Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore(), false); + issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); + IgniteEx g0 = startNode(0, issuer); IgniteEx g1 = startNode(1, peerIssuer); @@ -1135,7 +1233,7 @@ private void checkNodeRotation(TestCertificateAuthority peerCa) throws Exception assertComputeWorks(g0, g1); String renewedAlias = "node0-new"; - KeyStore replacement = ca.issue(renewedAlias, now, now + 2 * HOUR); + KeyStore replacement = ca.keyStoreWithPrivateKey(renewedAlias, now, now + 2 * HOUR); X509Certificate renewed = (X509Certificate)replacement.getCertificate(renewedAlias); keys.set(replacement); @@ -1183,7 +1281,7 @@ private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { * @return Step that issues a certificate valid for that time. */ private Callable valid(long notBefore, long notAfter) { - return () -> ca.issue(notBefore, notAfter); + return () -> ca.keyStoreWithPrivateKey(notBefore, notAfter); } /** @@ -1194,7 +1292,7 @@ private Callable fresh(long lifetime) { return () -> { long now = System.currentTimeMillis(); - return ca.issue(now, now + lifetime); + return ca.keyStoreWithPrivateKey(now, now + lifetime); }; } @@ -1207,7 +1305,7 @@ private static Callable failure() { /** @return Context to connect to the nodes with; a new one each time, so that no session is resumed with an old certificate. */ private SSLContext probe() throws Exception { - return TestCertificateAuthority.context(ca.issue(), ca.trustStore()); + return TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); } /** From 2388db405add07170d657af4b02d58b4d92899a8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:57:45 +0300 Subject: [PATCH 036/242] IGNITE-29211 Keep the test factory on the key-material hook --- .../ignite/internal/ssl/SslRenewalTest.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 9fd0471e99742..7a6bd6fd19013 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -37,7 +37,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; @@ -963,8 +962,8 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio String alias = "node"; KeyStore inUse = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); - AtomicReference> source = new AtomicReference<>(() -> inUse); - Issuer issuer = new Issuer(() -> source.get().call()); + AtomicReference> keySupplier = new AtomicReference<>(() -> inUse); + Issuer issuer = new Issuer(() -> keySupplier.get().call()); SslContextProvider p = new SslContextProvider(issuer); @@ -972,14 +971,14 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio assertEquals(1, p.renewalFailures()); // A failed manual reload is not an automatic renewal attempt. - source.set(failure()); + keySupplier.set(failure()); GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); assertEquals(1, p.renewalFailures()); // Reloading the same certificate clears the reload error, but not the renewal failures. - source.set(() -> inUse); + keySupplier.set(() -> inUse); p.reload(); @@ -998,7 +997,7 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio // A different certificate with the same expiry still does not extend the chain's lifetime. KeyStore sameExpiry = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); - source.set(() -> sameExpiry); + keySupplier.set(() -> sameExpiry); p.reload(); @@ -1006,7 +1005,7 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio assertEquals(1, p.renewalFailures()); // Only the final certificate extends the chain's lifetime. - source.set(() -> ca.keyStoreWithPrivateKey(notBefore, notAfter + HOUR)); + keySupplier.set(() -> ca.keyStoreWithPrivateKey(notBefore, notAfter + HOUR)); p.reload(); @@ -1477,11 +1476,6 @@ private Issuer(Callable... steps) { // No-op. } - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers() throws SSLException { - return createKeyMaterial().keyManagers(); - } - /** {@inheritDoc} */ @Override protected KeyMaterial createKeyMaterial() throws SSLException { Callable step = steps.get(Math.min(calls.size(), steps.size() - 1)); From 90b375a6b1d41c1d9a64c764d7d652229b741877 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 03:03:28 +0300 Subject: [PATCH 037/242] IGNITE-29211 Give both test keys explicit distinct aliases --- .../java/org/apache/ignite/internal/ssl/SslRenewalTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 7a6bd6fd19013..3f9f0c4591273 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -1143,8 +1143,9 @@ public void testReloadCannotLoseMetadataWithRenewal() { /** @return Store with two valid private keys and therefore no single chain for renewal metadata. */ private KeyStore twoKeys() throws Exception { + String firstAlias = "first"; String otherAlias = "other"; - KeyStore keys = ca.keyStoreWithPrivateKey(); + KeyStore keys = ca.keyStoreWithPrivateKey(firstAlias); KeyStore other = ca.keyStoreWithPrivateKey(otherAlias); keys.setKeyEntry(otherAlias, other.getKey(otherAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, From 2c9b01f235a7bec5598b6d56e2bc077c2d8c9cb6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:28:46 +0300 Subject: [PATCH 038/242] IGNITE-29209 Build fresh SSL bundles from loaded key material --- .../ignite/ssl/AbstractSslContextFactory.java | 186 +++------- .../ignite/ssl/DelegatingSSLContextSpi.java | 5 +- .../org/apache/ignite/ssl/KeyMaterial.java | 127 +++++++ .../apache/ignite/ssl/SSLContextWrapper.java | 57 +-- .../java/org/apache/ignite/ssl/SslBundle.java | 74 ++++ .../apache/ignite/ssl/SslContextFactory.java | 34 +- .../ignite/ssl/SslContextMetadataTest.java | 351 +++++++++--------- .../TestCertificateAuthority.java | 21 +- 8 files changed, 459 insertions(+), 396 deletions(-) create mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java create mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index d269abbd90722..dbcac5803ed30 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -18,12 +18,10 @@ package org.apache.ignite.ssl; import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.cert.Certificate; import java.security.cert.X509Certificate; -import java.util.Enumeration; +import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; @@ -37,7 +35,7 @@ /** * Base SSL context factory that caches the result of the first successful {@link #create()} call. - * Use {@link #createNew()} to build another context without changing the cached one. + * Use {@link #createSslBundle()} to load another context and its certificate chains without changing the cached context. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -115,16 +113,43 @@ public void setProtocols(String... protocols) { } /** - * Creates SSL context based on factory settings. + * Builds a fresh context with the legacy key-manager hook. Subclasses that load a key store can override this method + * and call {@link #createSslBundle(KeyMaterial)} to include its certificate chains. * - * @return Initialized SSL context. - * @throws SSLException If SSL context could not be created. + * @return Fresh context and the certificate metadata available to this factory. + * @throws IgniteException If the context could not be created. */ - private SSLContext createSslContext() throws SSLException { - checkParameters(); + public SslBundle createSslBundle() { + try { + checkParameters(); + + return createSslBundle(createKeyManagers(), null); + } + catch (SSLException e) { + throw new IgniteException(e); + } + } - KeyMaterial material = createKeyMaterial(); + /** + * Builds a fresh context from one loaded store. The caller must check its parameters before loading the material. + * This method does not call {@link #createKeyManagers()} or change the context cached by {@link #create()}. + * + * @param material Loaded key store, key password and manager algorithm. + * @return Context and certificate chains from that material. + * @throws SSLException If key managers, certificate metadata or the context cannot be created. + */ + protected SslBundle createSslBundle(KeyMaterial material) throws SSLException { + return createSslBundle(material.keyManagers(), material.certificateChains()); + } + /** + * @param keyMgrs Key managers, or {@code null} to use the provider's defaults. + * @param chains Certificate chains by private-key alias, or {@code null} if unknown. + * @return Initialized context and its certificate metadata. + * @throws SSLException If trust managers or the context cannot be created. + */ + private SslBundle createSslBundle(@Nullable KeyManager[] keyMgrs, + @Nullable Map> chains) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -142,12 +167,12 @@ private SSLContext createSslContext() throws SSLException { sslParameters.setProtocols(protocols); } - if (sslParameters != null || material.chain != null || material.privateKeyMissing) - ctx = new SSLContextWrapper(ctx, sslParameters, material.chain, material.privateKeyMissing); + if (sslParameters != null) + ctx = new SSLContextWrapper(ctx, sslParameters); - ctx.init(material.keyMgrs, trustMgrs, null); + ctx.init(keyMgrs, trustMgrs, null); - return ctx; + return new SslBundle(ctx, chains); } catch (NoSuchAlgorithmException e) { throw new SSLException("Unsupported SSL protocol: " + proto, e); @@ -179,22 +204,11 @@ protected void checkNullParameter(Object param, String name) throws SSLException * * @return Created key managers. * @throws SSLException If this method is not overridden or key managers cannot be created. - * @deprecated Override {@link #createKeyMaterial()} to return key managers with their certificate chain. + * @deprecated Override {@link #createSslBundle()} to return a context with its certificate chains. */ @Deprecated protected KeyManager[] createKeyManagers() throws SSLException { - throw new SSLException("Subclass must override createKeyMaterial() or createKeyManagers()"); - } - - /** - * Creates key managers and their certificate metadata from the same load. New subclasses should override this method. - * The default adapts {@link #createKeyManagers()} for existing subclasses and leaves the certificate chain unknown. - * - * @return Loaded key material. - * @throws SSLException If the key material could not be loaded. - */ - protected KeyMaterial createKeyMaterial() throws SSLException { - return new KeyMaterial(createKeyManagers(), null); + throw new SSLException("Subclass must override createSslBundle() or createKeyManagers()"); } /** @@ -208,122 +222,12 @@ protected KeyMaterial createKeyMaterial() throws SSLException { SSLContext ctx = sslCtx.get(); if (ctx == null) { - try { - ctx = createSslContext(); + ctx = createSslBundle().context(); - if (!sslCtx.compareAndSet(null, ctx)) - ctx = sslCtx.get(); - } - catch (SSLException e) { - throw new IgniteException(e); - } + if (!sslCtx.compareAndSet(null, ctx)) + ctx = sslCtx.get(); } return ctx; } - - /** - * Builds a new SSL context from the current factory settings without reading or replacing the context cached by - * {@link #create()}. - *

- * A subclass that builds its context in its own {@link #create()} must override this method too and build the context from - * the same sources. This implementation calls {@link #checkParameters()}, {@link #createKeyMaterial()} and - * {@link #createTrustManagers()}; it does not call {@link #create()}. - *

- * A subclass that only changes the key source can override {@link #createKeyMaterial()} instead. When building a context - * through another mechanism, attach its loaded certificate chain, if known, using - * {@link SSLContextWrapper#SSLContextWrapper(SSLContext, SSLParameters, X509Certificate[])}. - * - * @return New initialized SSL context. - * @throws IgniteException If the context could not be created. - */ - public SSLContext createNew() { - try { - return createSslContext(); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } - - /** Key managers and the certificate chain loaded with them. */ - protected static final class KeyMaterial { - /** */ - private final KeyManager[] keyMgrs; - - /** */ - private final X509Certificate[] chain; - - /** Whether the loaded store is known to contain no private key. */ - private final boolean privateKeyMissing; - - /** - * @param keyMgrs Key managers, or {@code null} to use the provider's defaults. - * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. - */ - public KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] chain) { - this(keyMgrs, chain, false); - } - - /** - * @param keyMgrs Key managers. - * @param chain Certificate chain, if known. - * @param privateKeyMissing Whether the loaded store contains no private key. - */ - private KeyMaterial(@Nullable KeyManager[] keyMgrs, @Nullable X509Certificate[] chain, boolean privateKeyMissing) { - this.keyMgrs = keyMgrs == null ? null : keyMgrs.clone(); - this.chain = chain == null || chain.length == 0 ? null : chain.clone(); - this.privateKeyMissing = privateKeyMissing; - } - - /** @return Key managers, or {@code null} to use the provider's defaults. */ - public @Nullable KeyManager[] keyManagers() { - return keyMgrs == null ? null : keyMgrs.clone(); - } - - /** - * Takes the chain from the store used to create the key managers. A store with one private key and an X.509 chain - * supplies that chain. More than one private key, or a chain containing a non-X.509 certificate, leaves the chain - * unknown. A store without a private key records that fact separately from an unknown chain. - * - * @param keyMgrs Key managers initialized from the store. - * @param store Already loaded key store. - * @return Key managers and the chain of the single private key, if known. - * @throws KeyStoreException If the store cannot be read. - */ - public static KeyMaterial fromKeyStore(KeyManager[] keyMgrs, KeyStore store) throws KeyStoreException { - String keyAlias = null; - - for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { - String alias = aliases.nextElement(); - - if (!store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) - continue; - - if (keyAlias != null) - return new KeyMaterial(keyMgrs, null); - - keyAlias = alias; - } - - if (keyAlias == null) - return new KeyMaterial(keyMgrs, null, true); - - Certificate[] certs = store.getCertificateChain(keyAlias); - - if (certs == null || certs.length == 0) - return new KeyMaterial(keyMgrs, null); - - X509Certificate[] chain = new X509Certificate[certs.length]; - - for (int i = 0; i < certs.length; i++) { - if (!(certs[i] instanceof X509Certificate)) - return new KeyMaterial(keyMgrs, null); - - chain[i] = (X509Certificate)certs[i]; - } - - return new KeyMaterial(keyMgrs, chain); - } - } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java index 193aa6c6a2013..767f51d6b01c1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/DelegatingSSLContextSpi.java @@ -51,13 +51,12 @@ class DelegatingSSLContextSpi extends SSLContextSpi { /** {@inheritDoc} */ @Override protected SSLSocketFactory engineGetSocketFactory() { - return parameters == null ? delegate.getSocketFactory() : new SSLSocketFactoryWrapper(delegate.getSocketFactory(), parameters); + return new SSLSocketFactoryWrapper(delegate.getSocketFactory(), parameters); } /** {@inheritDoc} */ @Override protected SSLServerSocketFactory engineGetServerSocketFactory() { - return parameters == null ? delegate.getServerSocketFactory() - : new SSLServerSocketFactoryWrapper(delegate.getServerSocketFactory(), parameters); + return new SSLServerSocketFactoryWrapper(delegate.getServerSocketFactory(), parameters); } /** {@inheritDoc} */ diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java new file mode 100644 index 0000000000000..a07796e8fe2de --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -0,0 +1,127 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLException; +import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; + +/** + * A loaded key store and the settings needed to create its key managers. Both managers and certificate metadata are read from + * this store without loading it again. The caller must not modify the store while building a context and its metadata. + */ +public final class KeyMaterial { + /** */ + private final KeyStore store; + + /** */ + private final char[] keyPwd; + + /** */ + private final String algorithm; + + /** + * @param store Loaded key store. + * @param keyPwd Password for its private keys, or {@code null} if the store requires no password. + * @param algorithm Key manager algorithm. + */ + public KeyMaterial(KeyStore store, @Nullable char[] keyPwd, String algorithm) { + A.notNull(store, "store", algorithm, "algorithm"); + + this.store = store; + this.keyPwd = keyPwd == null ? null : keyPwd.clone(); + this.algorithm = algorithm; + } + + /** + * @return Key managers initialized from the loaded store. + * @throws SSLException If the algorithm is unavailable or the keys cannot be read. + */ + public KeyManager[] keyManagers() throws SSLException { + try { + KeyManagerFactory factory = KeyManagerFactory.getInstance(algorithm); + + factory.init(store, keyPwd); + + return factory.getKeyManagers(); + } + catch (NoSuchAlgorithmException e) { + throw new SSLException("Unsupported key algorithm: " + algorithm, e); + } + catch (GeneralSecurityException e) { + throw new SSLException("Failed to initialize Key Manager (security exception occurred)", e); + } + } + + /** + * @return Immutable X.509 chains by private-key alias, own certificate first. An empty map means there are no private keys. + * An empty list retains an alias whose chain is absent or contains a non-X.509 certificate. + * @throws SSLException If the loaded store cannot be read. + */ + public Map> certificateChains() throws SSLException { + try { + Map> chains = new TreeMap<>(); + + for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { + String alias = aliases.nextElement(); + + if (store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + chains.put(alias, certificateChain(store.getCertificateChain(alias))); + } + + return Collections.unmodifiableMap(chains); + } + catch (KeyStoreException e) { + throw new SSLException("Failed to read certificate chains from key store", e); + } + } + + /** + * @param certs Chain from the store, if present. + * @return Immutable X.509 chain, or an empty list if it is absent or contains another certificate type. + */ + private static List certificateChain(@Nullable Certificate[] certs) { + if (certs == null) + return Collections.emptyList(); + + List chain = new ArrayList<>(certs.length); + + for (Certificate cert : certs) { + if (!(cert instanceof X509Certificate)) + return Collections.emptyList(); + + chain.add((X509Certificate)cert); + } + + return Collections.unmodifiableList(chain); + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java index af182ac8435cc..fb92fdd08d320 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java @@ -17,75 +17,20 @@ package org.apache.ignite.ssl; -import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; -import org.jetbrains.annotations.Nullable; /** - * Wrapper for {@link SSLContext} with custom SSL parameters and the certificate chain loaded with the context. + * Wrapper for {@link SSLContext} that extends the source context with custom SSL parameters. */ public class SSLContextWrapper extends SSLContext { - /** Chain loaded with the delegate, or {@code null} if unknown. */ - private final X509Certificate[] chain; - - /** Whether the loaded key material is known to contain no private key. */ - private final boolean privateKeyMissing; - /** - * Wraps a context with custom SSL parameters. If the delegate is an {@code SSLContextWrapper}, copies its - * certificate chain and missing-private-key flag. - * * @param delegate Wrapped SSL context. * @param sslParameters Extended SSL parameters. */ public SSLContextWrapper(SSLContext delegate, SSLParameters sslParameters) { - this(delegate, sslParameters, - delegate instanceof SSLContextWrapper ? ((SSLContextWrapper)delegate).getCertificateChain() : null, - delegate instanceof SSLContextWrapper && ((SSLContextWrapper)delegate).isPrivateKeyMissing()); - } - - /** - * Wraps a context with its loaded certificate chain. The chain describes the key material, not the outcome of a TLS - * handshake. The delegate must not be reinitialized with different key material after wrapping. - * This constructor uses the supplied chain and sets the missing-private-key flag to {@code false}. - * It does not copy metadata from the delegate. - * - * @param delegate Wrapped SSL context. - * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. - * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. - */ - public SSLContextWrapper(SSLContext delegate, @Nullable SSLParameters sslParameters, @Nullable X509Certificate[] chain) { - this(delegate, sslParameters, chain, false); - } - - /** - * Wraps a context with its loaded certificate chain and records whether that key material has no private key. - * Both metadata values come from the arguments, even if the delegate is an {@code SSLContextWrapper}. - * - * @param delegate Wrapped SSL context. - * @param sslParameters Extended SSL parameters, or {@code null} to keep the delegate's parameters. - * @param chain Chain of the single private key, own certificate first, or {@code null} if unknown. - * @param privateKeyMissing {@code true} only if the loaded material is known to contain no private key. Do not pass - * {@code true} just because the chain is unknown: the material may contain several keys or use custom key managers. - */ - public SSLContextWrapper(SSLContext delegate, @Nullable SSLParameters sslParameters, @Nullable X509Certificate[] chain, - boolean privateKeyMissing) { super(new DelegatingSSLContextSpi(delegate, sslParameters), delegate.getProvider(), delegate.getProtocol()); - - this.chain = chain == null || chain.length == 0 ? null : chain.clone(); - this.privateKeyMissing = privateKeyMissing; - } - - /** @return Chain loaded with the context, own certificate first, or {@code null} if unknown. */ - public @Nullable X509Certificate[] getCertificateChain() { - return chain == null ? null : chain.clone(); - } - - /** @return {@code true} only if the loaded material is known to contain no private key. */ - public boolean isPrivateKeyMissing() { - return privateKeyMissing; } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java new file mode 100644 index 0000000000000..e328dfeb176cc --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -0,0 +1,74 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; + +/** + * An initialized SSL context and a snapshot of the certificate chains loaded with it. The chains describe its key material, + * not the certificate selected for a particular connection. Do not reinitialize the context after creating the bundle. + */ +public final class SslBundle { + /** */ + private final SSLContext ctx; + + /** */ + private final Map> chains; + + /** + * @param ctx Initialized SSL context. + * @param chains Chains by private-key alias, own certificate first. {@code null} means the aliases are unknown; + * an empty map means there are no private keys. An empty list means that alias has a private key but its X.509 chain + * is unknown. Trusted certificate entries and secret keys must not be included. + */ + public SslBundle(SSLContext ctx, @Nullable Map> chains) { + A.notNull(ctx, "ctx"); + + this.ctx = ctx; + + if (chains == null) + this.chains = null; + else { + Map> copy = new TreeMap<>(); + + chains.forEach((alias, chain) -> copy.put(alias, List.copyOf(chain))); + + this.chains = Collections.unmodifiableMap(copy); + } + } + + /** @return Initialized context. */ + public SSLContext context() { + return ctx; + } + + /** + * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private keys; + * an empty list means that alias has a private key with an unknown X.509 chain. + */ + public @Nullable Map> certificateChains() { + return chains; + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 05940885e1224..1ca71f50468be 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -28,12 +28,12 @@ import java.security.cert.X509Certificate; import java.util.Arrays; import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; /** @@ -261,33 +261,35 @@ public static TrustManager getDisabledTrustManager() { /** * {@inheritDoc} * - * @deprecated Use {@link #createKeyMaterial()} to retain certificate metadata. + * @deprecated Use {@link #createSslBundle()} to obtain a context with its certificate chains. */ @Deprecated @Override protected final KeyManager[] createKeyManagers() throws SSLException { - return createKeyMaterial().keyManagers(); + return loadKeyMaterial().keyManagers(); } /** {@inheritDoc} */ - @Override protected final KeyMaterial createKeyMaterial() throws SSLException { + @Override public SslBundle createSslBundle() { try { - KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + checkParameters(); - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - keyMgrFactory.init(keyStore, keyStorePwd); - - return KeyMaterial.fromKeyStore(keyMgrFactory.getKeyManagers(), keyStore); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); + return createSslBundle(loadKeyMaterial()); } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred) [type=" + - keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + catch (SSLException e) { + throw new IgniteException(e); } } + /** + * Loads the key store once for both key managers and certificate metadata. + * + * @return Loaded store with its key password and manager algorithm. + * @throws SSLException If the store cannot be loaded. + */ + protected KeyMaterial loadKeyMaterial() throws SSLException { + return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); + } + /** {@inheritDoc} */ @Override protected final TrustManager[] createTrustManagers() throws SSLException { if (trustMgrs != null) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 408df05840862..96e3842c8dc9e 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -18,15 +18,21 @@ package org.apache.ignite.ssl; import java.security.KeyStore; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; import javax.crypto.KeyGenerator; 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; -import javax.net.ssl.SSLServerSocket; -import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; @@ -38,7 +44,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNotEquals; -/** Tests that certificate metadata belongs to the key material used to create a context. */ +/** Tests that a bundle's certificate metadata belongs to the key material used to create its context. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Store and key password of the generated test material. */ private static final char[] PASSWORD = TestCertificateAuthority.PWD; @@ -51,40 +57,46 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** A fresh context loads the replacement store once and leaves the cached context's certificate metadata unchanged. */ + /** Each bundle loads its store once; fresh bundles do not replace the context cached by the legacy create method. */ @Test - public void testFreshContextUsesReplacementStoreAndPreservesCachedContext() throws Exception { - KeyStore originalKeys = ca.keyStoreWithPrivateKey(); - X509Certificate[] originalChain = certificateChain(originalKeys); + public void testFreshBundlePreservesCachedContext() throws Exception { + KeyStore originalKeys = ca.keyStoreWithPrivateKey("original"); + List originalChain = Arrays.asList(certificateChain(originalKeys)); InMemoryStoreFactory factory = new InMemoryStoreFactory(originalKeys); + SslBundle original = factory.createSslBundle(); + + assertEquals(Map.of("original", originalChain), original.certificateChains()); + assertEquals(1, factory.loadKeyStoreCalls); + assertEquals(1, factory.parameterChecks); SSLContext cached = factory.create(); - assertEquals(1, factory.loadKeyStoreCalls); - assertArrayEquals(originalChain, reportedChain(cached)); + assertSame(cached, factory.create()); + assertEquals(2, factory.loadKeyStoreCalls); + assertEquals(2, factory.parameterChecks); - KeyStore replacementKeys = ca.keyStoreWithPrivateKey(); - X509Certificate[] replacementChain = certificateChain(replacementKeys); + KeyStore replacementKeys = ca.keyStoreWithPrivateKey("replacement"); + List replacementChain = Arrays.asList(certificateChain(replacementKeys)); - assertNotEquals(originalChain[0], replacementChain[0]); + assertNotEquals(originalChain.get(0), replacementChain.get(0)); factory.store = replacementKeys; - SSLContext fresh = factory.createNew(); - - assertNotSame(cached, fresh); - assertEquals(2, factory.loadKeyStoreCalls); - assertArrayEquals(replacementChain, reportedChain(fresh)); + SslBundle fresh = factory.createSslBundle(); + assertNotSame(cached, fresh.context()); + assertNotSame(original.context(), fresh.context()); + assertEquals(Map.of("replacement", replacementChain), fresh.certificateChains()); + assertEquals(Map.of("original", originalChain), original.certificateChains()); assertSame(cached, factory.create()); - assertArrayEquals(originalChain, reportedChain(cached)); - assertEquals("Returning the cached context must not load the store again", 2, factory.loadKeyStoreCalls); + assertEquals(3, factory.loadKeyStoreCalls); + assertEquals(3, factory.parameterChecks); } - /** Both factory methods retain certificate metadata when they also apply TLS parameters. */ + /** TLS parameters apply to the context while its metadata remains available separately. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey(); + KeyStore store = ca.keyStoreWithPrivateKey("node"); InMemoryStoreFactory factory = new InMemoryStoreFactory(store); String[] protocols = {"TLSv1.2"}; String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; @@ -92,33 +104,36 @@ public void testMetadataWithTlsParameters() throws Exception { factory.setProtocols(protocols); factory.setCipherSuites(ciphers); - for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertArrayEquals(certificateChain(store), reportedChain(ctx)); - assertFalse("The loaded store contains a private key", reportsMissingPrivateKey(ctx)); + SslBundle bundle = factory.createSslBundle(); - SSLEngine engine = ctx.createSSLEngine(); + assertEquals(Map.of("node", Arrays.asList(certificateChain(store))), bundle.certificateChains()); + + for (SSLContext ctx : new SSLContext[] {bundle.context(), factory.create()}) { + SSLEngine engine = ctx.createSSLEngine("peer.example", 10800); assertArrayEquals(protocols, engine.getEnabledProtocols()); assertArrayEquals(ciphers, engine.getEnabledCipherSuites()); + assertEquals("peer.example", engine.getPeerHost()); + assertEquals(10800, engine.getPeerPort()); } } - /** Multiple private keys leave the chain unknown but are not reported as missing. */ + /** Every private-key alias retains its own chain. */ @Test - public void testMultiplePrivateKeysHaveUnknownChain() throws Exception { + public void testMultiplePrivateKeysRetainAllChains() throws Exception { String firstKey = "first-key"; String secondKey = "second-key"; KeyStore store = ca.keyStoreWithPrivateKey(firstKey); KeyStore other = ca.keyStoreWithPrivateKey(secondKey); + List firstChain = Arrays.asList(certificateChain(store)); + List secondChain = Arrays.asList(certificateChain(other)); store.setKeyEntry(secondKey, other.getKey(secondKey, PASSWORD), PASSWORD, other.getCertificateChain(secondKey)); - InMemoryStoreFactory factory = new InMemoryStoreFactory(store); + SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); - for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertNull(reportedChain(ctx)); - assertFalse("Two private keys are present even though the served chain is unknown", reportsMissingPrivateKey(ctx)); - } + assertEquals(Map.of(firstKey, firstChain, secondKey, secondChain), bundle.certificateChains()); + assertEquals(List.of(firstKey, secondKey), new ArrayList<>(bundle.certificateChains().keySet())); } /** Trusted certificates and secret keys do not count as private keys. */ @@ -136,69 +151,97 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { new KeyStore.PasswordProtection(PASSWORD)); InMemoryStoreFactory factory = new InMemoryStoreFactory(store); + SslBundle withoutKey = factory.createSslBundle(); - SSLContext withoutKey = factory.create(); - - assertNull(reportedChain(withoutKey)); - assertTrue("Neither the trusted certificate nor the secret key is a private key", reportsMissingPrivateKey(withoutKey)); + assertEquals(Map.of(), withoutKey.certificateChains()); + assertNotNull(withoutKey.context().getSocketFactory()); store.setKeyEntry(keyAlias, keys.getKey(keyAlias, PASSWORD), PASSWORD, keys.getCertificateChain(keyAlias)); - SSLContext withKey = factory.createNew(); + SslBundle withKey = factory.createSslBundle(); - assertArrayEquals(certificateChain(keys), reportedChain(withKey)); - assertFalse("The store now contains a private-key entry", reportsMissingPrivateKey(withKey)); + assertEquals(Map.of(keyAlias, Arrays.asList(certificateChain(keys))), withKey.certificateChains()); + assertEquals("The earlier bundle remains keyless", Map.of(), withoutKey.certificateChains()); } - /** Trust-only contexts can still be built for clients, and another parameters wrapper preserves the missing-key marker. */ + /** A private key with a non-X.509 chain is retained as an alias with unknown certificate metadata. */ @Test - public void testTrustOnlyContextCreation() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.trustStore()); - String[] protocols = {"TLSv1.2"}; - SSLParameters params = new SSLParameters(); + public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { + String alias = "private-key"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); + X509Certificate cert = (X509Certificate)store.getCertificate(alias); + Certificate otherType = new Certificate("test-certificate") { + /** {@inheritDoc} */ + @Override public byte[] getEncoded() throws CertificateEncodingException { + return cert.getEncoded(); + } + + /** {@inheritDoc} */ + @Override public void verify(PublicKey key) { + throw new UnsupportedOperationException(); + } + + /** {@inheritDoc} */ + @Override public void verify(PublicKey key, String provider) { + throw new UnsupportedOperationException(); + } - params.setProtocols(protocols); + /** {@inheritDoc} */ + @Override public PublicKey getPublicKey() { + return cert.getPublicKey(); + } - for (SSLContext ctx : new SSLContext[] {factory.create(), factory.createNew()}) { - assertNull(reportedChain(ctx)); - assertTrue(((SSLContextWrapper)ctx).isPrivateKeyMissing()); + /** {@inheritDoc} */ + @Override public String toString() { + return "Test certificate with a non-X.509 type"; + } + }; - SSLContextWrapper outer = new SSLContextWrapper(ctx, params); + store.setKeyEntry(alias, store.getKey(alias, PASSWORD), PASSWORD, new Certificate[] {otherType}); - assertTrue(outer.isPrivateKeyMissing()); - assertArrayEquals(protocols, outer.createSSLEngine().getEnabledProtocols()); - } + KeyMaterial material = new KeyMaterial(store, PASSWORD, SslContextFactory.DFLT_KEY_ALGORITHM); + + assertEquals(Map.of(alias, List.of()), material.certificateChains()); } - /** Legacy hooks run for each new context, but not when create() returns its cached context. */ + /** Legacy hooks run once per new context and leave metadata unknown rather than reporting no private keys. */ @Test public void testLegacyHooksRunOncePerBuiltContext() { LegacyFactory factory = new LegacyFactory(); - SSLContext first = factory.create(); - assertNull(reportedChain(first)); - assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(first)); - assertEquals(1, factory.parameterChecks); - assertEquals(1, factory.keyManagerCalls); - assertEquals(1, factory.trustManagerCalls); - assertSame(first, factory.create()); - assertEquals(1, factory.parameterChecks); assertEquals(1, factory.keyManagerCalls); assertEquals(1, factory.trustManagerCalls); - SSLContext next = factory.createNew(); + SslBundle next = factory.createSslBundle(); - assertNotSame(first, next); - assertNull(reportedChain(next)); - assertFalse("Legacy hooks do not report whether a private key is available", reportsMissingPrivateKey(next)); + assertNotSame(first, next.context()); + assertNull(next.certificateChains()); + assertSame(first, factory.create()); assertEquals(2, factory.parameterChecks); assertEquals(2, factory.keyManagerCalls); assertEquals(2, factory.trustManagerCalls); } + /** Existing subclasses can still populate the protected cache before the first create call. */ + @Test + public void testLegacyProtectedCache() throws Exception { + LegacyFactory factory = new LegacyFactory(); + SSLContext prepared = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); + + factory.sslCtx.set(prepared); + + assertSame(prepared, factory.create()); + assertEquals(0, factory.parameterChecks); + assertEquals(0, factory.keyManagerCalls); + assertEquals(0, factory.trustManagerCalls); + + assertNotSame(prepared, factory.createSslBundle().context()); + assertSame(prepared, factory.create()); + } + /** A subclass can still obtain key managers from the file factory's legacy hook. */ @Test public void testLegacyKeyManagerAdapter() throws Exception { @@ -210,7 +253,21 @@ public void testLegacyKeyManagerAdapter() throws Exception { assertEquals(1, factory.loadKeyStoreCalls); } - /** A factory that implements neither key-material hook reports the missing implementation. */ + /** Invalid parameters are reported before loading the store. */ + @Test + public void testInvalidParametersDoNotLoadStore() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + + factory.setTrustManagers((TrustManager[])null); + + GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, + IgniteException.class, "trustManagers must be provided"); + + assertEquals(1, factory.parameterChecks); + assertEquals(0, factory.loadKeyStoreCalls); + } + + /** A factory implementing neither a bundle builder nor the legacy key-manager hook reports the missing implementation. */ @Test public void testMissingKeyMaterialImplementation() { AbstractSslContextFactory factory = new AbstractSslContextFactory() { @@ -225,42 +282,42 @@ public void testMissingKeyMaterialImplementation() { } }; - String msg = "Subclass must override createKeyMaterial() or createKeyManagers()"; + String msg = "Subclass must override createSslBundle() or createKeyManagers()"; Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, msg); assertTrue(err.getCause() instanceof SSLException); - err = GridTestUtils.assertThrows(log, factory::createNew, IgniteException.class, msg); + err = GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, IgniteException.class, msg); assertTrue(err.getCause() instanceof SSLException); } - /** A subclass can supply material directly, and callers cannot replace its recorded managers or chain through arrays. */ + /** A custom store source does not need the legacy key-manager hook and keeps its own copy of the key password. */ @Test - public void testKeyMaterialDefensiveCopies() throws Exception { - KeyStore keys = ca.keyStoreWithPrivateKey(); - KeyManager[] mgrs = TestCertificateAuthority.keyManagers(keys); - KeyManager mgr = mgrs[0]; - X509Certificate[] expected = certificateChain(keys); - X509Certificate[] input = expected.clone(); - AbstractSslContextFactory.KeyMaterial material = new AbstractSslContextFactory.KeyMaterial(mgrs, input); - - mgrs[0] = null; - input[0] = null; - material.keyManagers()[0] = null; + public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { + KeyStore store = ca.keyStoreWithPrivateKey("node"); + char[] pwd = PASSWORD.clone(); + KeyMaterial material = new KeyMaterial(store, pwd, SslContextFactory.DFLT_KEY_ALGORITHM); - assertSame(mgr, material.keyManagers()[0]); + Arrays.fill(pwd, '?'); AbstractSslContextFactory factory = new AbstractSslContextFactory() { /** {@inheritDoc} */ - @Override protected void checkParameters() { - // No parameters are required by this factory. + @Override public SslBundle createSslBundle() { + try { + checkParameters(); + + return createSslBundle(material); + } + catch (SSLException e) { + throw new IgniteException(e); + } } /** {@inheritDoc} */ - @Override protected KeyMaterial createKeyMaterial() { - return material; + @Override protected void checkParameters() { + // The test supplies already loaded material. } /** {@inheritDoc} */ @@ -269,104 +326,34 @@ public void testKeyMaterialDefensiveCopies() throws Exception { } }; - assertArrayEquals(expected, reportedChain(factory.create())); - assertArrayEquals(expected, reportedChain(factory.createNew())); + assertEquals(Map.of("node", Arrays.asList(certificateChain(store))), factory.createSslBundle().certificateChains()); } - /** Neither callers nor another parameters wrapper can replace a context's recorded chain. */ + /** Mutating the source map, its lists or the store cannot change an existing bundle. */ @Test - public void testWrapperPreservesAndCopiesChain() throws Exception { - X509Certificate[] expected = certificateChain(ca.keyStoreWithPrivateKey()); - X509Certificate[] input = expected.clone(); - SSLContext delegate = SSLContext.getInstance("TLS"); + public void testImmutableCertificateMetadata() throws Exception { + KeyStore store = ca.keyStoreWithPrivateKey("node"); + SslBundle loaded = new InMemoryStoreFactory(store).createSslBundle(); + List expected = Arrays.asList(certificateChain(store)); + List inputChain = new ArrayList<>(expected); + Map> input = new HashMap<>(); - delegate.init(null, null, null); + input.put("node", inputChain); - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, input); + SslBundle bundle = new SslBundle(loaded.context(), input); - input[0] = null; - wrapper.getCertificateChain()[0] = null; + inputChain.clear(); + input.clear(); + store.deleteEntry("node"); - assertArrayEquals(expected, wrapper.getCertificateChain()); + assertEquals(Map.of("node", expected), loaded.certificateChains()); + assertEquals(Map.of("node", expected), bundle.certificateChains()); + assertSame(loaded.context(), bundle.context()); - String[] protocols = {"TLSv1.2"}; - SSLParameters params = new SSLParameters(); - - params.setProtocols(protocols); - - SSLContextWrapper outer = new SSLContextWrapper(wrapper, params); - - assertArrayEquals(expected, outer.getCertificateChain()); - assertArrayEquals(protocols, outer.createSSLEngine().getEnabledProtocols()); - } - - /** Explicit metadata takes precedence over the wrapped context's metadata. */ - @Test - public void testExplicitWrapperMetadataOverridesDelegateMetadata() throws Exception { - X509Certificate[] expectedChain = certificateChain(ca.keyStoreWithPrivateKey()); - SSLContext delegate = SSLContext.getInstance("TLS"); - - delegate.init(null, null, null); - - SSLContextWrapper keyless = new SSLContextWrapper(delegate, null, null, true); - SSLContextWrapper withChain = new SSLContextWrapper(keyless, null, expectedChain); - - assertArrayEquals(expectedChain, withChain.getCertificateChain()); - assertFalse(withChain.isPrivateKeyMissing()); - - SSLContextWrapper unknownChain = new SSLContextWrapper(withChain, null, null); - - assertNull(unknownChain.getCertificateChain()); - assertFalse(unknownChain.isPrivateKeyMissing()); - - SSLContextWrapper explicitlyKeyless = new SSLContextWrapper(withChain, null, null, true); - - assertNull(explicitlyKeyless.getCertificateChain()); - assertTrue(explicitlyKeyless.isPrivateKeyMissing()); - } - - /** Adding metadata without TLS parameters preserves peer addresses, TLS defaults and unconnected socket creation. */ - @Test - public void testMetadataWrapperPreservesContextBehavior() throws Exception { - SSLContext delegate = SSLContext.getInstance("TLS"); - - delegate.init(null, null, null); - - SSLContextWrapper wrapper = new SSLContextWrapper(delegate, null, certificateChain(ca.keyStoreWithPrivateKey())); - String host = "peer.example"; - int port = 10800; - SSLEngine engine = wrapper.createSSLEngine(host, port); - SSLEngine delegateEngine = delegate.createSSLEngine(); - - assertEquals(host, engine.getPeerHost()); - assertEquals(port, engine.getPeerPort()); - assertArrayEquals(delegateEngine.getEnabledProtocols(), engine.getEnabledProtocols()); - assertArrayEquals(delegateEngine.getEnabledCipherSuites(), engine.getEnabledCipherSuites()); - - try (SSLSocket expected = (SSLSocket)delegate.getSocketFactory().createSocket(); - SSLSocket actual = (SSLSocket)wrapper.getSocketFactory().createSocket()) { - assertFalse(actual.isConnected()); - assertFalse(actual.isBound()); - assertArrayEquals(expected.getEnabledProtocols(), actual.getEnabledProtocols()); - assertArrayEquals(expected.getEnabledCipherSuites(), actual.getEnabledCipherSuites()); - } - - try (SSLServerSocket expected = (SSLServerSocket)delegate.getServerSocketFactory().createServerSocket(); - SSLServerSocket actual = (SSLServerSocket)wrapper.getServerSocketFactory().createServerSocket()) { - assertFalse(actual.isBound()); - assertArrayEquals(expected.getEnabledProtocols(), actual.getEnabledProtocols()); - assertArrayEquals(expected.getEnabledCipherSuites(), actual.getEnabledCipherSuites()); - } - } - - /** @return Chain attached to the context, if known. */ - private static X509Certificate[] reportedChain(SSLContext ctx) { - return ctx instanceof SSLContextWrapper ? ((SSLContextWrapper)ctx).getCertificateChain() : null; - } - - /** @return Whether the context reports that no private key was loaded. Unknown metadata does not set this marker. */ - private static boolean reportsMissingPrivateKey(SSLContext ctx) { - return ctx instanceof SSLContextWrapper && ((SSLContextWrapper)ctx).isPrivateKeyMissing(); + GridTestUtils.assertThrows(log, () -> bundle.certificateChains().put("other", List.of()), + UnsupportedOperationException.class, null); + GridTestUtils.assertThrows(log, () -> bundle.certificateChains().get("node").set(0, expected.get(1)), + UnsupportedOperationException.class, null); } /** A file factory whose existing store-loading hook supplies the test material. */ @@ -377,6 +364,9 @@ private static class InMemoryStoreFactory extends SslContextFactory { /** */ private int loadKeyStoreCalls; + /** */ + private int parameterChecks; + /** */ InMemoryStoreFactory(KeyStore store) { this.store = store; @@ -387,6 +377,13 @@ private static class InMemoryStoreFactory extends SslContextFactory { setTrustManagers(getDisabledTrustManager()); } + /** {@inheritDoc} */ + @Override protected void checkParameters() throws SSLException { + parameterChecks++; + + super.checkParameters(); + } + /** {@inheritDoc} */ @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { loadKeyStoreCalls++; @@ -401,7 +398,7 @@ private KeyManager[] legacyKeyManagers() throws SSLException { } } - /** A subclass that implements the manager hooks without overriding createKeyMaterial(). */ + /** A subclass that implements only the legacy manager hooks. */ private static class LegacyFactory extends AbstractSslContextFactory { /** */ private int parameterChecks; diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 29bcfd74aad48..c7d9b71788eda 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -38,7 +38,8 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.security.auth.x500.X500Principal; -import org.apache.ignite.ssl.SSLContextWrapper; +import org.apache.ignite.ssl.KeyMaterial; +import org.apache.ignite.ssl.SslBundle; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; @@ -169,14 +170,28 @@ public static KeyStore trustStore(TestCertificateAuthority... authorities) throw /** * @param keyStore Store with the certificate to present. * @param trustStore Store with the authorities to trust. - * @return Context using the supplied key and trust stores, with certificate metadata attached. + * @return Context using the supplied key and trust stores. */ public static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); - return new SSLContextWrapper(ctx, null, certificateChain(keyStore)); + return ctx; + } + + /** + * @param keyStore Store with the certificates to present. + * @param trustStore Store with the authorities to trust. + * @return Context and certificate chains from the supplied stores. + */ + public static SslBundle bundle(KeyStore keyStore, KeyStore trustStore) throws Exception { + KeyMaterial material = new KeyMaterial(keyStore, PWD, KeyManagerFactory.getDefaultAlgorithm()); + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(material.keyManagers(), trustManagers(trustStore), null); + + return new SslBundle(ctx, material.certificateChains()); } /** From be95316277d8426d59cc46cd31117933bf755e8d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:35:43 +0300 Subject: [PATCH 039/242] IGNITE-29209 Clarify fresh bundle creation contract --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index dbcac5803ed30..dccf4e9ec68d7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -113,8 +113,9 @@ public void setProtocols(String... protocols) { } /** - * Builds a fresh context with the legacy key-manager hook. Subclasses that load a key store can override this method - * and call {@link #createSslBundle(KeyMaterial)} to include its certificate chains. + * Builds a fresh context and its certificate metadata without calling {@link #create()} or changing its cached context. + * The default implementation calls the legacy key-manager hook and leaves certificate metadata unknown. Subclasses that + * load a key store can override this method and call {@link #createSslBundle(KeyMaterial)} to include its certificate chains. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. From c16d40e84960de147f7c3b1c982c2713a66d2a42 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:44:04 +0300 Subject: [PATCH 040/242] IGNITE-29210 Reload SSL bundles and report every private-key alias --- .../management/ssl/SslReloadTask.java | 4 +- .../management/ssl/SslStatusTask.java | 31 +++- .../ignite/internal/ssl/SslCertificates.java | 23 ++- .../internal/ssl/SslContextProvider.java | 109 ++++++----- .../internal/ssl/SslContextRegistry.java | 20 +- .../internal/ssl/SslContextProviderTest.java | 174 +++++++++++++----- .../ignite/ssl/SslContextReloadTest.java | 34 ++-- 7 files changed, 271 insertions(+), 124 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index e25af2cc1f1c9..644566725f455 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -32,7 +32,7 @@ import org.apache.ignite.lang.IgniteBiTuple; import org.apache.ignite.plugin.security.SecuritySubject; -import static org.apache.ignite.internal.ssl.SslCertificates.describe; +import static org.apache.ignite.internal.ssl.SslCertificates.describeChains; import static org.apache.ignite.internal.ssl.SslCertificates.reason; /** Reloads TLS certificates on every mapped node. */ @@ -86,7 +86,7 @@ private SslReloadJob(NoArg arg, boolean debug, UUID originNodeId) { String transports = provider.transports(); try { - String desc = describe(provider.reload()); + String desc = describeChains(provider.reload().certificateChains()); U.log(ignite.log(), "TLS certificates reloaded [transports=" + transports + (desc.isEmpty() ? "" : ", " + desc) + ", initiator=" + initiator + ']'); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 9ebc1ca910735..7970600f87af5 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.processors.task.GridInternal; @@ -74,20 +75,34 @@ private SslStatusJob(NoArg arg, boolean debug) { for (SslContextProvider provider : providers) { lines.add(id + ": " + provider.transports()); - X509Certificate[] chain = provider.certificateChain(); + Map> chains = provider.certificateChains(); - if (chain == null) + if (chains == null) lines.add(DOUBLE_INDENT + "certificate metadata is unavailable"); + else if (chains.isEmpty()) + lines.add(DOUBLE_INDENT + "no private keys"); else { - long chainNotAfter = chainNotAfter(chain); + for (Map.Entry> entry : chains.entrySet()) { + List chain = entry.getValue(); + String alias = "alias=" + entry.getKey(); - lines.add(DOUBLE_INDENT + "loaded " + describe(chain[0]) + - (chainNotAfter < chain[0].getNotAfter().getTime() ? ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) : "")); + if (chain.isEmpty()) { + lines.add(DOUBLE_INDENT + alias + ": certificate chain is unavailable"); - if (invalidAt(chain, System.currentTimeMillis()) != null) { - invalid = true; + continue; + } - lines.add(DOUBLE_INDENT + "PROBLEM: the loaded certificate is not valid now"); + X509Certificate leaf = chain.get(0); + long chainNotAfter = chainNotAfter(chain); + + lines.add(DOUBLE_INDENT + "loaded " + alias + ", " + describe(leaf) + + (chainNotAfter < leaf.getNotAfter().getTime() ? ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) : "")); + + if (invalidAt(chain, System.currentTimeMillis()) != null) { + invalid = true; + + lines.add(DOUBLE_INDENT + "PROBLEM: the loaded certificate is not valid now [" + alias + ']'); + } } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 63cfb031386c4..2d60984c012db 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -18,6 +18,9 @@ package org.apache.ignite.internal.ssl; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import org.apache.ignite.internal.util.typedef.X; import org.jetbrains.annotations.Nullable; @@ -32,7 +35,7 @@ private SslCertificates() { * @param chain Chain, own certificate first. * @return Earliest expiry in the chain, in milliseconds since the epoch. */ - public static long chainNotAfter(X509Certificate[] chain) { + public static long chainNotAfter(Iterable chain) { long res = Long.MAX_VALUE; for (X509Certificate cert : chain) @@ -46,7 +49,7 @@ public static long chainNotAfter(X509Certificate[] chain) { * @param time Time to check, in milliseconds since the epoch. * @return First certificate of the chain that is not valid at that time, {@code null} if all are. */ - public static @Nullable X509Certificate invalidAt(X509Certificate[] chain, long time) { + public static @Nullable X509Certificate invalidAt(Iterable chain, long time) { for (X509Certificate cert : chain) { if (time < cert.getNotBefore().getTime() || time > cert.getNotAfter().getTime()) return cert; @@ -65,6 +68,22 @@ public static String describe(@Nullable X509Certificate cert) { ", notAfter=" + cert.getNotAfter().toInstant(); } + /** + * @param chains Chains by private-key alias, or {@code null} when metadata is unavailable. + * @return Description of every alias and its leaf certificate, or an empty string if the aliases are unknown. + */ + public static String describeChains(@Nullable Map> chains) { + if (chains == null) + return ""; + + List descriptions = new ArrayList<>(); + + chains.forEach((alias, chain) -> descriptions.add("alias=" + alias + ", " + + (chain.isEmpty() ? "certificate chain is unavailable" : describe(chain.get(0))))); + + return String.join("; ", descriptions); + } + /** * @param e Failure. * @return Non-repeated messages from the exception and its causes, or {@link Throwable#toString()} if none are available. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 256995ae0c99a..7d34540350f0f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -19,13 +19,17 @@ import java.security.cert.X509Certificate; import java.time.Instant; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; +import java.util.function.Supplier; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.SSLContextWrapper; +import org.apache.ignite.ssl.SslBundle; +import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; import static org.apache.ignite.internal.ssl.SslCertificates.describe; @@ -38,8 +42,11 @@ public class SslContextProvider { /** */ private final Set transports = new ConcurrentSkipListSet<>(); - /** Context in use. */ - private volatile SSLContext ctx; + /** Builds fresh bundles, or invokes an older factory through its original create method. */ + private final Supplier source; + + /** Context and certificate metadata in use. */ + private volatile SslBundle bundle; /** Stable context handed to every transport using this factory. */ private final SSLContext sharedCtx; @@ -51,8 +58,9 @@ public class SslContextProvider { SslContextProvider(Factory factory) { this.factory = factory; - ctx = factory.create(); - ctx.createSSLEngine(); + source = bundleSource(factory); + bundle = source.get(); + bundle.context().createSSLEngine(); sharedCtx = new CurrentSslContext(this::context); } @@ -64,7 +72,7 @@ Factory factory() { /** @return Context to open the next connection with. */ SSLContext context() { - return ctx; + return bundle.context(); } /** @return Stable context shared by every transport configured with this factory. */ @@ -82,16 +90,37 @@ public String transports() { return String.join(", ", transports); } - /** @return Chain loaded with the context in use, own certificate first, or {@code null} if it is unknown. */ - public @Nullable X509Certificate[] certificateChain() { - return certificateChain(ctx); + /** @return Chains by private-key alias, or {@code null} when the factory supplies no metadata. */ + public @Nullable Map> certificateChains() { + return bundle.certificateChains(); } - /** @return Certificate loaded with the context in use, or {@code null} if it is unknown. */ - public @Nullable X509Certificate certificate() { - X509Certificate[] chain = certificateChain(ctx); + /** + * Custom factories opt into bundles by overriding createSslBundle(). Older subclasses keep create(), including a context + * placed directly in the protected cache. A more specific create() override must not be bypassed either. + * + * @param factory Configured factory. + * @return Source that preserves the factory's most specific creation method. + */ + private static Supplier bundleSource(Factory factory) { + if (factory.getClass() == SslContextFactory.class) + return ((SslContextFactory)factory)::createSslBundle; + + if (factory instanceof AbstractSslContextFactory f) { + try { + Class createOwner = f.getClass().getMethod("create").getDeclaringClass(); + Class bundleOwner = f.getClass().getMethod("createSslBundle").getDeclaringClass(); + + if (bundleOwner != AbstractSslContextFactory.class && bundleOwner != SslContextFactory.class && + createOwner.isAssignableFrom(bundleOwner)) + return f::createSslBundle; + } + catch (NoSuchMethodException | SecurityException ignored) { + // Retain the original create() path when the creation methods cannot be inspected. + } + } - return chain == null ? null : chain[0]; + return () -> new SslBundle(factory.create(), null); } /** @@ -99,23 +128,21 @@ public String transports() { * private key. If a certificate chain is supplied, every certificate must be within its validity period. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * - * @return Certificate installed by this reload, or {@code null} if its metadata is unavailable. + * @return Context and certificate metadata installed by this reload. * @throws IgniteCheckedException If the new context fails a check or the factory returns the context in use. On any failure, * including a factory exception, the current context is retained. */ - public synchronized @Nullable X509Certificate reload() throws IgniteCheckedException { + public synchronized SslBundle reload() throws IgniteCheckedException { try { - SSLContext rebuilt = build(); + SslBundle rebuilt = build(); - if (rebuilt == ctx) + if (rebuilt.context() == bundle.context()) throw new IgniteCheckedException("The SSL context factory returns the context in use, so the node gets no new " + "certificates from it"); - X509Certificate[] next = certificateChain(rebuilt); - - check(next); + check(rebuilt.certificateChains()); - ctx = rebuilt; + bundle = rebuilt; } catch (Throwable e) { onFailure(e); @@ -125,44 +152,42 @@ public String transports() { onSuccess(); - return certificate(); + return bundle; } /** - * @param ctx Context just built by the factory. - * @return Chain supplied with that context, or {@code null} if the factory supplies no metadata. + * @return Bundle built anew, or the context in use if an older factory caches it. + * @throws IgniteCheckedException If the loaded material is known to have no private key. */ - static @Nullable X509Certificate[] certificateChain(SSLContext ctx) { - return ctx instanceof SSLContextWrapper wrapper ? wrapper.getCertificateChain() : null; - } + private SslBundle build() throws IgniteCheckedException { + SslBundle rebuilt = source.get(); + Map> chains = rebuilt.certificateChains(); - /** - * @return Context built anew, or the one in use if the factory caches it. - * @throws IgniteCheckedException If the factory reports that the loaded material has no private key. - */ - private SSLContext build() throws IgniteCheckedException { - SSLContext rebuilt = factory instanceof AbstractSslContextFactory f ? f.createNew() : factory.create(); - - if (rebuilt instanceof SSLContextWrapper wrapper && wrapper.isPrivateKeyMissing()) + if (chains != null && chains.isEmpty()) throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); - rebuilt.createSSLEngine(); + rebuilt.context().createSSLEngine(); return rebuilt; } /** - * @param next Chain loaded with the new context, {@code null} if it is unknown. + * @param next Chains loaded with the new context, {@code null} if they are unknown. * @throws IgniteCheckedException If a certificate is outside its validity period. */ - private void check(@Nullable X509Certificate[] next) throws IgniteCheckedException { + private void check(@Nullable Map> next) throws IgniteCheckedException { + if (next == null) + return; + long now = System.currentTimeMillis(); - X509Certificate invalid = next == null ? null : SslCertificates.invalidAt(next, now); + for (Map.Entry> entry : next.entrySet()) { + X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), now); - if (invalid != null) { - throw new IgniteCheckedException("The new certificate chain is not valid now [" + describe(invalid) + ", now=" + - Instant.ofEpochMilli(now) + ']'); + if (invalid != null) { + throw new IgniteCheckedException("The new certificate chain is not valid now [alias=" + entry.getKey() + ", " + + describe(invalid) + ", now=" + Instant.ofEpochMilli(now) + ']'); + } } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 7741509f8a2c8..55c72f0350b4f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -20,6 +20,8 @@ import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -73,13 +75,19 @@ public synchronized SSLContext register(Factory factory, String tran /** @param provider Newly registered provider. */ private void warnIfExpired(SslContextProvider provider) { - X509Certificate[] chain = provider.certificateChain(); - X509Certificate invalid = chain == null ? null : SslCertificates.invalidAt(chain, System.currentTimeMillis()); + Map> chains = provider.certificateChains(); - if (invalid != null) { - U.warn(log, "The TLS certificate chain in use is not valid now on " + - provider.transports() + "; put a valid certificate in place and run control.sh --ssl reload [" + - SslCertificates.describe(invalid) + ']'); + if (chains == null) + return; + + for (Map.Entry> entry : chains.entrySet()) { + X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), System.currentTimeMillis()); + + if (invalid != null) { + U.warn(log, "The TLS certificate chain in use is not valid now on " + provider.transports() + + "; put a valid certificate in place and run control.sh --ssl reload [alias=" + entry.getKey() + ", " + + SslCertificates.describe(invalid) + ']'); + } } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 6bb3a47e75c73..89f5a12d1a177 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -21,6 +21,7 @@ import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; @@ -31,6 +32,9 @@ import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.KeyMaterial; +import org.apache.ignite.ssl.SslBundle; +import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; @@ -39,13 +43,13 @@ import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.SslContextFactory.DFLT_KEY_ALGORITHM; import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; import static org.apache.ignite.ssl.SslTestUtils.reload; import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; -import static org.junit.Assert.assertArrayEquals; /** Tests manual reload validation and certificate sources that supply optional metadata. */ public class SslContextProviderTest extends GridCommonAbstractTest { @@ -68,21 +72,61 @@ public class SslContextProviderTest extends GridCommonAbstractTest { stopAllGrids(); } - /** Changing a returned chain must not change the provider's certificate or chain. */ + /** An existing create override still controls the context, including its cache. Reload must not silently bypass it. */ @Test - public void testCertificateChainCannotChangeProvider() { - SslContextProvider p = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey)); + public void testLegacyCreateOverrideIsPreserved() { + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { + @Override public SSLContext create() { + return super.create(); + } + }; + SSLContext cached = factory.create(); + SslContextProvider provider = new SslContextProvider(factory); + + assertSame(cached, provider.context()); + assertNull(provider.certificateChains()); + GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "returns the context in use"); + assertSame(cached, provider.context()); + assertEquals(1, factory.calls); + } + + /** An old file-factory subclass may supply its cached context without configuring any store paths. */ + @Test + public void testLegacyPreloadedContextIsPreserved() throws Exception { + SSLContext prepared = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); + SslContextFactory factory = new SslContextFactory() { + { + sslCtx.set(prepared); + } + }; + SslContextProvider provider = new SslContextProvider(factory); - X509Certificate[] chain = p.certificateChain(); + assertSame(prepared, provider.context()); + assertNull(provider.certificateChains()); + GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "returns the context in use"); + assertSame(prepared, provider.context()); + } - assertNotNull(chain); + /** A factory that overrides both methods opts into bundles, even if its old create method still caches. */ + @Test + public void testBundleOverrideOptsIntoReload() throws Exception { + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { + @Override public SSLContext create() { + throw new AssertionError("The node must use createSslBundle()"); + } - X509Certificate[] expected = chain.clone(); + @Override public SslBundle createSslBundle() { + return super.createSslBundle(); + } + }; + SslContextProvider provider = new SslContextProvider(factory); + SSLContext first = provider.context(); - chain[0] = null; + provider.reload(); - assertSame(expected[0], p.certificate()); - assertArrayEquals(expected, p.certificateChain()); + assertNotSame(first, provider.context()); + assertNotNull(provider.certificateChains()); + assertEquals(2, factory.calls); } /** Nodes with different certificates from one authority keep communicating when only one node reloads. */ @@ -112,48 +156,51 @@ public void testReloadDoesNotBypassPeerTrust() throws Exception { IgniteEx g0 = startNode(0, factory); IgniteEx g1 = startNode(1, peerFactory); - X509Certificate peerCert = provider(g1).certificate(); + X509Certificate peerCert = certificate(provider(g1)); - assertEquals(provider(g0).certificate(), servedCertificate(peerFactory.createNew(), discoveryPort(g0))); + assertEquals(certificate(provider(g0)), servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0))); keys.set(untrusted.keyStoreWithPrivateKey()); assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); assertTrue(provider(g0).reloadOutcome().successTime() > 0); - assertEquals("CN=" + untrustedCaName, provider(g0).certificate().getIssuerX500Principal().getName()); - assertEquals(peerCert, provider(g1).certificate()); + assertEquals("CN=" + untrustedCaName, certificate(provider(g0)).getIssuerX500Principal().getName()); + assertEquals(peerCert, certificate(provider(g1))); - assertThrowsWithCause(() -> servedCertificate(peerFactory.createNew(), discoveryPort(g0)), SSLException.class); + assertThrowsWithCause(() -> servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0)), SSLException.class); } - /** Commands reload a store with multiple private keys and report that its certificate metadata is unavailable. */ + /** Commands report both private-key aliases before and after reloading a store with multiple keys. */ @Test - public void testCommandsReportUnknownMetadataForMultipleKeys() throws Exception { + public void testCommandsReportEveryPrivateKey() throws Exception { ScriptedSslContextFactory factory = new ScriptedSslContextFactory(this::twoKeys); IgniteEx g = startClientConnectorOn(factory); SslContextProvider p = provider(g, CLIENT_CONNECTOR); - assertNull(p.certificateChain()); - assertContains(log, status(g), "certificate metadata is unavailable"); - assertContains(log, reload(g), ": reloaded " + CLIENT_CONNECTOR); + assertEquals(2, p.certificateChains().size()); + + for (String report : List.of(status(g), reload(g))) { + for (String alias : p.certificateChains().keySet()) + assertContains(log, report, "alias=" + alias); + } } /** Replacing one key with several keys must not prevent manual reload. */ @Test - public void testReloadCanLoseMetadata() throws Exception { + public void testReloadCanAddPrivateKeys() throws Exception { ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, this::twoKeys); SslContextProvider p = new SslContextProvider(factory); SSLContext inUse = p.context(); - assertNotNull(p.certificateChain()); + assertEquals(1, p.certificateChains().size()); p.reload(); assertNotSame(inUse, p.context()); - assertNull(p.certificateChain()); + assertEquals(2, p.certificateChains().size()); assertTrue(p.reloadOutcome().successTime() > 0); assertEquals(0, p.reloadOutcome().failures()); } @@ -176,7 +223,7 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { p.reload(); SSLContext inUse = p.context(); - X509Certificate[] chain = p.certificateChain(); + Map> chains = p.certificateChains(); long lastSuccess = p.reloadOutcome().successTime(); assertTrue(lastSuccess > 0); @@ -187,7 +234,7 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { GridTestUtils.assertThrows(log, p::reload, IgniteException.class, failureReason); assertSame(inUse, p.context()); - assertArrayEquals(chain, p.certificateChain()); + assertEquals(chains, p.certificateChains()); assertEquals(lastSuccess, p.reloadOutcome().successTime()); assertEquals(failures, p.reloadOutcome().failures()); assertTrue(p.reloadOutcome().failureTime() >= attemptStart); @@ -196,11 +243,12 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { long recoveryStart = System.currentTimeMillis(); - X509Certificate recovered = p.reload(); + SslBundle recovered = p.reload(); assertNotSame(inUse, p.context()); - assertEquals(recovered, p.certificate()); - assertTrue(recovered.getNotAfter().after(chain[0].getNotAfter())); + assertSame(recovered.context(), p.context()); + assertEquals(recovered.certificateChains(), p.certificateChains()); + assertTrue(certificate(p).getNotAfter().after(chains.values().iterator().next().get(0).getNotAfter())); assertTrue(p.reloadOutcome().successTime() >= recoveryStart); assertEquals(0, p.reloadOutcome().failures()); assertEquals(0, p.reloadOutcome().failureTime()); @@ -215,12 +263,12 @@ public void testNotYetValidCertificateNotApplied() throws Exception { () -> ca.keyStoreWithPrivateKey("future", now + HOUR, now + 2 * HOUR)); SslContextProvider p = new SslContextProvider(factory); SSLContext inUse = p.context(); - X509Certificate[] chain = p.certificateChain(); + Map> chains = p.certificateChains(); GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "The new certificate chain is not valid now"); assertSame(inUse, p.context()); - assertArrayEquals(chain, p.certificateChain()); + assertEquals(chains, p.certificateChains()); assertEquals(1, p.reloadOutcome().failures()); } @@ -236,15 +284,37 @@ public void testExpiredIssuerNotApplied() throws Exception { SslContextProvider p = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, () -> candidate)); SSLContext inUse = p.context(); - X509Certificate[] chain = p.certificateChain(); + Map> chains = p.certificateChains(); GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "subject=CN=" + expiredCaName); assertSame(inUse, p.context()); - assertArrayEquals(chain, p.certificateChain()); + assertEquals(chains, p.certificateChains()); assertEquals(1, p.reloadOutcome().failures()); } + /** A valid first key must not hide an expired certificate belonging to another key. */ + @Test + public void testEveryPrivateKeyIsValidated() throws Exception { + long now = System.currentTimeMillis(); + String validAlias = "a-valid"; + String expiredAlias = "z-expired"; + KeyStore keys = ca.keyStoreWithPrivateKey(validAlias); + KeyStore expired = ca.keyStoreWithPrivateKey(expiredAlias, now - 2 * HOUR, now - HOUR); + + keys.setKeyEntry(expiredAlias, expired.getKey(expiredAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + expired.getCertificateChain(expiredAlias)); + + SslContextProvider provider = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, () -> keys)); + SSLContext original = provider.context(); + Map> originalChains = provider.certificateChains(); + + GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "alias=" + expiredAlias); + + assertSame(original, provider.context()); + assertEquals(originalChains, provider.certificateChains()); + } + /** An unsupported protocol in a replacement context leaves the working context in use. */ @Test public void testUnsupportedProtocolNotApplied() { @@ -261,7 +331,7 @@ public void testUnsupportedProtocolNotApplied() { assertEquals(1, p.reloadOutcome().failures()); } - /** @return Store with two valid private keys and therefore no single chain for certificate metadata. */ + /** @return Store with two private keys, each with its own valid certificate chain. */ private KeyStore twoKeys() throws Exception { String firstAlias = "first-key"; String secondAlias = "second-key"; @@ -320,9 +390,9 @@ private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthorit IgniteEx g0 = startNode(0, factory); IgniteEx g1 = startNode(1, peerFactory); - X509Certificate peerCert = provider(g1).certificate(); + X509Certificate peerCert = certificate(provider(g1)); - assertFalse(provider(g0).certificate().equals(peerCert)); + assertFalse(certificate(provider(g0)).equals(peerCert)); assertComputeWorks(g0, g1); KeyStore replacement = ca.keyStoreWithPrivateKey(); @@ -332,11 +402,11 @@ private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthorit assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); - assertEquals(replacementCert, provider(g0).certificate()); - assertEquals(peerCert, provider(g1).certificate()); + assertEquals(replacementCert, certificate(provider(g0))); + assertEquals(peerCert, certificate(provider(g1))); assertEquals(0, provider(g1).reloadOutcome().successTime()); - assertEquals(replacementCert, servedCertificate(peerFactory.createNew(), discoveryPort(g0))); - assertEquals(peerCert, servedCertificate(factory.createNew(), discoveryPort(g1))); + assertEquals(replacementCert, servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0))); + assertEquals(peerCert, servedCertificate(factory.createSslBundle().context(), discoveryPort(g1))); assertComputeWorks(g0, g1); @@ -344,17 +414,17 @@ private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthorit g1 = startNode(1, factory(() -> peerKeys, ca.trustStore())); - assertEquals(replacementCert, provider(g0).certificate()); - assertEquals(peerCert, provider(g1).certificate()); + assertEquals(replacementCert, certificate(provider(g0))); + assertEquals(peerCert, certificate(provider(g1))); assertComputeWorks(g0, g1); stopGrid(0); IgniteEx restarted = startNode(0, factory(keys::get, peerCa.trustStore())); - assertEquals(replacementCert, provider(restarted).certificate()); - assertEquals(replacementCert, servedCertificate(peerFactory.createNew(), discoveryPort(restarted))); - assertEquals(peerCert, provider(g1).certificate()); + assertEquals(replacementCert, certificate(provider(restarted))); + assertEquals(replacementCert, servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(restarted))); + assertEquals(peerCert, certificate(provider(g1))); assertComputeWorks(restarted, g1); } @@ -401,6 +471,16 @@ private static SslContextProvider provider(IgniteEx g, String transport) { throw new AssertionError("Nothing serves " + transport); } + /** @return The sole leaf certificate of a provider used by a single-key test. */ + private static X509Certificate certificate(SslContextProvider provider) { + Map> chains = provider.certificateChains(); + + assertNotNull(chains); + assertEquals(1, chains.size()); + + return chains.values().iterator().next().get(0); + } + /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ private class ScriptedSslContextFactory extends AbstractSslContextFactory { /** */ @@ -428,19 +508,19 @@ private ScriptedSslContextFactory(Callable... steps) { } /** {@inheritDoc} */ - @Override protected KeyMaterial createKeyMaterial() throws SSLException { + @Override public SslBundle createSslBundle() { Callable step = steps.get(Math.min(calls++, steps.size() - 1)); try { KeyStore store = step.call(); - return KeyMaterial.fromKeyStore(TestCertificateAuthority.keyManagers(store), store); + return createSslBundle(new KeyMaterial(store, TestCertificateAuthority.PWD, DFLT_KEY_ALGORITHM)); } catch (IgniteException e) { throw e; } catch (Exception e) { - throw new SSLException(e); + throw new IgniteException(e); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 6cbc1ac3b32dc..a4fb22e9f2b16 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -73,7 +73,6 @@ import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; -import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; /** @@ -191,7 +190,7 @@ public void testReloadOnRunningCluster() throws Exception { IgniteEx g1 = startGrid(1); LogListener logged = LogListener.matches(Pattern.compile("TLS certificates reloaded \\[transports=" + ALL_TRANSPORTS + - ", subject=" + Pattern.quote(newCert.getSubjectX500Principal().getName()) + + ", alias=" + NEW_NODE + ", subject=" + Pattern.quote(newCert.getSubjectX500Principal().getName()) + ", .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); nodeLog.registerListener(logged); @@ -220,7 +219,7 @@ public void testReloadOnRunningCluster() throws Exception { SslContextProvider p = g0.context().sslContexts().providers().iterator().next(); - assertEquals(newCert, p.certificate()); + assertEquals(newCert, p.certificateChains().get(NEW_NODE).get(0)); assertTrue(p.reloadOutcome().successTime() > 0); String status = status(g0); @@ -264,7 +263,7 @@ public void testPartialFailureAcrossNodes() throws Exception { assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "Failed to initialize key store"); - assertContains(log, status, "loaded subject=" + oldCert.getSubjectX500Principal().getName() + ','); + assertContains(log, status, "loaded alias=" + OLD_NODE + ", subject=" + oldCert.getSubjectX500Principal().getName() + ','); } /** @@ -321,7 +320,7 @@ public void testCustomFactoryReloaded() throws Exception { assertReloaded(reload(g), g, transports); assertEquals("Reload must load the shared factory once", loadsAfterReload, factory.calls); assertSame(ctx, registry.register(factory, COMMUNICATION)); - assertContains(log, status(g), "loaded subject=" + newCert.getSubjectX500Principal().getName() + ','); + assertContains(log, status(g), "certificate metadata is unavailable"); assertEquals("Status must not load the factory", loadsAfterReload, factory.calls); assertEquals(newCert, certificateAt(discoveryPort(g))); @@ -385,8 +384,8 @@ public void testExpiredCertificateNotApplied() throws Exception { KeyStore expiredStore = expiredKeys(); String expiredSubj = certificateChain(expiredStore)[0].getSubjectX500Principal().getName(); - LogListener expired = LogListener.matches(Pattern.compile("Failed to reload TLS certificates, the ones in use stay .*" + - "reason=The new certificate chain is not valid now \\[subject=" + Pattern.quote(expiredSubj) + ',')).build(); + String failureReason = "The new certificate chain is not valid now [alias=expired-node, subject=" + expiredSubj + ','; + LogListener expired = LogListener.matches(failureReason).build(); nodeLog.registerListener(expired); @@ -397,13 +396,13 @@ public void testExpiredCertificateNotApplied() throws Exception { String res = reloadFailure(g); assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "The new certificate chain is not valid now [subject=" + expiredSubj + ','); + assertContains(log, res, failureReason); assertTrue(expired.check()); assertEquals(oldCert, certificateAt(discoveryPort(g))); String status = status(g); - assertContains(log, status, "loaded subject=" + oldCert.getSubjectX500Principal().getName() + ", issuer="); + assertContains(log, status, "loaded alias=" + OLD_NODE + ", subject=" + oldCert.getSubjectX500Principal().getName() + ", issuer="); assertContains(log, status, "last reload failed 1 time(s) in a row"); assertContains(log, status, "The new certificate chain is not valid now"); } @@ -469,9 +468,9 @@ public void testClientTransportsWithSeparateFactories() throws Exception { assertEquals(certificateChain(nextRestKeys)[0], certificateAt(restPort)); } - /** Manual reload accepts several keys without metadata. Client operations and new TLS connections still work. */ + /** Manual reload reports every private key. Client operations and new TLS connections still work. */ @Test - public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Exception { + public void testKeyStoreWithMultipleKeysReloaded() throws Exception { ownClientConnectorFactory = true; IgniteEx g = startGrid(0); @@ -505,9 +504,9 @@ public void testKeyStoreWithMultipleKeysReloadedWithoutMetadata() throws Excepti String status = status(g); - assertContains(log, status, "certificate metadata is unavailable"); + assertContains(log, status, "alias=" + OLD_NODE); + assertContains(log, status, "alias=" + NEW_NODE); assertContains(log, status, "last reload succeeded at"); - assertNotContains(log, status, "loaded subject="); } /** @@ -586,8 +585,9 @@ public void testStatus() throws Exception { String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); String id = node(g); - assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded subject=" + expiredSubj + ", issuer="); - assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + "\n loaded subject=" + expiredSubj + ", issuer="); + assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded alias=expired-node, subject=" + expiredSubj + ", issuer="); + assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + + "\n loaded alias=expired-node, subject=" + expiredSubj + ", issuer="); assertContains(log, res, expired.getIssuerX500Principal().getName()); assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); } @@ -642,7 +642,7 @@ public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + ": node was unavailable to run the command"); assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); - assertContains(log, err.getMessage(), "loaded subject=" + expectedSubj + ','); + assertContains(log, err.getMessage(), "subject=" + expectedSubj + ','); } assertEquals(newCert, certificateAt(g.context().clientListener().port())); @@ -734,7 +734,7 @@ private KeyStore expiredKeys() throws Exception { /** */ private void assertReloaded(String res, IgniteEx node, String transports) { - assertContains(log, res, node(node) + ": reloaded " + transports + "; loaded "); + assertContains(log, res, node(node) + ": reloaded " + transports); } /** */ From 7ab76356a919e030dc97c45f3b1457e34df33950 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:45:19 +0300 Subject: [PATCH 041/242] IGNITE-29209 Make metadata test aliases explicit --- .../ignite/ssl/SslContextMetadataTest.java | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 96e3842c8dc9e..8a3796c889eda 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -60,12 +60,13 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Each bundle loads its store once; fresh bundles do not replace the context cached by the legacy create method. */ @Test public void testFreshBundlePreservesCachedContext() throws Exception { - KeyStore originalKeys = ca.keyStoreWithPrivateKey("original"); + String originalAlias = "original"; + KeyStore originalKeys = ca.keyStoreWithPrivateKey(originalAlias); List originalChain = Arrays.asList(certificateChain(originalKeys)); InMemoryStoreFactory factory = new InMemoryStoreFactory(originalKeys); SslBundle original = factory.createSslBundle(); - assertEquals(Map.of("original", originalChain), original.certificateChains()); + assertEquals(Map.of(originalAlias, originalChain), original.certificateChains()); assertEquals(1, factory.loadKeyStoreCalls); assertEquals(1, factory.parameterChecks); @@ -75,7 +76,8 @@ public void testFreshBundlePreservesCachedContext() throws Exception { assertEquals(2, factory.loadKeyStoreCalls); assertEquals(2, factory.parameterChecks); - KeyStore replacementKeys = ca.keyStoreWithPrivateKey("replacement"); + String replacementAlias = "replacement"; + KeyStore replacementKeys = ca.keyStoreWithPrivateKey(replacementAlias); List replacementChain = Arrays.asList(certificateChain(replacementKeys)); assertNotEquals(originalChain.get(0), replacementChain.get(0)); @@ -86,8 +88,8 @@ public void testFreshBundlePreservesCachedContext() throws Exception { assertNotSame(cached, fresh.context()); assertNotSame(original.context(), fresh.context()); - assertEquals(Map.of("replacement", replacementChain), fresh.certificateChains()); - assertEquals(Map.of("original", originalChain), original.certificateChains()); + assertEquals(Map.of(replacementAlias, replacementChain), fresh.certificateChains()); + assertEquals(Map.of(originalAlias, originalChain), original.certificateChains()); assertSame(cached, factory.create()); assertEquals(3, factory.loadKeyStoreCalls); assertEquals(3, factory.parameterChecks); @@ -96,7 +98,8 @@ public void testFreshBundlePreservesCachedContext() throws Exception { /** TLS parameters apply to the context while its metadata remains available separately. */ @Test public void testMetadataWithTlsParameters() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("node"); + String alias = "node"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); InMemoryStoreFactory factory = new InMemoryStoreFactory(store); String[] protocols = {"TLSv1.2"}; String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; @@ -106,15 +109,13 @@ public void testMetadataWithTlsParameters() throws Exception { SslBundle bundle = factory.createSslBundle(); - assertEquals(Map.of("node", Arrays.asList(certificateChain(store))), bundle.certificateChains()); + assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); for (SSLContext ctx : new SSLContext[] {bundle.context(), factory.create()}) { - SSLEngine engine = ctx.createSSLEngine("peer.example", 10800); + SSLEngine engine = ctx.createSSLEngine(); assertArrayEquals(protocols, engine.getEnabledProtocols()); assertArrayEquals(ciphers, engine.getEnabledCipherSuites()); - assertEquals("peer.example", engine.getPeerHost()); - assertEquals(10800, engine.getPeerPort()); } } @@ -296,7 +297,8 @@ public void testMissingKeyMaterialImplementation() { /** A custom store source does not need the legacy key-manager hook and keeps its own copy of the key password. */ @Test public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("node"); + String alias = "node"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); char[] pwd = PASSWORD.clone(); KeyMaterial material = new KeyMaterial(store, pwd, SslContextFactory.DFLT_KEY_ALGORITHM); @@ -326,33 +328,34 @@ public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { } }; - assertEquals(Map.of("node", Arrays.asList(certificateChain(store))), factory.createSslBundle().certificateChains()); + assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), factory.createSslBundle().certificateChains()); } /** Mutating the source map, its lists or the store cannot change an existing bundle. */ @Test public void testImmutableCertificateMetadata() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("node"); + String alias = "node"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); SslBundle loaded = new InMemoryStoreFactory(store).createSslBundle(); List expected = Arrays.asList(certificateChain(store)); List inputChain = new ArrayList<>(expected); Map> input = new HashMap<>(); - input.put("node", inputChain); + input.put(alias, inputChain); SslBundle bundle = new SslBundle(loaded.context(), input); inputChain.clear(); input.clear(); - store.deleteEntry("node"); + store.deleteEntry(alias); - assertEquals(Map.of("node", expected), loaded.certificateChains()); - assertEquals(Map.of("node", expected), bundle.certificateChains()); + assertEquals(Map.of(alias, expected), loaded.certificateChains()); + assertEquals(Map.of(alias, expected), bundle.certificateChains()); assertSame(loaded.context(), bundle.context()); GridTestUtils.assertThrows(log, () -> bundle.certificateChains().put("other", List.of()), UnsupportedOperationException.class, null); - GridTestUtils.assertThrows(log, () -> bundle.certificateChains().get("node").set(0, expected.get(1)), + GridTestUtils.assertThrows(log, () -> bundle.certificateChains().get(alias).set(0, expected.get(1)), UnsupportedOperationException.class, null); } From 5878205b09cb69c33e919c1d9eb723dffcc3803f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:57:48 +0300 Subject: [PATCH 042/242] IGNITE-29210 Explain bundle factory migration and alias reports --- docs/_docs/security/ssl-tls.adoc | 28 +++++++++++++++---- docs/_docs/tools/control-script.adoc | 14 ++++++---- .../util/GridCommandHandlerSslReloadTest.java | 12 +++++--- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 6c6a24acabdfb..b5c5c16703eaa 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -179,7 +179,7 @@ Use link:tools/control-script#reloading-certificates[`control.sh --ssl reload`] If `trustManagers` is configured on the factory, it controls trust instead of a trust store file. Reload keeps those managers. Follow their implementation's procedure to change which certificates they accept. -When the factory supplies its certificate chain, reload rejects expired or not yet valid certificates and keeps the previous context. Without the chain, reload cannot check certificate dates. +When the factory supplies certificate chains, reload checks every chain and rejects the replacement if any certificate is expired or not yet valid. The previous context stays in use. Without metadata, reload cannot check certificate dates. With `SslContextFactory`, reload also rejects a key store that contains no private key. Put the certificate and its private key in the key store and run reload again. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. @@ -293,10 +293,26 @@ Reload leaves existing connections open. After preventing new connections with t == Custom SSL Context Factories This section is for developers integrating a certificate source, such as a PKI service. -Extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] to support manual reload. The link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createNew()[createNew() contract] describes fresh creation, overrides, serialized calls, and handling of remote requests. -To let Ignite report and validate certificate dates, return the key managers and their single X.509 chain from the same certificate load through `createKeyMaterial()`. -If the factory builds the SSL context directly, attach the chain with link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SSLContextWrapper.html[SSLContextWrapper]. -Metadata is optional: without a chain, manual reload can still replace the context, but it cannot check certificate dates. +To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] and override `createSslBundle()`. +Return a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. +Include every private key, even when the context can choose among several keys. -A plain `Factory` must return a new context on every call. Returning the context already in use makes reload fail. +If the source provides a key store, pass it with its key password and key manager algorithm to link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial], then call the protected `createSslBundle(KeyMaterial)` helper. +It builds the key managers and metadata from that store. + +If you build the context yourself, return it and its chains in an `SslBundle`. +Use `null` for unavailable metadata. An empty map reports that there are no private keys, so node reload rejects it. +See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. + +=== Migrating an Existing Factory + +The inherited `create()` method keeps its cached context. Unmodified subclasses continue through `create()`, including those that supply a context through the protected cache. + +To opt into fresh bundles, override `createSslBundle()`: + +* If the subclass only changes how `SslContextFactory` loads its store, delegate the override to `super.createSslBundle()`. +* If it builds its own context in `create()`, build a fresh context from the same source in `createSslBundle()` and return it with its metadata. + +A plain `Factory` can still support reload by returning a fresh context on every call. Returning the context already in use makes reload fail. +This legacy path supplies no metadata: status cannot report certificate dates, and reload cannot validate them. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index f075090468b0b..2ea800d525329 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1787,7 +1787,7 @@ The command lists failed nodes first and exits with an error if any update faile Correct the reported cause and run reload again. Each factory reload attempt is also recorded in the node log, including the user's login and network address when security is enabled. -A successful result means the new SSL context is in use for new connections. Use `--ssl status` to check the loaded certificate, then test new connections as described in link:security/ssl-tls#upgrading-certificates[Upgrading Certificates]. +A successful result means the new SSL context is in use for new connections. Use `--ssl status` to check the loaded certificates, then test new connections as described in link:security/ssl-tls#upgrading-certificates[Upgrading Certificates]. === Checking Certificates @@ -1798,16 +1798,16 @@ Use this command after a replacement or when investigating a reload failure. It control.sh|bat --ssl status ---- -For each factory, the output lists its transports, loaded certificate and last reload result: +For each factory, the output lists its transports and last reload result. Each private-key entry (alias) has its own `loaded` line: [source,text] ---- e1e18f84-0fb5-484d-a623-15ffe3800000 [consistentId=node01]: client connector, communication, discovery - loaded subject=CN=node01, issuer=CN=ca, serial=2c41d7a09b, notBefore=2026-10-05T09:00:00Z, notAfter=2027-10-05T09:00:00Z + loaded alias=node01, subject=CN=node01, issuer=CN=ca, serial=2c41d7a09b, notBefore=2026-10-05T09:00:00Z, notAfter=2027-10-05T09:00:00Z last reload succeeded at 2026-10-05T09:00:02.347Z ---- -Check the certificate's identity and expiry date in the `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. +Check each certificate's identity and expiry date in its `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. Status does not test connections or verify that other nodes and clients trust this certificate. [cols="2,3",opts="header"] @@ -1818,7 +1818,11 @@ Status does not test connections or verify that other nodes and clients trust th |`PROBLEM: the loaded certificate is not valid now` |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` -|The factory did not supply its certificate chain, so the command cannot check certificate dates. With `SslContextFactory`, check that the key store contains exactly one private key and its X.509 certificate chain. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. Check the certificate dates directly in the key store or other certificate source. +|The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. +|`alias=...: certificate chain is unavailable` +|The source has this private key but did not supply its X.509 chain. Check the chain in the certificate source. +|`no private keys` +|The loaded source contains no private key. Install the node's certificate and private key, then run reload. |`no SSL context factory is configured` |This node has no certificates managed by these commands. This message does not make either command fail. |=== diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 2a7ac6f584949..cc145b97762f9 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -90,22 +90,26 @@ public void testReloadAndStatus() throws Exception { List nodes = List.of(startGrid(0), startGrid(1), startClientGrid(2)); - place("node02", keyStore); + String replacement = "node02"; + String replacementSubject = "CN=" + replacement; + String loaded = "loaded alias=" + replacement + ", subject=" + replacementSubject; + + place(replacement, keyStore); String reloadOutput = executeCommand(EXIT_CODE_OK, "--ssl", "reload"); for (IgniteEx grid : nodes) assertContains(log, reloadOutput, - node(grid) + ": reloaded " + transports(grid) + "; loaded subject=CN=node02"); + node(grid) + ": reloaded " + transports(grid) + "; " + loaded); - assertEquals("CN=node02", servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); + assertEquals(replacementSubject, servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); String statusOutput = executeCommand(EXIT_CODE_OK, "--ssl", "status"); for (IgniteEx grid : nodes) assertContains(log, statusOutput, node(grid) + ": " + transports(grid)); - assertContains(log, statusOutput, "loaded subject=CN=node02"); + assertContains(log, statusOutput, loaded); assertNotContains(log, statusOutput, "subject=CN=node01"); assertContains(log, statusOutput, "last reload succeeded at"); From 72a76a4c1db30c8b67b0ef1f2b7a4bda26d1e3af Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:10:03 +0300 Subject: [PATCH 043/242] IGNITE-29209 Keep key material loading internal to the file factory --- .../src/main/java/org/apache/ignite/ssl/SslContextFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 1ca71f50468be..1f182b2c1c0dd 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -286,7 +286,7 @@ public static TrustManager getDisabledTrustManager() { * @return Loaded store with its key password and manager algorithm. * @throws SSLException If the store cannot be loaded. */ - protected KeyMaterial loadKeyMaterial() throws SSLException { + private KeyMaterial loadKeyMaterial() throws SSLException { return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } From 934f8a2a658be29f3b5c5e905231921f7bc62775 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:11:29 +0300 Subject: [PATCH 044/242] IGNITE-29211 Separate delivery and renewal prerequisites --- docs/_docs/security/ssl-tls.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index d72daf144825f..62ebe37746e8b 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -255,11 +255,13 @@ For manual file replacement, use <> and run reload after === Prepare Certificate Delivery The delivery system must make a valid replacement available before renewal is due. Ignite reads the files when renewal is due, not when they change. + Renewal follows the first certificate to expire across all private-key chains, including CA certificates. If several chains expire together, the earliest renewal window applies. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. With `SslContextFactory`, include the X.509 chain of every private key in the key store. A custom factory must supply all these chains with each context. Without complete chains, a node with automatic renewal enabled cannot start because it cannot determine when renewal is due. + Ignite replaces the entire context at once. All replacement chains must be valid, but their aliases need not match the old ones. Nodes and applications must trust the replacement certificates before they are loaded. For a new CA, follow the stages in <>. @@ -350,6 +352,7 @@ It builds the key managers and metadata from that store. If you build the context yourself, return it and its chains in an `SslBundle`. Use `null` for unavailable metadata. An empty map reports that there are no private keys, so node reload rejects it. + With automatic renewal enabled, every private key must have a nonempty chain at startup and on each reload. Without renewal, metadata is optional. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. From f1b4533ce6f95eadde7c49bbe879dc5e69dbb1ab Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:20:43 +0300 Subject: [PATCH 045/242] IGNITE-29211 Calculate each certificate chain expiry once --- .../apache/ignite/internal/ssl/SslContextProvider.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 33f5a6b8fd977..b813a57587cca 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -280,14 +280,19 @@ private void putInUse(SslBundle rebuilt) { return null; List earliest = null; + long earliestExpiry = Long.MAX_VALUE; for (List chain : chains.values()) { if (chain.isEmpty()) return null; - if (earliest == null || chainNotAfter(chain) < chainNotAfter(earliest) || - chainNotAfter(chain) == chainNotAfter(earliest) && chain.get(0).getNotBefore().before(earliest.get(0).getNotBefore())) + long expiry = chainNotAfter(chain); + + if (earliest == null || expiry < earliestExpiry || + expiry == earliestExpiry && chain.get(0).getNotBefore().before(earliest.get(0).getNotBefore())) { earliest = chain; + earliestExpiry = expiry; + } } return earliest; From dd245e07b5c3286c12a108df12890ddc7158c4ae Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:22:05 +0300 Subject: [PATCH 046/242] IGNITE-29210 Follow project naming in SSL command test --- .../apache/ignite/util/GridCommandHandlerSslReloadTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index cc145b97762f9..02f0ade39a17c 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -91,8 +91,8 @@ public void testReloadAndStatus() throws Exception { List nodes = List.of(startGrid(0), startGrid(1), startClientGrid(2)); String replacement = "node02"; - String replacementSubject = "CN=" + replacement; - String loaded = "loaded alias=" + replacement + ", subject=" + replacementSubject; + String replacementSubj = "CN=" + replacement; + String loaded = "loaded alias=" + replacement + ", subject=" + replacementSubj; place(replacement, keyStore); @@ -102,7 +102,7 @@ public void testReloadAndStatus() throws Exception { assertContains(log, reloadOutput, node(grid) + ": reloaded " + transports(grid) + "; " + loaded); - assertEquals(replacementSubject, servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); + assertEquals(replacementSubj, servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); String statusOutput = executeCommand(EXIT_CODE_OK, "--ssl", "status"); From a360d09a188ebdddf4bc68d771bcfa2f16086d77 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:28:29 +0300 Subject: [PATCH 047/242] IGNITE-29209 Leave unrelated wrapper wording unchanged --- .../src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java index fb92fdd08d320..67bc834d0886c 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SSLContextWrapper.java @@ -21,7 +21,7 @@ import javax.net.ssl.SSLParameters; /** - * Wrapper for {@link SSLContext} that extends the source context with custom SSL parameters. + * Wrapper for {@link SSLContext} that extend source context with custom SSL parameters. */ public class SSLContextWrapper extends SSLContext { /** From b672056dcbafe97d58e7de3e394bc52c5af201ab Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:54:40 +0300 Subject: [PATCH 048/242] IGNITE-29209 Isolate legacy context creation from SSL bundles --- .../ignite/ssl/AbstractSslContextFactory.java | 56 +++++++++++-------- .../ignite/ssl/SslContextMetadataTest.java | 44 ++++++++++----- 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index dccf4e9ec68d7..172634dfb7c75 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -19,9 +19,6 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; -import java.util.List; -import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; @@ -53,7 +50,12 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; - /** Cached instance of an {@link SSLContext}. */ + /** + * Cached instance used by the legacy {@link #create()} method. + * + * @deprecated Hold the result of {@link #createSslBundle()} outside the factory. + */ + @Deprecated protected final AtomicReference sslCtx = new AtomicReference<>(); /** @@ -114,43 +116,34 @@ public void setProtocols(String... protocols) { /** * Builds a fresh context and its certificate metadata without calling {@link #create()} or changing its cached context. - * The default implementation calls the legacy key-manager hook and leaves certificate metadata unknown. Subclasses that - * load a key store can override this method and call {@link #createSslBundle(KeyMaterial)} to include its certificate chains. + * Subclasses must override this method. Those that load a key store can call {@link #createSslBundle(KeyMaterial)} to + * include its certificate chains. * * @return Fresh context and the certificate metadata available to this factory. + * @throws UnsupportedOperationException If the subclass does not implement bundle creation. * @throws IgniteException If the context could not be created. */ public SslBundle createSslBundle() { - try { - checkParameters(); - - return createSslBundle(createKeyManagers(), null); - } - catch (SSLException e) { - throw new IgniteException(e); - } + throw new UnsupportedOperationException("Subclass must override createSslBundle()"); } /** * Builds a fresh context from one loaded store. The caller must check its parameters before loading the material. - * This method does not call {@link #createKeyManagers()} or change the context cached by {@link #create()}. * * @param material Loaded key store, key password and manager algorithm. * @return Context and certificate chains from that material. * @throws SSLException If key managers, certificate metadata or the context cannot be created. */ protected SslBundle createSslBundle(KeyMaterial material) throws SSLException { - return createSslBundle(material.keyManagers(), material.certificateChains()); + return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); } /** * @param keyMgrs Key managers, or {@code null} to use the provider's defaults. - * @param chains Certificate chains by private-key alias, or {@code null} if unknown. - * @return Initialized context and its certificate metadata. + * @return Initialized context. * @throws SSLException If trust managers or the context cannot be created. */ - private SslBundle createSslBundle(@Nullable KeyManager[] keyMgrs, - @Nullable Map> chains) throws SSLException { + private SSLContext createSslContext(@Nullable KeyManager[] keyMgrs) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -173,7 +166,7 @@ private SslBundle createSslBundle(@Nullable KeyManager[] keyMgrs, ctx.init(keyMgrs, trustMgrs, null); - return new SslBundle(ctx, chains); + return ctx; } catch (NoSuchAlgorithmException e) { throw new SSLException("Unsupported SSL protocol: " + proto, e); @@ -218,12 +211,29 @@ protected KeyManager[] createKeyManagers() throws SSLException { */ protected abstract TrustManager[] createTrustManagers() throws SSLException; - /** {@inheritDoc} */ + /** + * Returns the cached context, building it through the subclass's bundle method or its legacy key-manager hook. + * + * @return Cached context. + * @deprecated Use {@link #createSslBundle()} and keep the context in the component that uses it. + */ + @Deprecated @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); if (ctx == null) { - ctx = createSslBundle().context(); + try { + if (getClass().getMethod("createSslBundle").getDeclaringClass() == AbstractSslContextFactory.class) { + checkParameters(); + + ctx = createSslContext(createKeyManagers()); + } + else + ctx = createSslBundle().context(); + } + catch (NoSuchMethodException | SSLException e) { + throw new IgniteException(e); + } if (!sslCtx.compareAndSet(null, ctx)) ctx = sslCtx.get(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 8a3796c889eda..f9734a47dded7 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -205,7 +205,7 @@ public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { assertEquals(Map.of(alias, List.of()), material.certificateChains()); } - /** Legacy hooks run once per new context and leave metadata unknown rather than reporting no private keys. */ + /** Legacy creation stays cached; requesting a bundle does not invoke the old hooks. */ @Test public void testLegacyHooksRunOncePerBuiltContext() { LegacyFactory factory = new LegacyFactory(); @@ -216,14 +216,13 @@ public void testLegacyHooksRunOncePerBuiltContext() { assertEquals(1, factory.keyManagerCalls); assertEquals(1, factory.trustManagerCalls); - SslBundle next = factory.createSslBundle(); + GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, + UnsupportedOperationException.class, "Subclass must override createSslBundle()"); - assertNotSame(first, next.context()); - assertNull(next.certificateChains()); assertSame(first, factory.create()); - assertEquals(2, factory.parameterChecks); - assertEquals(2, factory.keyManagerCalls); - assertEquals(2, factory.trustManagerCalls); + assertEquals(1, factory.parameterChecks); + assertEquals(1, factory.keyManagerCalls); + assertEquals(1, factory.trustManagerCalls); } /** Existing subclasses can still populate the protected cache before the first create call. */ @@ -238,9 +237,6 @@ public void testLegacyProtectedCache() throws Exception { assertEquals(0, factory.parameterChecks); assertEquals(0, factory.keyManagerCalls); assertEquals(0, factory.trustManagerCalls); - - assertNotSame(prepared, factory.createSslBundle().context()); - assertSame(prepared, factory.create()); } /** A subclass can still obtain key managers from the file factory's legacy hook. */ @@ -289,9 +285,8 @@ public void testMissingKeyMaterialImplementation() { assertTrue(err.getCause() instanceof SSLException); - err = GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, IgniteException.class, msg); - - assertTrue(err.getCause() instanceof SSLException); + GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, + UnsupportedOperationException.class, "Subclass must override createSslBundle()"); } /** A custom store source does not need the legacy key-manager hook and keeps its own copy of the key password. */ @@ -329,6 +324,29 @@ public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { }; assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), factory.createSslBundle().certificateChains()); + + SSLContext cached = factory.create(); + + assertSame(cached, factory.create()); + assertNotSame(cached, factory.createSslBundle().context()); + } + + /** Failure in a new factory must not fall back to legacy key managers. */ + @Test + public void testBundleFailureDoesNotUseLegacyManagers() { + String failure = "Certificate source failed"; + LegacyFactory factory = new LegacyFactory() { + /** {@inheritDoc} */ + @Override public SslBundle createSslBundle() { + throw new UnsupportedOperationException(failure); + } + }; + + GridTestUtils.assertThrows(log, factory::create, UnsupportedOperationException.class, failure); + + assertEquals(0, factory.parameterChecks); + assertEquals(0, factory.keyManagerCalls); + assertEquals(0, factory.trustManagerCalls); } /** Mutating the source map, its lists or the store cannot change an existing bundle. */ From 14ea28492847779954fc506c15bd0159fe3fbdb9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:55:38 +0300 Subject: [PATCH 049/242] IGNITE-29210 Clarify migration from legacy SSL factory hooks --- docs/_docs/security/ssl-tls.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b5c5c16703eaa..dce7152b92704 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -307,7 +307,8 @@ See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ign === Migrating an Existing Factory -The inherited `create()` method keeps its cached context. Unmodified subclasses continue through `create()`, including those that supply a context through the protected cache. +The deprecated `create()` method keeps its cached context. Existing subclasses can still use it, the protected `sslCtx` cache and the deprecated `createKeyManagers()` hook. +The new `createSslBundle()` method requires an implementation; it does not adapt the old manager hook. To opt into fresh bundles, override `createSslBundle()`: From 0d91bf50dc30ec9cabf7891d0650d6558e83dd7e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:59:38 +0300 Subject: [PATCH 050/242] IGNITE-29210 Mark the legacy factory selector for removal --- .../java/org/apache/ignite/internal/ssl/SslContextProvider.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 7d34540350f0f..3ecc9f013c979 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -101,7 +101,9 @@ public String transports() { * * @param factory Configured factory. * @return Source that preserves the factory's most specific creation method. + * @deprecated When the legacy factory cache is removed, use createSslBundle() for every AbstractSslContextFactory. */ + @Deprecated private static Supplier bundleSource(Factory factory) { if (factory.getClass() == SslContextFactory.class) return ((SslContextFactory)factory)::createSslBundle; From cbc092c97105a8ea24139c26eafda1ab8a2f271b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:11:36 +0300 Subject: [PATCH 051/242] IGNITE-29210 Isolate discovery addresses between SSL reload tests --- .../java/org/apache/ignite/ssl/SslContextReloadTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index a4fb22e9f2b16..fa0dd2f8873a4 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -51,6 +51,8 @@ import org.apache.ignite.internal.visor.VisorTaskArgument; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; import org.apache.ignite.testframework.GridTestNode; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.ListeningTestLogger; @@ -125,6 +127,9 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** Trust store every node starts on, unless one is placed for it. */ private KeyStore initTrustStore; + /** Discovery addresses shared only by nodes in this test. */ + private TcpDiscoveryVmIpFinder ipFinder; + /** */ private final ListeningTestLogger nodeLog = new ListeningTestLogger(log); @@ -132,6 +137,8 @@ public class SslContextReloadTest extends GridCommonAbstractTest { @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName).setGridLogger(nodeLog); + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + if (!Files.exists(keyStore(igniteInstanceName))) TestCertificateAuthority.save(oldKeys, keyStore(igniteInstanceName)); @@ -149,6 +156,7 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { + ipFinder = new TcpDiscoveryVmIpFinder(true); dir = Files.createTempDirectory("ignite-ssl-reload-"); now = System.currentTimeMillis(); oldCa = new TestCertificateAuthority("old-ca"); From 7b09b59b1e66505a655c34dcad546581a6a17498 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:40:47 +0300 Subject: [PATCH 052/242] IGNITE-29210 Explain independent legacy and bundle factory paths --- docs/_docs/security/ssl-tls.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index dce7152b92704..8e5fc2bdd69d9 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -310,6 +310,9 @@ See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ign The deprecated `create()` method keeps its cached context. Existing subclasses can still use it, the protected `sslCtx` cache and the deprecated `createKeyManagers()` hook. The new `createSslBundle()` method requires an implementation; it does not adapt the old manager hook. +Overriding `createSslBundle()` does not change `create()`. +If a new factory also needs to serve callers of `Factory.create()`, provide an explicit `create()` adapter or implement the deprecated `createKeyManagers()` hook. + To opt into fresh bundles, override `createSslBundle()`: * If the subclass only changes how `SslContextFactory` loads its store, delegate the override to `super.createSslBundle()`. From 65a208e51413a3445152b0bbd00a54e131476ce9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:40:47 +0300 Subject: [PATCH 053/242] IGNITE-29209 Keep legacy SSL creation independent of bundles --- .../ignite/ssl/AbstractSslContextFactory.java | 15 ++++----- .../ignite/ssl/SslContextMetadataTest.java | 31 ++++++++++++------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 172634dfb7c75..9d62832321573 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -202,7 +202,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ @Deprecated protected KeyManager[] createKeyManagers() throws SSLException { - throw new SSLException("Subclass must override createSslBundle() or createKeyManagers()"); + throw new SSLException("Subclass must override createKeyManagers() to use create()"); } /** @@ -212,7 +212,8 @@ protected KeyManager[] createKeyManagers() throws SSLException { protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Returns the cached context, building it through the subclass's bundle method or its legacy key-manager hook. + * Returns the cached context, building it through the legacy key-manager hook. + * Overriding {@link #createSslBundle()} does not change this method's behavior. * * @return Cached context. * @deprecated Use {@link #createSslBundle()} and keep the context in the component that uses it. @@ -223,15 +224,11 @@ protected KeyManager[] createKeyManagers() throws SSLException { if (ctx == null) { try { - if (getClass().getMethod("createSslBundle").getDeclaringClass() == AbstractSslContextFactory.class) { - checkParameters(); + checkParameters(); - ctx = createSslContext(createKeyManagers()); - } - else - ctx = createSslBundle().context(); + ctx = createSslContext(createKeyManagers()); } - catch (NoSuchMethodException | SSLException e) { + catch (SSLException e) { throw new IgniteException(e); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index f9734a47dded7..5e69735810d90 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -279,7 +279,7 @@ public void testMissingKeyMaterialImplementation() { } }; - String msg = "Subclass must override createSslBundle() or createKeyManagers()"; + String msg = "Subclass must override createKeyManagers() to use create()"; Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, msg); @@ -289,7 +289,7 @@ public void testMissingKeyMaterialImplementation() { UnsupportedOperationException.class, "Subclass must override createSslBundle()"); } - /** A custom store source does not need the legacy key-manager hook and keeps its own copy of the key password. */ + /** Bundle creation needs no legacy key-manager hook; create() still does. Loaded material copies its key password. */ @Test public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { String alias = "node"; @@ -323,17 +323,20 @@ public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { } }; - assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), factory.createSslBundle().certificateChains()); + SslBundle bundle = factory.createSslBundle(); - SSLContext cached = factory.create(); + assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); - assertSame(cached, factory.create()); - assertNotSame(cached, factory.createSslBundle().context()); + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "Subclass must override createKeyManagers() to use create()"); + + assertTrue(err.getCause() instanceof SSLException); + assertNotSame(bundle.context(), factory.createSslBundle().context()); } - /** Failure in a new factory must not fall back to legacy key managers. */ + /** A bundle override does not change the legacy creation path or its cached context. */ @Test - public void testBundleFailureDoesNotUseLegacyManagers() { + public void testLegacyCreationIsIndependentOfBundleCreation() { String failure = "Certificate source failed"; LegacyFactory factory = new LegacyFactory() { /** {@inheritDoc} */ @@ -342,11 +345,15 @@ public void testBundleFailureDoesNotUseLegacyManagers() { } }; - GridTestUtils.assertThrows(log, factory::create, UnsupportedOperationException.class, failure); + SSLContext cached = factory.create(); - assertEquals(0, factory.parameterChecks); - assertEquals(0, factory.keyManagerCalls); - assertEquals(0, factory.trustManagerCalls); + GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, + UnsupportedOperationException.class, failure); + + assertSame(cached, factory.create()); + assertEquals(1, factory.parameterChecks); + assertEquals(1, factory.keyManagerCalls); + assertEquals(1, factory.trustManagerCalls); } /** Mutating the source map, its lists or the store cannot change an existing bundle. */ From a2fd28f30737d68012feb83a7847f85c2bc72c10 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:43:51 +0300 Subject: [PATCH 054/242] IGNITE-29211 Keep superseded renewal test focused on factory requests --- .../apache/ignite/internal/ssl/SslRenewalTest.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 9df5b87732568..a32392f76f815 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -423,17 +423,13 @@ public void testRenewalNotValidYetRejected() { assertRenewalRejected(p, "The new certificate chain is not valid now"); } - /** - * A renewal planned before a reload must not ask for a certificate or replace the one the reload has put in use. The reload - * must leave the factory's {@code create()} returning the first context. - */ + /** A renewal planned before a reload must not ask for a certificate or replace the one the reload has put in use. */ @Test public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { KeyStore inUse = ca.keyStoreWithPrivateKey(); Issuer issuer = new Issuer(() -> inUse); - SSLContext cached = issuer.create(); SslContextProvider p = new SslContextProvider(issuer); SSLContext planned = p.context(); @@ -444,12 +440,14 @@ public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { p.reload(); SSLContext reloaded = p.context(); + int callsAfterReload = issuer.calls.size(); + + assertNotSame(planned, reloaded); assertNull(p.renew(planned)); assertEquals(1, p.renewalFailures()); assertSame(reloaded, p.context()); - assertEquals("The superseded renewal must not ask for another certificate", 4, issuer.calls.size()); - assertSame(cached, issuer.create()); + assertEquals("The superseded renewal must not ask for another certificate", callsAfterReload, issuer.calls.size()); } /** A reload during renewal planning prevents the outdated attempt from calling the factory or replacing the new context. */ From ea20914095f6a0bb9b62164e910af2c64a77ae9a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:43:51 +0300 Subject: [PATCH 055/242] IGNITE-29210 Make the legacy factory override explicit in its test --- .../ignite/internal/ssl/SslContextProviderTest.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 89f5a12d1a177..644869f43b00f 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -74,20 +74,21 @@ public class SslContextProviderTest extends GridCommonAbstractTest { /** An existing create override still controls the context, including its cache. Reload must not silently bypass it. */ @Test - public void testLegacyCreateOverrideIsPreserved() { + public void testLegacyCreateOverrideIsPreserved() throws Exception { + SSLContext cached = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { @Override public SSLContext create() { - return super.create(); + return cached; } }; - SSLContext cached = factory.create(); SslContextProvider provider = new SslContextProvider(factory); assertSame(cached, provider.context()); assertNull(provider.certificateChains()); GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "returns the context in use"); assertSame(cached, provider.context()); - assertEquals(1, factory.calls); + assertEquals(0, factory.calls); } /** An old file-factory subclass may supply its cached context without configuring any store paths. */ From fecbd9e1e6533d56c45f353443af3c1d048c884b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 18:08:05 +0300 Subject: [PATCH 056/242] IGNITE-29209 Preserve legacy key manager initialization --- .../apache/ignite/ssl/SslContextFactory.java | 18 ++++++++++++++++- .../ignite/ssl/SslContextMetadataTest.java | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index d4547c22b16ef..856be26d1e4ca 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -28,6 +28,7 @@ import java.security.cert.X509Certificate; import java.util.Arrays; import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; @@ -268,7 +269,22 @@ public static TrustManager getDisabledTrustManager() { */ @Deprecated @Override protected final KeyManager[] createKeyManagers() throws SSLException { - return loadKeyMaterial().keyManagers(); + try { + KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + + keyMgrFactory.init(keyStore, keyStorePwd); + + return keyMgrFactory.getKeyManagers(); + } + catch (NoSuchAlgorithmException e) { + throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); + } + catch (GeneralSecurityException e) { + throw new SSLException("Failed to initialize Key Manager (security exception occurred) [type=" + + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + } } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 5e69735810d90..6279d1af514d3 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -250,6 +250,26 @@ public void testLegacyKeyManagerAdapter() throws Exception { assertEquals(1, factory.loadKeyStoreCalls); } + /** Legacy store loaders can still pass null to JSSE to initialize key managers without a store. */ + @Test + public void testLegacyNullKeyStore() { + SslContextFactory factory = new SslContextFactory() { + /** {@inheritDoc} */ + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { + return null; + } + }; + + factory.setKeyStoreFilePath("unused-by-custom-loader"); + factory.setKeyStorePassword(PASSWORD); + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + + SSLContext ctx = factory.create(); + + assertNotNull(ctx.createSSLEngine()); + assertSame(ctx, factory.create()); + } + /** Invalid parameters are reported before loading the store. */ @Test public void testInvalidParametersDoNotLoadStore() throws Exception { From 166027dccbcf6bea8f96595c49a1280f2951520c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:19:50 +0300 Subject: [PATCH 057/242] IGNITE-29209 Build fresh SSL bundles without legacy factory state --- .../ignite/ssl/AbstractSslContextFactory.java | 84 ++----- .../apache/ignite/ssl/SslContextFactory.java | 51 +--- .../ignite/ssl/SslContextMetadataTest.java | 235 ++++-------------- 3 files changed, 77 insertions(+), 293 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 9d62832321573..a7c65a65cccc0 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -19,7 +19,6 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; @@ -31,8 +30,8 @@ import org.jetbrains.annotations.Nullable; /** - * Base SSL context factory that caches the result of the first successful {@link #create()} call. - * Use {@link #createSslBundle()} to load another context and its certificate chains without changing the cached context. + * Builds a fresh SSL context and its certificate metadata on each call. The component using the context is responsible for + * retaining it between connections. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -50,14 +49,6 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; - /** - * Cached instance used by the legacy {@link #create()} method. - * - * @deprecated Hold the result of {@link #createSslBundle()} outside the factory. - */ - @Deprecated - protected final AtomicReference sslCtx = new AtomicReference<>(); - /** * Gets protocol for secure transport. * @@ -115,27 +106,23 @@ public void setProtocols(String... protocols) { } /** - * Builds a fresh context and its certificate metadata without calling {@link #create()} or changing its cached context. - * Subclasses must override this method. Those that load a key store can call {@link #createSslBundle(KeyMaterial)} to - * include its certificate chains. + * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from + * that material. The factory does not retain the result. * * @return Fresh context and the certificate metadata available to this factory. - * @throws UnsupportedOperationException If the subclass does not implement bundle creation. * @throws IgniteException If the context could not be created. */ public SslBundle createSslBundle() { - throw new UnsupportedOperationException("Subclass must override createSslBundle()"); - } + try { + checkParameters(); - /** - * Builds a fresh context from one loaded store. The caller must check its parameters before loading the material. - * - * @param material Loaded key store, key password and manager algorithm. - * @return Context and certificate chains from that material. - * @throws SSLException If key managers, certificate metadata or the context cannot be created. - */ - protected SslBundle createSslBundle(KeyMaterial material) throws SSLException { - return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); + KeyMaterial material = loadKeyMaterial(); + + return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); + } + catch (SSLException e) { + throw new IgniteException(e); + } } /** @@ -149,20 +136,17 @@ private SSLContext createSslContext(@Nullable KeyManager[] keyMgrs) throws SSLEx try { SSLContext ctx = SSLContext.getInstance(proto); - SSLParameters sslParameters = null; - if (cipherSuites != null || protocols != null) { - sslParameters = new SSLParameters(); + SSLParameters sslParameters = new SSLParameters(); if (cipherSuites != null) sslParameters.setCipherSuites(cipherSuites); if (protocols != null) sslParameters.setProtocols(protocols); - } - if (sslParameters != null) ctx = new SSLContextWrapper(ctx, sslParameters); + } ctx.init(keyMgrs, trustMgrs, null); @@ -194,16 +178,12 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract void checkParameters() throws SSLException; /** - * Creates key managers without certificate metadata. + * Loads the key store and the settings used to read its keys. Called once per context, after the parameters are checked. * - * @return Created key managers. - * @throws SSLException If this method is not overridden or key managers cannot be created. - * @deprecated Override {@link #createSslBundle()} to return a context with its certificate chains. + * @return Loaded key material. + * @throws SSLException If the key material cannot be loaded. */ - @Deprecated - protected KeyManager[] createKeyManagers() throws SSLException { - throw new SSLException("Subclass must override createKeyManagers() to use create()"); - } + protected abstract KeyMaterial loadKeyMaterial() throws SSLException; /** * @return Created Trust Managers. @@ -212,30 +192,12 @@ protected KeyManager[] createKeyManagers() throws SSLException { protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Returns the cached context, building it through the legacy key-manager hook. - * Overriding {@link #createSslBundle()} does not change this method's behavior. + * Builds a fresh context through {@link #createSslBundle()}. * - * @return Cached context. - * @deprecated Use {@link #createSslBundle()} and keep the context in the component that uses it. + * @return Fresh initialized context. + * @throws IgniteException If the context could not be created. */ - @Deprecated @Override public SSLContext create() { - SSLContext ctx = sslCtx.get(); - - if (ctx == null) { - try { - checkParameters(); - - ctx = createSslContext(createKeyManagers()); - } - catch (SSLException e) { - throw new IgniteException(e); - } - - if (!sslCtx.compareAndSet(null, ctx)) - ctx = sslCtx.get(); - } - - return ctx; + return createSslBundle().context(); } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 856be26d1e4ca..58da511294165 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -27,22 +27,19 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import org.apache.ignite.IgniteException; import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * This factory caches the result of the first successful attempt to create an {@link SSLContext} and always returns it - * as a result of further invocations of the {@link SslContextFactory#create()}} method. + * Each call loads the stores again and builds a fresh context. The component using it should retain the context between + * connections. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager @@ -262,50 +259,8 @@ public static TrustManager getDisabledTrustManager() { return new DisabledX509TrustManager(); } - /** - * {@inheritDoc} - * - * @deprecated Use {@link #createSslBundle()} to obtain a context with its certificate chains. - */ - @Deprecated - @Override protected final KeyManager[] createKeyManagers() throws SSLException { - try { - KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); - - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - keyMgrFactory.init(keyStore, keyStorePwd); - - return keyMgrFactory.getKeyManagers(); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); - } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred) [type=" + - keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); - } - } - /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { - try { - checkParameters(); - - return createSslBundle(loadKeyMaterial()); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } - - /** - * Loads the key store once for both key managers and certificate metadata. - * - * @return Loaded store with its key password and manager algorithm. - * @throws SSLException If the store cannot be loaded. - */ - private KeyMaterial loadKeyMaterial() throws SSLException { + @Override protected KeyMaterial loadKeyMaterial() throws SSLException { return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 6279d1af514d3..290268eae507c 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -29,11 +29,11 @@ import java.util.Map; import java.util.concurrent.Callable; import javax.crypto.KeyGenerator; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; @@ -57,9 +57,9 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each bundle loads its store once; fresh bundles do not replace the context cached by the legacy create method. */ + /** Each create or bundle request loads fresh material once and leaves earlier bundles unchanged. */ @Test - public void testFreshBundlePreservesCachedContext() throws Exception { + public void testFreshContextsLoadMaterialOnce() throws Exception { String originalAlias = "original"; KeyStore originalKeys = ca.keyStoreWithPrivateKey(originalAlias); List originalChain = Arrays.asList(certificateChain(originalKeys)); @@ -70,9 +70,9 @@ public void testFreshBundlePreservesCachedContext() throws Exception { assertEquals(1, factory.loadKeyStoreCalls); assertEquals(1, factory.parameterChecks); - SSLContext cached = factory.create(); + SSLContext created = factory.create(); - assertSame(cached, factory.create()); + assertNotSame(original.context(), created); assertEquals(2, factory.loadKeyStoreCalls); assertEquals(2, factory.parameterChecks); @@ -86,13 +86,17 @@ public void testFreshBundlePreservesCachedContext() throws Exception { SslBundle fresh = factory.createSslBundle(); - assertNotSame(cached, fresh.context()); + assertNotSame(created, fresh.context()); assertNotSame(original.context(), fresh.context()); assertEquals(Map.of(replacementAlias, replacementChain), fresh.certificateChains()); assertEquals(Map.of(originalAlias, originalChain), original.certificateChains()); - assertSame(cached, factory.create()); assertEquals(3, factory.loadKeyStoreCalls); assertEquals(3, factory.parameterChecks); + + assertNotSame(fresh.context(), factory.create()); + assertEquals(4, factory.loadKeyStoreCalls); + assertEquals(4, factory.parameterChecks); + assertEquals(4, factory.bundleCalls); } /** TLS parameters apply to the context while its metadata remains available separately. */ @@ -205,71 +209,6 @@ public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { assertEquals(Map.of(alias, List.of()), material.certificateChains()); } - /** Legacy creation stays cached; requesting a bundle does not invoke the old hooks. */ - @Test - public void testLegacyHooksRunOncePerBuiltContext() { - LegacyFactory factory = new LegacyFactory(); - SSLContext first = factory.create(); - - assertSame(first, factory.create()); - assertEquals(1, factory.parameterChecks); - assertEquals(1, factory.keyManagerCalls); - assertEquals(1, factory.trustManagerCalls); - - GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, - UnsupportedOperationException.class, "Subclass must override createSslBundle()"); - - assertSame(first, factory.create()); - assertEquals(1, factory.parameterChecks); - assertEquals(1, factory.keyManagerCalls); - assertEquals(1, factory.trustManagerCalls); - } - - /** Existing subclasses can still populate the protected cache before the first create call. */ - @Test - public void testLegacyProtectedCache() throws Exception { - LegacyFactory factory = new LegacyFactory(); - SSLContext prepared = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - - factory.sslCtx.set(prepared); - - assertSame(prepared, factory.create()); - assertEquals(0, factory.parameterChecks); - assertEquals(0, factory.keyManagerCalls); - assertEquals(0, factory.trustManagerCalls); - } - - /** A subclass can still obtain key managers from the file factory's legacy hook. */ - @Test - public void testLegacyKeyManagerAdapter() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); - KeyManager[] mgrs = factory.legacyKeyManagers(); - - assertNotNull(mgrs); - assertTrue(mgrs.length > 0); - assertEquals(1, factory.loadKeyStoreCalls); - } - - /** Legacy store loaders can still pass null to JSSE to initialize key managers without a store. */ - @Test - public void testLegacyNullKeyStore() { - SslContextFactory factory = new SslContextFactory() { - /** {@inheritDoc} */ - @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { - return null; - } - }; - - factory.setKeyStoreFilePath("unused-by-custom-loader"); - factory.setKeyStorePassword(PASSWORD); - factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); - - SSLContext ctx = factory.create(); - - assertNotNull(ctx.createSSLEngine()); - assertSame(ctx, factory.create()); - } - /** Invalid parameters are reported before loading the store. */ @Test public void testInvalidParametersDoNotLoadStore() throws Exception { @@ -284,34 +223,34 @@ public void testInvalidParametersDoNotLoadStore() throws Exception { assertEquals(0, factory.loadKeyStoreCalls); } - /** A factory implementing neither a bundle builder nor the legacy key-manager hook reports the missing implementation. */ + /** A failed load is reported instead of reusing an earlier context, and the next request can recover. */ @Test - public void testMissingKeyMaterialImplementation() { - AbstractSslContextFactory factory = new AbstractSslContextFactory() { - /** {@inheritDoc} */ - @Override protected void checkParameters() { - // No parameters are required by this factory. - } - - /** {@inheritDoc} */ - @Override protected TrustManager[] createTrustManagers() { - return null; - } - }; + public void testFailedLoadCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SSLContext original = factory.create(); + String failure = "Key material is unavailable"; - String msg = "Subclass must override createKeyManagers() to use create()"; + factory.loadFailure = failure; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, msg); + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, failure); assertTrue(err.getCause() instanceof SSLException); - GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, - UnsupportedOperationException.class, "Subclass must override createSslBundle()"); + GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, IgniteException.class, failure); + + factory.loadFailure = null; + + SSLContext recovered = factory.create(); + + assertNotSame(original, recovered); + assertNotNull(recovered.createSSLEngine()); + assertEquals(4, factory.loadKeyStoreCalls); + assertEquals(4, factory.parameterChecks); } - /** Bundle creation needs no legacy key-manager hook; create() still does. Loaded material copies its key password. */ + /** Mutating the caller's password does not change how loaded material opens its private keys. */ @Test - public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { + public void testLoadedMaterialCopiesKeyPassword() throws Exception { String alias = "node"; KeyStore store = ca.keyStoreWithPrivateKey(alias); char[] pwd = PASSWORD.clone(); @@ -319,61 +258,10 @@ public void testLoadedMaterialBypassesLegacyKeyManagerHook() throws Exception { Arrays.fill(pwd, '?'); - AbstractSslContextFactory factory = new AbstractSslContextFactory() { - /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { - try { - checkParameters(); - - return createSslBundle(material); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } - - /** {@inheritDoc} */ - @Override protected void checkParameters() { - // The test supplies already loaded material. - } + X509KeyManager mgr = (X509KeyManager)material.keyManagers()[0]; - /** {@inheritDoc} */ - @Override protected TrustManager[] createTrustManagers() { - return null; - } - }; - - SslBundle bundle = factory.createSslBundle(); - - assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); - - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, - "Subclass must override createKeyManagers() to use create()"); - - assertTrue(err.getCause() instanceof SSLException); - assertNotSame(bundle.context(), factory.createSslBundle().context()); - } - - /** A bundle override does not change the legacy creation path or its cached context. */ - @Test - public void testLegacyCreationIsIndependentOfBundleCreation() { - String failure = "Certificate source failed"; - LegacyFactory factory = new LegacyFactory() { - /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { - throw new UnsupportedOperationException(failure); - } - }; - - SSLContext cached = factory.create(); - - GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, - UnsupportedOperationException.class, failure); - - assertSame(cached, factory.create()); - assertEquals(1, factory.parameterChecks); - assertEquals(1, factory.keyManagerCalls); - assertEquals(1, factory.trustManagerCalls); + assertNotNull(mgr.getPrivateKey(alias)); + assertArrayEquals(certificateChain(store), mgr.getCertificateChain(alias)); } /** Mutating the source map, its lists or the store cannot change an existing bundle. */ @@ -404,7 +292,7 @@ public void testImmutableCertificateMetadata() throws Exception { UnsupportedOperationException.class, null); } - /** A file factory whose existing store-loading hook supplies the test material. */ + /** A file factory whose store-loading hook supplies the test material. */ private static class InMemoryStoreFactory extends SslContextFactory { /** */ private KeyStore store; @@ -415,6 +303,12 @@ private static class InMemoryStoreFactory extends SslContextFactory { /** */ private int parameterChecks; + /** */ + private int bundleCalls; + + /** Failure to report while the store is unavailable. */ + private String loadFailure; + /** */ InMemoryStoreFactory(KeyStore store) { this.store = store; @@ -426,54 +320,27 @@ private static class InMemoryStoreFactory extends SslContextFactory { } /** {@inheritDoc} */ - @Override protected void checkParameters() throws SSLException { - parameterChecks++; - - super.checkParameters(); - } - - /** {@inheritDoc} */ - @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) { - loadKeyStoreCalls++; - - return store; - } + @Override public SslBundle createSslBundle() { + bundleCalls++; - /** @return Key managers supplied by the inherited legacy hook. */ - @SuppressWarnings("deprecation") - private KeyManager[] legacyKeyManagers() throws SSLException { - return super.createKeyManagers(); + return super.createSslBundle(); } - } - - /** A subclass that implements only the legacy manager hooks. */ - private static class LegacyFactory extends AbstractSslContextFactory { - /** */ - private int parameterChecks; - - /** */ - private int keyManagerCalls; - - /** */ - private int trustManagerCalls; /** {@inheritDoc} */ - @Override protected void checkParameters() { + @Override protected void checkParameters() throws SSLException { parameterChecks++; - } - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers() { - keyManagerCalls++; - - return null; + super.checkParameters(); } /** {@inheritDoc} */ - @Override protected TrustManager[] createTrustManagers() { - trustManagerCalls++; + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { + loadKeyStoreCalls++; + + if (loadFailure != null) + throw new SSLException(loadFailure); - return null; + return store; } } } From f1449e9593a0dd478109bbddd0f884c615a0d8c8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:19:56 +0300 Subject: [PATCH 058/242] IGNITE-29209 Reuse the communication SSL context across connections --- .../tcp/internal/ClusterStateProvider.java | 8 -- .../tcp/internal/GridNioServerWrapper.java | 10 +- .../ignite/ssl/SslContextReuseTest.java | 114 ++++++++++++++++++ .../ignite/testsuites/SecurityTestSuite.java | 2 + 4 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java index d4383c096cefb..017ae837389d8 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java @@ -19,7 +19,6 @@ import java.util.UUID; import java.util.function.Supplier; -import javax.net.ssl.SSLEngine; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteLogger; import org.apache.ignite.cluster.ClusterNode; @@ -108,13 +107,6 @@ public boolean isSslEnabled() { return ignite.configuration().getSslContextFactory() != null; } - /** - * @return {@link SSLEngine} for ssl connections. - */ - public SSLEngine createSSLEngine() { - return ignite.configuration().getSslContextFactory().create().createSSLEngine(); - } - /** * @return {@code true} if {@link TcpCommunicationSpi} stopped. */ diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index 9368f9f8a83db..7ec7ea0b0a0d8 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -40,6 +40,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteCheckedException; @@ -217,6 +218,9 @@ public class GridNioServerWrapper { private final int msgQueueWarningSize = IgniteSystemProperties.getInteger( IgniteSystemProperties.IGNITE_TCP_COMM_MSG_QUEUE_WARN_SIZE, 0); + /** Context shared by incoming and outgoing connections until the SPI stops. */ + private SSLContext sslCtx; + /** NIO server. */ private GridNioServer nioSrv; @@ -476,7 +480,7 @@ public GridNioSession createNioSession(ClusterNode node, int connIdx) throws Ign if (stateProvider.isSslEnabled()) { meta.put(SSL_META.ordinal(), sslMeta = new GridSslMeta()); - SSLEngine sslEngine = stateProvider.createSSLEngine(); + SSLEngine sslEngine = sslCtx.createSSLEngine(); sslEngine.setUseClientMode(true); @@ -803,6 +807,8 @@ public GridNioServer resetNioServer() throws IgniteCheckedException { if (cfg.boundTcpPort() >= 0) throw new IgniteCheckedException("Tcp NIO server was already created on port " + cfg.boundTcpPort()); + sslCtx = stateProvider.isSslEnabled() ? igniteCfg.getSslContextFactory().create() : null; + IgniteCheckedException lastEx = null; // If configured TCP port is busy, find first available in range. @@ -919,7 +925,7 @@ private IgniteMessageFactory get() { if (stateProvider.isSslEnabled()) { GridNioSslFilter sslFilter = U.sslFilter( - igniteCfg.getSslContextFactory().create(), + sslCtx, true, ByteOrder.LITTLE_ENDIAN, log, diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java new file mode 100644 index 0000000000000..93fb82c0110cb --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -0,0 +1,114 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.security.KeyStore; +import java.util.concurrent.atomic.AtomicInteger; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteException; +import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; +import org.apache.ignite.testframework.TestCertificateAuthority; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +/** Tests that communication connections reuse the context loaded at startup. */ +public class SslContextReuseTest extends GridCommonAbstractTest { + /** Discovery addresses shared only by the nodes in this test. */ + private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); + + /** {@inheritDoc} */ + @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { + IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName); + + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + + return cfg; + } + + /** {@inheritDoc} */ + @Override protected void afterTest() throws Exception { + stopAllGrids(); + + super.afterTest(); + } + + /** Opening and reopening a communication connection must not invoke the SSL factory again. */ + @Test + public void testCommunicationReusesContextOnReconnect() throws Exception { + TestCertificateAuthority ca = new TestCertificateAuthority("communication-ca"); + CountingFactory firstFactory = new CountingFactory(ca); + CountingFactory secondFactory = new CountingFactory(ca); + IgniteEx first = startGrid(0, cfg -> { + cfg.setSslContextFactory(firstFactory); + }); + IgniteEx second = startGrid(1, cfg -> { + cfg.setSslContextFactory(secondFactory); + }); + int firstStartupCalls = firstFactory.calls.get(); + int secondStartupCalls = secondFactory.calls.get(); + + assertEquals((Integer)1, first.compute(first.cluster().forNodeId(second.localNode().id())).call(() -> 1)); + + ConnectionClientPool pool = U.field(first.configuration().getCommunicationSpi(), "clientPool"); + + pool.forceCloseConnection(second.localNode().id()); + + assertEquals((Integer)1, first.compute(first.cluster().forNodeId(second.localNode().id())).call(() -> 1)); + assertEquals(firstStartupCalls, firstFactory.calls.get()); + assertEquals(secondStartupCalls, secondFactory.calls.get()); + } + + /** A factory that builds a fresh context on every call from mutually trusted test material. */ + private static class CountingFactory implements Factory { + /** */ + private static final long serialVersionUID = 0L; + + /** */ + private final KeyStore keys; + + /** */ + private final KeyStore trust; + + /** */ + private final AtomicInteger calls = new AtomicInteger(); + + /** @param ca Authority shared by both nodes. */ + CountingFactory(TestCertificateAuthority ca) throws Exception { + keys = ca.keyStoreWithPrivateKey(); + trust = ca.trustStore(); + } + + /** {@inheritDoc} */ + @Override public SSLContext create() { + calls.incrementAndGet(); + + try { + return TestCertificateAuthority.context(keys, trust); + } + catch (Exception e) { + throw new IgniteException(e); + } + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 13e9204fd95f8..eb9e1015b0d44 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -81,6 +81,7 @@ import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; import org.apache.ignite.ssl.SslContextMetadataTest; +import org.apache.ignite.ssl.SslContextReuseTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -145,6 +146,7 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextMetadataTest.class, + SslContextReuseTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, From e11f5b28475a81160707e0b7c234743b888f16f3 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:20:35 +0300 Subject: [PATCH 059/242] IGNITE-29209 Remove unused SSL context import --- .../src/main/java/org/apache/ignite/ssl/SslContextFactory.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 58da511294165..5a412b1272546 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -27,7 +27,6 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; From a4b484c496b6760ce6bdb59c4fe70b1649ae61ac Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:21:24 +0300 Subject: [PATCH 060/242] IGNITE-29210 Use the bundle factory contract directly --- docs/_docs/security/ssl-tls.adoc | 32 ++++++----- .../internal/ssl/SslContextProvider.java | 43 +++------------ .../internal/ssl/SslContextProviderTest.java | 53 +++---------------- 3 files changed, 29 insertions(+), 99 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 8e5fc2bdd69d9..d4377cc34d2e9 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -294,29 +294,27 @@ Reload leaves existing connections open. After preventing new connections with t This section is for developers integrating a certificate source, such as a PKI service. -To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] and override `createSslBundle()`. -Return a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. -Include every private key, even when the context can choose among several keys. +To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. +Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. +The `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. -If the source provides a key store, pass it with its key password and key manager algorithm to link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial], then call the protected `createSslBundle(KeyMaterial)` helper. -It builds the key managers and metadata from that store. +If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. +The base factory builds the context and metadata from the same store load. -If you build the context yourself, return it and its chains in an `SslBundle`. -Use `null` for unavailable metadata. An empty map reports that there are no private keys, so node reload rejects it. +The bundle includes every private-key alias, even when the context can choose among several keys. +An empty map reports that there are no private keys, so node reload rejects it. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory -The deprecated `create()` method keeps its cached context. Existing subclasses can still use it, the protected `sslCtx` cache and the deprecated `createKeyManagers()` hook. -The new `createSslBundle()` method requires an implementation; it does not adapt the old manager hook. +The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed. Update subclasses that use them and recompile before upgrading: -Overriding `createSslBundle()` does not change `create()`. -If a new factory also needs to serve callers of `Factory.create()`, provide an explicit `create()` adapter or implement the deprecated `createKeyManagers()` hook. +* Replace `createKeyManagers()` overrides with `loadKeyMaterial()`, returning the loaded store and its settings instead of key managers. +* If a custom `create()` override loads a store and constructs a context, move that loading into `loadKeyMaterial()` and use the base factory's bundle construction. The inherited `create()` adapter then uses the same implementation as node reload. +* Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. -To opt into fresh bundles, override `createSslBundle()`: +A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override; the inherited bundle method uses it. -* If the subclass only changes how `SslContextFactory` loads its store, delegate the override to `super.createSslBundle()`. -* If it builds its own context in `create()`, build a fresh context from the same source in `createSslBundle()` and return it with its metadata. - -A plain `Factory` can still support reload by returning a fresh context on every call. Returning the context already in use makes reload fail. -This legacy path supplies no metadata: status cannot report certificate dates, and reload cannot validate them. +If the source provides a ready-made `SSLContext` instead of a key store, use a plain `Factory`. +It can support reload by returning a fresh context on every call. Returning the context already in use makes reload fail. +It supplies no metadata: status cannot report certificate dates, and reload cannot validate them. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 3ecc9f013c979..e65ab128f7ae5 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -23,13 +23,11 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; -import java.util.function.Supplier; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslBundle; -import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; import static org.apache.ignite.internal.ssl.SslCertificates.describe; @@ -42,9 +40,6 @@ public class SslContextProvider { /** */ private final Set transports = new ConcurrentSkipListSet<>(); - /** Builds fresh bundles, or invokes an older factory through its original create method. */ - private final Supplier source; - /** Context and certificate metadata in use. */ private volatile SslBundle bundle; @@ -58,8 +53,7 @@ public class SslContextProvider { SslContextProvider(Factory factory) { this.factory = factory; - source = bundleSource(factory); - bundle = source.get(); + bundle = createBundle(); bundle.context().createSSLEngine(); sharedCtx = new CurrentSslContext(this::context); @@ -95,34 +89,9 @@ public String transports() { return bundle.certificateChains(); } - /** - * Custom factories opt into bundles by overriding createSslBundle(). Older subclasses keep create(), including a context - * placed directly in the protected cache. A more specific create() override must not be bypassed either. - * - * @param factory Configured factory. - * @return Source that preserves the factory's most specific creation method. - * @deprecated When the legacy factory cache is removed, use createSslBundle() for every AbstractSslContextFactory. - */ - @Deprecated - private static Supplier bundleSource(Factory factory) { - if (factory.getClass() == SslContextFactory.class) - return ((SslContextFactory)factory)::createSslBundle; - - if (factory instanceof AbstractSslContextFactory f) { - try { - Class createOwner = f.getClass().getMethod("create").getDeclaringClass(); - Class bundleOwner = f.getClass().getMethod("createSslBundle").getDeclaringClass(); - - if (bundleOwner != AbstractSslContextFactory.class && bundleOwner != SslContextFactory.class && - createOwner.isAssignableFrom(bundleOwner)) - return f::createSslBundle; - } - catch (NoSuchMethodException | SecurityException ignored) { - // Retain the original create() path when the creation methods cannot be inspected. - } - } - - return () -> new SslBundle(factory.create(), null); + /** @return A new bundle, with unknown certificate metadata for factories that only return a context. */ + private SslBundle createBundle() { + return factory instanceof AbstractSslContextFactory f ? f.createSslBundle() : new SslBundle(factory.create(), null); } /** @@ -158,11 +127,11 @@ public synchronized SslBundle reload() throws IgniteCheckedException { } /** - * @return Bundle built anew, or the context in use if an older factory caches it. + * @return Bundle built anew, or the context in use if a custom factory caches it. * @throws IgniteCheckedException If the loaded material is known to have no private key. */ private SslBundle build() throws IgniteCheckedException { - SslBundle rebuilt = source.get(); + SslBundle rebuilt = createBundle(); Map> chains = rebuilt.certificateChains(); if (chains != null && chains.isEmpty()) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 644869f43b00f..b78476daddb40 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -34,7 +34,6 @@ import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.KeyMaterial; import org.apache.ignite.ssl.SslBundle; -import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; @@ -72,57 +71,21 @@ public class SslContextProviderTest extends GridCommonAbstractTest { stopAllGrids(); } - /** An existing create override still controls the context, including its cache. Reload must not silently bypass it. */ + /** The node retains a bundle until reload and gets metadata even when the factory overrides only its store loader. */ @Test - public void testLegacyCreateOverrideIsPreserved() throws Exception { - SSLContext cached = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { - @Override public SSLContext create() { - return cached; - } - }; - SslContextProvider provider = new SslContextProvider(factory); - - assertSame(cached, provider.context()); - assertNull(provider.certificateChains()); - GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "returns the context in use"); - assertSame(cached, provider.context()); - assertEquals(0, factory.calls); - } - - /** An old file-factory subclass may supply its cached context without configuring any store paths. */ - @Test - public void testLegacyPreloadedContextIsPreserved() throws Exception { - SSLContext prepared = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - SslContextFactory factory = new SslContextFactory() { - { - sslCtx.set(prepared); - } - }; - SslContextProvider provider = new SslContextProvider(factory); - - assertSame(prepared, provider.context()); - assertNull(provider.certificateChains()); - GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "returns the context in use"); - assertSame(prepared, provider.context()); - } - - /** A factory that overrides both methods opts into bundles, even if its old create method still caches. */ - @Test - public void testBundleOverrideOptsIntoReload() throws Exception { + public void testNodeKeepsBundleUntilReload() throws Exception { ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { @Override public SSLContext create() { throw new AssertionError("The node must use createSslBundle()"); } - - @Override public SslBundle createSslBundle() { - return super.createSslBundle(); - } }; SslContextProvider provider = new SslContextProvider(factory); SSLContext first = provider.context(); + assertSame(first, provider.context()); + assertNotNull(provider.certificateChains()); + assertEquals(1, factory.calls); + provider.reload(); assertNotSame(first, provider.context()); @@ -509,13 +472,13 @@ private ScriptedSslContextFactory(Callable... steps) { } /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override protected KeyMaterial loadKeyMaterial() { Callable step = steps.get(Math.min(calls++, steps.size() - 1)); try { KeyStore store = step.call(); - return createSslBundle(new KeyMaterial(store, TestCertificateAuthority.PWD, DFLT_KEY_ALGORITHM)); + return new KeyMaterial(store, TestCertificateAuthority.PWD, DFLT_KEY_ALGORITHM); } catch (IgniteException e) { throw e; From 544fea355f0165bdf1a0a54de700a8c101b06386 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:22:13 +0300 Subject: [PATCH 061/242] IGNITE-29211 Load renewal material through the bundle factory --- docs/_docs/security/ssl-tls.adoc | 32 +++++++++---------- .../ignite/internal/ssl/SslRenewalTest.java | 14 ++++---- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 30758a4eb62fd..e976a341b469e 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -343,31 +343,29 @@ The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] me This section is for developers integrating a certificate source, such as a PKI service. -To support reload and automatic renewal with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory] and override `createSslBundle()`. -Return a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. -Include every private key, even when the context can choose among several keys. +To support reload and automatic renewal with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. +Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. +The `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. -If the source provides a key store, pass it with its key password and key manager algorithm to link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial], then call the protected `createSslBundle(KeyMaterial)` helper. -It builds the key managers and metadata from that store. +If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. +The base factory builds the context and metadata from the same store load. -If you build the context yourself, return it and its chains in an `SslBundle`. -Use `null` for unavailable metadata. An empty map reports that there are no private keys, so node reload rejects it. +The bundle includes every private-key alias, even when the context can choose among several keys. +An empty map reports that there are no private keys, so node reload rejects it. With automatic renewal enabled, every private key must have a nonempty chain at startup and on each reload. Without renewal, metadata is optional. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory -The deprecated `create()` method keeps its cached context. Existing subclasses can still use it, the protected `sslCtx` cache and the deprecated `createKeyManagers()` hook. -The new `createSslBundle()` method requires an implementation; it does not adapt the old manager hook. +The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed. Update subclasses that use them and recompile before upgrading: -Overriding `createSslBundle()` does not change `create()`. -If a new factory also needs to serve callers of `Factory.create()`, provide an explicit `create()` adapter or implement the deprecated `createKeyManagers()` hook. +* Replace `createKeyManagers()` overrides with `loadKeyMaterial()`, returning the loaded store and its settings instead of key managers. +* If a custom `create()` override loads a store and constructs a context, move that loading into `loadKeyMaterial()` and use the base factory's bundle construction. The inherited `create()` adapter then uses the same implementation as node reload. +* Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. -To opt into fresh bundles, override `createSslBundle()`: +A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override; the inherited bundle method uses it. -* If the subclass only changes how `SslContextFactory` loads its store, delegate the override to `super.createSslBundle()`. -* If it builds its own context in `create()`, build a fresh context from the same source in `createSslBundle()` and return it with its metadata. - -A plain `Factory` can still support manual reload by returning a fresh context on every call. Returning the context already in use makes reload fail. -This legacy path supplies no metadata: status cannot report certificate dates, and reload cannot validate them. +If the source provides a ready-made `SSLContext` instead of a key store, use a plain `Factory`. +It can support manual reload by returning a fresh context on every call. Returning the context already in use makes reload fail. +It supplies no metadata: status cannot report certificate dates, and reload cannot validate them. diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index a32392f76f815..38e89f5e9d8cd 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -1666,17 +1666,19 @@ private Issuer(Callable... steps) { /** {@inheritDoc} */ @Override public SslBundle createSslBundle() { + SslBundle bundle = super.createSslBundle(); + + return metadata ? bundle : new SslBundle(bundle.context(), null); + } + + /** {@inheritDoc} */ + @Override protected KeyMaterial loadKeyMaterial() { Callable step = steps.get(Math.min(calls.size(), steps.size() - 1)); calls.add(new Call()); try { - KeyStore store = step.call(); - - SslBundle bundle = createSslBundle(new KeyMaterial(store, TestCertificateAuthority.PWD, - KeyManagerFactory.getDefaultAlgorithm())); - - return metadata ? bundle : new SslBundle(bundle.context(), null); + return new KeyMaterial(step.call(), TestCertificateAuthority.PWD, KeyManagerFactory.getDefaultAlgorithm()); } catch (IgniteException e) { throw e; From 9fc0b6974f0a59e0aaafe5ae2055e0c9c697874d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 19:23:04 +0300 Subject: [PATCH 062/242] IGNITE-29210 Clarify the bundle ownership test --- .../org/apache/ignite/internal/ssl/SslContextProviderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index b78476daddb40..6ac957e1da088 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -71,7 +71,7 @@ public class SslContextProviderTest extends GridCommonAbstractTest { stopAllGrids(); } - /** The node retains a bundle until reload and gets metadata even when the factory overrides only its store loader. */ + /** The node retains a bundle until reload and uses metadata from the factory's store loader. */ @Test public void testNodeKeepsBundleUntilReload() throws Exception { ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { From e0dd39eba6118fe37ffb6f3ff1088678ec2cc55e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 22:09:24 +0300 Subject: [PATCH 063/242] IGNITE-29209 Clarify key material ownership and factory customization --- .../ignite/ssl/AbstractSslContextFactory.java | 27 ++- .../org/apache/ignite/ssl/KeyMaterial.java | 6 +- .../java/org/apache/ignite/ssl/SslBundle.java | 6 +- .../apache/ignite/ssl/SslContextFactory.java | 13 +- .../GridCommandHandlerWithSslFactoryTest.java | 5 +- modules/core/pom.xml | 2 +- .../ignite/ssl/SslContextMetadataTest.java | 177 ++++++++++++++++-- .../ignite/ssl/SslContextReuseTest.java | 15 -- .../TestCertificateAuthority.java | 2 +- parent/pom.xml | 1 + 10 files changed, 204 insertions(+), 50 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index a7c65a65cccc0..d63cd81492b83 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -27,7 +27,6 @@ import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; -import org.jetbrains.annotations.Nullable; /** * Builds a fresh SSL context and its certificate metadata on each call. The component using the context is responsible for @@ -118,7 +117,7 @@ public SslBundle createSslBundle() { KeyMaterial material = loadKeyMaterial(); - return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); + return new SslBundle(createSslContext(createKeyManagers(material)), material.certificateChains()); } catch (SSLException e) { throw new IgniteException(e); @@ -126,11 +125,13 @@ public SslBundle createSslBundle() { } /** - * @param keyMgrs Key managers, or {@code null} to use the provider's defaults. + * @param keyMgrs Key managers for the loaded material. * @return Initialized context. * @throws SSLException If trust managers or the context cannot be created. */ - private SSLContext createSslContext(@Nullable KeyManager[] keyMgrs) throws SSLException { + private SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { + checkNullParameter(keyMgrs, "keyManagers"); + TrustManager[] trustMgrs = createTrustManagers(); try { @@ -179,12 +180,24 @@ protected void checkNullParameter(Object param, String name) throws SSLException /** * Loads the key store and the settings used to read its keys. Called once per context, after the parameters are checked. + * For a context without private keys, return material over an empty store initialized with {@code KeyStore.load(null, null)}. * - * @return Loaded key material. + * @return Loaded key material, never {@code null}. * @throws SSLException If the key material cannot be loaded. */ protected abstract KeyMaterial loadKeyMaterial() throws SSLException; + /** + * Creates key managers from the loaded material. Override to customize key selection without reloading the store. + * + * @param material Loaded key material whose certificate chains will be included in the bundle. + * @return Key managers using that material, never {@code null}. + * @throws SSLException If the key managers cannot be created. + */ + protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { + return material.keyManagers(); + } + /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. @@ -192,12 +205,12 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Builds a fresh context through {@link #createSslBundle()}. + * Builds a fresh context through {@link #createSslBundle()}. Override that method to customize both entry points. * * @return Fresh initialized context. * @throws IgniteException If the context could not be created. */ - @Override public SSLContext create() { + @Override public final SSLContext create() { return createSslBundle().context(); } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index a07796e8fe2de..5c0f46df5fb2a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -37,7 +37,8 @@ /** * A loaded key store and the settings needed to create its key managers. Both managers and certificate metadata are read from - * this store without loading it again. The caller must not modify the store while building a context and its metadata. + * this store without loading it again. Do not modify the store while any context created from it is in use: key managers + * such as PKIX read it during handshakes. Load replacement material into a separate store. */ public final class KeyMaterial { /** */ @@ -50,7 +51,8 @@ public final class KeyMaterial { private final String algorithm; /** - * @param store Loaded key store. + * @param store Loaded key store, never {@code null}. For a context without private keys, initialize an empty store with + * {@code KeyStore.load(null, null)}. * @param keyPwd Password for its private keys, or {@code null} if the store requires no password. * @param algorithm Key manager algorithm. */ diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java index e328dfeb176cc..3646806c3f618 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -40,8 +40,8 @@ public final class SslBundle { /** * @param ctx Initialized SSL context. * @param chains Chains by private-key alias, own certificate first. {@code null} means the aliases are unknown; - * an empty map means there are no private keys. An empty list means that alias has a private key but its X.509 chain - * is unknown. Trusted certificate entries and secret keys must not be included. + * an empty map means there are no private keys. An empty list means the alias has a private key but its chain is absent + * or contains a non-X.509 certificate. Trusted certificate entries and secret keys must not be included. */ public SslBundle(SSLContext ctx, @Nullable Map> chains) { A.notNull(ctx, "ctx"); @@ -66,7 +66,7 @@ public SSLContext context() { /** * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private keys; - * an empty list means that alias has a private key with an unknown X.509 chain. + * an empty list means the alias's chain is absent or contains a non-X.509 certificate. */ public @Nullable Map> certificateChains() { return chains; diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 5a412b1272546..181160a5c64b3 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -27,6 +27,7 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -263,6 +264,16 @@ public static TrustManager getDisabledTrustManager() { return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { + try { + return super.createKeyManagers(material); + } + catch (SSLException e) { + throw new SSLException(e.getMessage() + " [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + } + } + /** {@inheritDoc} */ @Override protected final TrustManager[] createTrustManagers() throws SSLException { if (trustMgrs != null) @@ -341,7 +352,7 @@ protected InputStream openFileInputStream(String filePath) throws IOException { * @param keyStoreType Type of key store. * @param storeFilePath Path to key store file. * @param keyStorePwd Store password. - * @return Initialized key store. + * @return Initialized key store, never {@code null}. Return an empty loaded store for a context without private keys. * @throws SSLException If key store could not be initialized. */ protected KeyStore loadKeyStore(String keyStoreType, String storeFilePath, char[] keyStorePwd) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java index ed2828b8af1e7..78d9b2be5edeb 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java @@ -21,6 +21,7 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.junit.AfterClass; @@ -92,10 +93,10 @@ public static void tearDown() { */ public static class SslFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override public SSLContext create() { + @Override public SslBundle createSslBundle() { factoryUsed = true; - return super.create(); + return super.createSslBundle(); } } diff --git a/modules/core/pom.xml b/modules/core/pom.xml index 47fd6cc45de8b..f2b464d91bd6e 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -161,7 +161,7 @@ org.bouncycastle bcpkix-jdk18on - 1.86 + ${bouncycastle.version} test diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 290268eae507c..91a09d242b9ba 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -17,24 +17,36 @@ package org.apache.ignite.ssl; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import java.security.PublicKey; +import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; @@ -42,9 +54,12 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertNotEquals; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; -/** Tests that a bundle's certificate metadata belongs to the key material used to create its context. */ +/** Tests that each context loads its key store once and retains its certificate metadata. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Store and key password of the generated test material. */ private static final char[] PASSWORD = TestCertificateAuthority.PWD; @@ -57,7 +72,7 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each create or bundle request loads fresh material once and leaves earlier bundles unchanged. */ + /** Each create or bundle request loads fresh material once. */ @Test public void testFreshContextsLoadMaterialOnce() throws Exception { String originalAlias = "original"; @@ -80,8 +95,6 @@ public void testFreshContextsLoadMaterialOnce() throws Exception { KeyStore replacementKeys = ca.keyStoreWithPrivateKey(replacementAlias); List replacementChain = Arrays.asList(certificateChain(replacementKeys)); - assertNotEquals(originalChain.get(0), replacementChain.get(0)); - factory.store = replacementKeys; SslBundle fresh = factory.createSslBundle(); @@ -89,7 +102,6 @@ public void testFreshContextsLoadMaterialOnce() throws Exception { assertNotSame(created, fresh.context()); assertNotSame(original.context(), fresh.context()); assertEquals(Map.of(replacementAlias, replacementChain), fresh.certificateChains()); - assertEquals(Map.of(originalAlias, originalChain), original.certificateChains()); assertEquals(3, factory.loadKeyStoreCalls); assertEquals(3, factory.parameterChecks); @@ -154,22 +166,27 @@ public void testOnlyPrivateKeyEntriesCount() throws Exception { store.setCertificateEntry("trusted", keys.getCertificate(keyAlias)); store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), new KeyStore.PasswordProtection(PASSWORD)); + store.setKeyEntry(keyAlias, keys.getKey(keyAlias, PASSWORD), PASSWORD, keys.getCertificateChain(keyAlias)); - InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - SslBundle withoutKey = factory.createSslBundle(); + SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); - assertEquals(Map.of(), withoutKey.certificateChains()); - assertNotNull(withoutKey.context().getSocketFactory()); + assertEquals(Map.of(keyAlias, Arrays.asList(certificateChain(keys))), bundle.certificateChains()); + } - store.setKeyEntry(keyAlias, keys.getKey(keyAlias, PASSWORD), PASSWORD, keys.getCertificateChain(keyAlias)); + /** An empty loaded store creates a context without private keys. */ + @Test + public void testEmptyStoreCreatesKeylessContext() throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); - SslBundle withKey = factory.createSslBundle(); + store.load(null, null); - assertEquals(Map.of(keyAlias, Arrays.asList(certificateChain(keys))), withKey.certificateChains()); - assertEquals("The earlier bundle remains keyless", Map.of(), withoutKey.certificateChains()); + SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); + + assertEquals(Map.of(), bundle.certificateChains()); + assertNotNull(bundle.context().getSocketFactory()); } - /** A private key with a non-X.509 chain is retained as an alias with unknown certificate metadata. */ + /** A private key with a non-X.509 chain retains its alias with an empty chain. */ @Test public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { String alias = "private-key"; @@ -264,7 +281,7 @@ public void testLoadedMaterialCopiesKeyPassword() throws Exception { assertArrayEquals(certificateChain(store), mgr.getCertificateChain(alias)); } - /** Mutating the source map, its lists or the store cannot change an existing bundle. */ + /** Mutating the source map or its lists cannot change the bundle's certificate metadata. */ @Test public void testImmutableCertificateMetadata() throws Exception { String alias = "node"; @@ -280,9 +297,7 @@ public void testImmutableCertificateMetadata() throws Exception { inputChain.clear(); input.clear(); - store.deleteEntry(alias); - assertEquals(Map.of(alias, expected), loaded.certificateChains()); assertEquals(Map.of(alias, expected), bundle.certificateChains()); assertSame(loaded.context(), bundle.context()); @@ -292,6 +307,132 @@ public void testImmutableCertificateMetadata() throws Exception { UnsupportedOperationException.class, null); } + /** A factory that cannot report its private-key aliases keeps that distinction in the bundle. */ + @Test + public void testBundleKeepsUnknownMetadata() throws Exception { + SSLContext ctx = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()).create(); + + assertNull(new SslBundle(ctx, null).certificateChains()); + } + + /** Alias order does not depend on the map supplied by a factory. */ + @Test + public void testBundleOrdersAliases() throws Exception { + String firstAlias = "a"; + String secondAlias = "b"; + KeyStore store = ca.keyStoreWithPrivateKey(firstAlias); + List chain = Arrays.asList(certificateChain(store)); + + store.setKeyEntry(secondAlias, store.getKey(firstAlias, PASSWORD), PASSWORD, store.getCertificateChain(firstAlias)); + + SSLContext ctx = new InMemoryStoreFactory(store).create(); + Map> chains = new LinkedHashMap<>(); + + chains.put(secondAlias, chain); + chains.put(firstAlias, chain); + + assertEquals(List.of(firstAlias, secondAlias), new ArrayList<>(new SslBundle(ctx, chains).certificateChains().keySet())); + } + + /** A manager decorator participates in TLS without a second store load. */ + @Test + public void testCustomKeyManagerUsedByContext() throws Exception { + String alias = "server-key"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); + AtomicReference customMgr = new AtomicReference<>(); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store) { + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { + X509ExtendedKeyManager mgr = mock(X509ExtendedKeyManager.class, delegatesTo(super.createKeyManagers(material)[0])); + + customMgr.set(mgr); + + return new KeyManager[] {mgr}; + } + }; + SslBundle bundle = factory.createSslBundle(); + SSLContext clientCtx = SSLContext.getInstance("TLS"); + + clientCtx.init(new KeyManager[0], TestCertificateAuthority.trustManagers(ca.trustStore()), null); + + int timeout = 10_000; + + try (SSLServerSocket listener = (SSLServerSocket)bundle.context().getServerSocketFactory() + .createServerSocket(0, 0, InetAddress.getLoopbackAddress())) { + listener.setSoTimeout(timeout); + + IgniteInternalFuture accepted = GridTestUtils.runAsync(() -> { + try (SSLSocket server = (SSLSocket)listener.accept()) { + server.setSoTimeout(timeout); + server.startHandshake(); + } + + return null; + }); + + try (SSLSocket client = (SSLSocket)clientCtx.getSocketFactory() + .createSocket(listener.getInetAddress(), listener.getLocalPort())) { + client.setSoTimeout(timeout); + client.startHandshake(); + + assertArrayEquals(certificateChain(store), client.getSession().getPeerCertificates()); + } + + accepted.get(timeout); + } + + verify(customMgr.get(), atLeastOnce()).getPrivateKey(alias); + assertEquals(1, factory.loadKeyStoreCalls); + assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); + } + + /** A null manager result is rejected instead of silently using the JVM's default keys. */ + @Test + public void testNullKeyManagersRejected() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()) { + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers(KeyMaterial material) { + return null; + } + }; + + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "keyManagers"); + + assertTrue(err.getCause() instanceof SSLException); + } + + /** An unreadable private key reports the source store and preserves the original cause. */ + @Test + public void testKeyPasswordFailureNamesStore() throws Exception { + String alias = "private-key"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); + + store.setKeyEntry(alias, store.getKey(alias, PASSWORD), "different-key-password".toCharArray(), store.getCertificateChain(alias)); + + Path path = Files.createTempFile("unreadable-key", ".jks"); + + try { + TestCertificateAuthority.save(store, path); + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreType(store.getType()); + factory.setKeyStoreFilePath(path.toString()); + factory.setKeyStorePassword(PASSWORD); + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, null); + + assertTrue(err.getCause() instanceof SSLException); + assertTrue(err.getCause().getMessage().contains(path.toString())); + assertTrue(err.getCause().getMessage().contains("type=" + store.getType())); + assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); + } + finally { + Files.delete(path); + } + } + /** A file factory whose store-loading hook supplies the test material. */ private static class InMemoryStoreFactory extends SslContextFactory { /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 93fb82c0110cb..657415be1cbba 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -22,30 +22,15 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteException; -import org.apache.ignite.configuration.IgniteConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; -import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; -import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; /** Tests that communication connections reuse the context loaded at startup. */ public class SslContextReuseTest extends GridCommonAbstractTest { - /** Discovery addresses shared only by the nodes in this test. */ - private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); - - /** {@inheritDoc} */ - @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { - IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName); - - ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); - - return cfg; - } - /** {@inheritDoc} */ @Override protected void afterTest() throws Exception { stopAllGrids(); diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index c7d9b71788eda..4cd2231af81a3 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -138,7 +138,7 @@ public KeyStore keyStoreWithPrivateKey(String cn, long notBefore, long notAfter) * @param notAfter Time it expires; rounded down to a second. * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. */ - public KeyStore keyStoreWithPrivateKey(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) + private KeyStore keyStoreWithPrivateKey(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) throws Exception { X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); diff --git a/parent/pom.xml b/parent/pom.xml index 17318b8d34efb..48624ec8905de 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -68,6 +68,7 @@ false + 1.86 1.23.0 3.2.2 2.22.0 From 015fa865e12ece25bff8ff627911b84a082d5304 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 22:09:50 +0300 Subject: [PATCH 064/242] IGNITE-29211 Distinguish unusable keys from missing renewal metadata --- docs/_docs/security/ssl-tls.adoc | 29 ++++++---- docs/_docs/tools/control-script.adoc | 4 +- .../internal/ssl/SslContextProvider.java | 36 ++++++++++-- .../ignite/internal/ssl/SslRenewalTest.java | 57 +++++++------------ 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index e976a341b469e..026c8127246e9 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -180,8 +180,12 @@ For automatically delivered certificates, see <> and run reload after === Prepare Certificate Delivery -The delivery system must make a valid replacement available before renewal is due. Ignite reads the files when renewal is due, not when they change. +Renewal follows the first certificate to expire across all private-key chains, including CA certificates. +For that chain, the renewal window is the fraction `renewBeforeFraction` of the time from its leaf certificate's start date to its earliest expiry. Ignite starts requesting replacements when this window opens; `control.sh --ssl status` shows the next attempt time. If several chains expire together, the earliest window applies. -Renewal follows the first certificate to expire across all private-key chains, including CA certificates. If several chains expire together, the earliest renewal window applies. +The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. With `SslContextFactory`, include the X.509 chain of every private key in the key store. A custom factory must supply all these chains with each context. @@ -345,26 +350,28 @@ This section is for developers integrating a certificate source, such as a PKI s To support reload and automatic renewal with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. -The `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. +The final `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. +Nodes call `createSslBundle()` directly; callers using `Factory` obtain its context through `create()`. If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. -The base factory builds the context and metadata from the same store load. +The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. +To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. The bundle includes every private-key alias, even when the context can choose among several keys. -An empty map reports that there are no private keys, so node reload rejects it. +An empty map reports that there are no private keys. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. With automatic renewal enabled, every private key must have a nonempty chain at startup and on each reload. Without renewal, metadata is optional. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory -The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed. Update subclasses that use them and recompile before upgrading: +The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed, and `create()` is now final. Update affected subclasses and recompile before upgrading: -* Replace `createKeyManagers()` overrides with `loadKeyMaterial()`, returning the loaded store and its settings instead of key managers. -* If a custom `create()` override loads a store and constructs a context, move that loading into `loadKeyMaterial()` and use the base factory's bundle construction. The inherited `create()` adapter then uses the same implementation as node reload. +* Move store loading from `createKeyManagers()` into `loadKeyMaterial()`, returning the loaded store and its settings. Move key-manager customization into `createKeyManagers(KeyMaterial)`. +* Remove `create()` overrides. Move store loading into `loadKeyMaterial()`. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. * Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. -A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override; the inherited bundle method uses it. +A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. If the source provides a ready-made `SSLContext` instead of a key store, use a plain `Factory`. It can support manual reload by returning a fresh context on every call. Returning the context already in use makes reload fail. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 45d27314830b4..6794658445284 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1824,8 +1824,8 @@ Status does not test connections or verify that other nodes and clients trust th |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` |The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. -|`alias=...: certificate chain is unavailable` -|The source has this private key but did not supply its X.509 chain. Check the chain in the certificate source. +|`alias=...: certificate chain is absent or is not X.509` +|This private-key entry has no usable X.509 certificate chain. Add its X.509 chain or remove the unusable private-key entry, then run reload. Reload rejects a replacement that still contains such an entry. |`no private keys` |The loaded source contains no private key. Install the node's certificate and private key, then run reload. |`no SSL context factory is configured` diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 27fe0cf2af3e1..0dee28c841218 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -125,6 +125,13 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { if (renewBeforeFraction == 0) return; + try { + checkKeyMaterial(bundle.certificateChains()); + } + catch (IgniteCheckedException e) { + throw new IgniteException(e.getMessage(), e); + } + if (renewalChain(bundle) == null) { throw new IgniteException("The SSL context factory must provide a nonempty certificate chain for every private key " + "to renew automatically [transports=" + transports() + ']'); @@ -136,7 +143,7 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { /** * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no - * private key. If a certificate chain is supplied, every certificate must be within its validity period. + * private key or a key without an X.509 certificate chain. If a certificate chain is supplied, every certificate must be within its validity period. * Certificate metadata is required when automatic renewal is enabled. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * @@ -242,7 +249,7 @@ private void putInUse(SslBundle rebuilt) { * Chooses the first chain to expire, using the earliest leaf validity start to break ties. * * @param bundle Context and its chains. - * @return Chain that determines renewal, or {@code null} unless every private key has a known, nonempty chain. + * @return Chain that determines renewal, or {@code null} unless every private key has a nonempty X.509 chain. */ static @Nullable List renewalChain(SslBundle bundle) { Map> chains = bundle.certificateChains(); @@ -271,20 +278,37 @@ private void putInUse(SslBundle rebuilt) { /** * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material is known to have no private key. + * @throws IgniteCheckedException If the loaded material has no private key or a key has no X.509 certificate chain. */ private SslBundle build() throws IgniteCheckedException { SslBundle rebuilt = createBundle(); - Map> chains = rebuilt.certificateChains(); - if (chains != null && chains.isEmpty()) - throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + checkKeyMaterial(rebuilt.certificateChains()); rebuilt.context().createSSLEngine(); return rebuilt; } + /** + * @param chains Chains by private-key alias, {@code null} if the factory supplies no metadata. + * @throws IgniteCheckedException If there is no private key or a key has no X.509 certificate chain. + */ + private static void checkKeyMaterial(@Nullable Map> chains) throws IgniteCheckedException { + if (chains == null) + return; + + if (chains.isEmpty()) + throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + + for (Map.Entry> entry : chains.entrySet()) { + if (entry.getValue().isEmpty()) { + throw new IgniteCheckedException("The private key has no X.509 certificate chain; remove the key or provide its " + + "X.509 certificate chain [alias=" + entry.getKey() + ']'); + } + } + } + /** * @param next Bundle loaded by the factory. * @param requireChains Whether renewal requires known chains for every private key. diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 38e89f5e9d8cd..10b908069bf30 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -313,8 +313,6 @@ public void testLateFailureLoggedAsError() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure()); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startNode(issuer); String late = "Failed to reload TLS certificates, the ones in use stay and expire soon [" + NODE_TRANSPORTS + @@ -381,8 +379,6 @@ public void testRenewalLogsAppliedCertificate() throws Exception { () -> ca.keyStoreWithPrivateKey(renewedName, now, now + HOUR), () -> ca.keyStoreWithPrivateKey(manualName, now, now + 2 * HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - SslContextProvider p = new SslContextProvider(issuer) { @Override SslBundle renew(SSLContext expected) throws IgniteCheckedException { SslBundle renewed = super.renew(expected); @@ -461,8 +457,6 @@ public void testReloadDuringRenewalPlanning() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(2 * HOUR), fresh(3 * HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - CountDownLatch planRead = new CountDownLatch(1); CountDownLatch continuePlanning = new CountDownLatch(1); CountDownLatch reloaded = new CountDownLatch(1); @@ -562,8 +556,6 @@ public void testReloadByCommandReplans() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure(), fresh(2 * HOUR), fresh(HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> logged(Level.WARN, ISSUER_DOWN) == 1, 30_000)); @@ -599,8 +591,6 @@ public void testReloadContextNotCarriedIntoRenewals() throws Exception { Issuer issuer = new Issuer(fresh(HOUR), valid(notAfter - lifetime, notAfter), fresh(HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startNode(issuer); try (Scope ignored = OperationContext.set(OPERATOR, operator)) { @@ -630,8 +620,6 @@ public void testFailedRenewalRetried() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure(), failure(), failure(), failure(), failure(), failure(), fresh(HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - pauses(minPause, maxPause); IgniteEx g = startNode(issuer); @@ -695,8 +683,6 @@ public void testRenewalsNoMoreOftenThanMinPause() throws Exception { Issuer issuer = new Issuer(dueOnIssue); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - pauses(minPause, minPause); startNode(issuer); @@ -732,8 +718,6 @@ public void testClientConnectorRenewed() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(HOUR)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startClientConnectorOn(issuer); assertTrue(waitForCondition(() -> automaticRenewals("transports=" + CLIENT_CONNECTOR) == 1, 30_000)); @@ -762,7 +746,6 @@ public void testRenewalOffByDefault() throws Exception { Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter)); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); issuer.setRenewalEnabled(false); IgniteEx g = startNode(issuer); @@ -843,8 +826,6 @@ public void testWaitsForNewerCertificate() throws Exception { Issuer issuer = new Issuer(() -> inUse); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startNode(issuer); assertTrue(waitForCondition(() -> provider(g).renewalFailures() >= 5, 30_000)); @@ -942,8 +923,6 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { return null; }); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g = startNode(issuer); SslContextProvider p = provider(g); @@ -1189,7 +1168,6 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { factory.setKeyStorePassword(TestCertificateAuthority.PWD); factory.setKeyStoreType("JKS"); factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); - factory.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); factory.setRenewalEnabled(true); IgniteEx g = startNode(factory); @@ -1209,14 +1187,23 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { /** Automatic renewal without certificate metadata must stop the node from starting. */ @Test public void testUnknownCertificateRefusedAtStart() { - Issuer issuer = new Issuer(SslRenewalTest::noKey); + Issuer issuer = new Issuer(fresh(HOUR)); - issuer.setRenewalEnabled(true); + issuer.metadata = false; GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, "The SSL context factory must provide a nonempty certificate chain for every private key"); } + /** Automatic renewal cannot start when the loaded store contains no private key. */ + @Test + public void testKeyStoreWithoutKeyRefusedAtStart() { + Issuer issuer = new Issuer(SslRenewalTest::noKey); + + GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, + "The new key material has no private key, so the node would present no certificate"); + } + /** Renewal rejects a store without a private key and retains the context and chain in use. */ @Test public void testKeyStoreWithoutKeyNotRenewed() { @@ -1247,26 +1234,29 @@ public void testUnknownCertificateWithoutRenewal() throws Exception { /** Known aliases with a missing secondary chain cannot start or replace an automatically renewed context. */ @Test public void testMissingSecondaryChainRefused() throws Exception { - String knownAlias = "known"; - String unknownAlias = "unknown"; + String completeAlias = "complete-key"; + String incompleteAlias = "key-without-chain"; AtomicBoolean missing = new AtomicBoolean(); - KeyStore keys = ca.keyStoreWithPrivateKey(knownAlias); + KeyStore keys = ca.keyStoreWithPrivateKey(completeAlias); - addKey(keys, ca.keyStoreWithPrivateKey(unknownAlias), unknownAlias); + addKey(keys, ca.keyStoreWithPrivateKey(incompleteAlias), incompleteAlias); Issuer issuer = new Issuer(() -> keys) { @Override public SslBundle createSslBundle() { SslBundle loaded = super.createSslBundle(); return missing.get() ? new SslBundle(loaded.context(), - Map.of(knownAlias, loaded.certificateChains().get(knownAlias), unknownAlias, List.of())) : loaded; + Map.of(completeAlias, loaded.certificateChains().get(completeAlias), incompleteAlias, List.of())) : loaded; } }; SslContextProvider p = new SslContextProvider(issuer); missing.set(true); - assertRenewalRejected(p, "The new SSL context must provide a nonempty certificate chain for every private key"); + String reason = "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate chain " + + "[alias=" + incompleteAlias + ']'; + + assertRenewalRejected(p, reason); SslContextProvider incomplete = new SslContextProvider(issuer); ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1); @@ -1276,7 +1266,7 @@ public void testMissingSecondaryChainRefused() throws Exception { incomplete.startRenewal(exec, log); return null; - }, IgniteException.class, "The SSL context factory must provide a nonempty certificate chain for every private key"); + }, IgniteException.class, reason); assertEquals(0, incomplete.nextRenewalTime()); } @@ -1291,9 +1281,6 @@ public void testMissingSecondaryChainRefused() throws Exception { @Test public void testReloadCannotLoseMetadataWithRenewal() { Issuer issuer = new Issuer(fresh(HOUR)); - - issuer.setRenewalEnabled(true); - SslContextProvider p = new SslContextProvider(issuer); issuer.setRenewalEnabled(false); @@ -1391,8 +1378,6 @@ private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthorit Issuer issuer = issuer(keys::get, peerCa.trustStore(), true); Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore(), false); - issuer.setRenewBeforeFraction(DFLT_RENEW_BEFORE_FRACTION); - IgniteEx g0 = startNode(0, issuer); IgniteEx g1 = startNode(1, peerIssuer); From e28ac2bc099d3585e0d984fe1d4d652c28bd2766 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 22:12:12 +0300 Subject: [PATCH 065/242] IGNITE-29210 Reject unusable key chains and retain communication ownership --- docs/_docs/security/ssl-tls.adoc | 24 +++++---- docs/_docs/tools/control-script.adoc | 4 +- .../ignite/ssl/AbstractSslContextFactory.java | 5 +- .../management/ssl/SslReloadTask.java | 2 +- .../management/ssl/SslStatusTask.java | 2 +- .../ignite/internal/ssl/SslCertificates.java | 2 +- .../internal/ssl/SslContextProvider.java | 28 +++++++++-- .../internal/ssl/SslContextRegistry.java | 2 +- .../tcp/internal/ClusterStateProvider.java | 23 --------- .../tcp/internal/GridNioServerWrapper.java | 13 ++++- .../internal/ssl/SslContextProviderTest.java | 49 +++++++++++++++++-- .../ignite/ssl/SslContextReloadTest.java | 42 +++++++++++++++- 12 files changed, 143 insertions(+), 53 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index d4377cc34d2e9..835c16b5bcdc1 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -179,8 +179,12 @@ Use link:tools/control-script#reloading-certificates[`control.sh --ssl reload`] If `trustManagers` is configured on the factory, it controls trust instead of a trust store file. Reload keeps those managers. Follow their implementation's procedure to change which certificates they accept. -When the factory supplies certificate chains, reload checks every chain and rejects the replacement if any certificate is expired or not yet valid. The previous context stays in use. Without metadata, reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects a key store that contains no private key. Put the certificate and its private key in the key store and run reload again. +Reload checks the dates of the certificate chains supplied by the factory. If any certificate is expired or not yet valid, the replacement is rejected and the previous context stays in use. + +A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. + +With `SslContextFactory`, reload also rejects a store with no private keys or a private-key entry without a usable X.509 certificate chain. If there are no private keys, install the node's certificate chain and private key. If the error identifies an alias, add its missing X.509 chain or remove that unusable private-key entry, then run reload again. + You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. == Upgrading Certificates @@ -296,24 +300,26 @@ This section is for developers integrating a certificate source, such as a PKI s To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. -The `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. +The final `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. +Nodes call `createSslBundle()` directly; callers using `Factory` obtain its context through `create()`. If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. -The base factory builds the context and metadata from the same store load. +The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. +To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. The bundle includes every private-key alias, even when the context can choose among several keys. -An empty map reports that there are no private keys, so node reload rejects it. +An empty map reports that there are no private keys. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory -The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed. Update subclasses that use them and recompile before upgrading: +The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed, and `create()` is now final. Update affected subclasses and recompile before upgrading: -* Replace `createKeyManagers()` overrides with `loadKeyMaterial()`, returning the loaded store and its settings instead of key managers. -* If a custom `create()` override loads a store and constructs a context, move that loading into `loadKeyMaterial()` and use the base factory's bundle construction. The inherited `create()` adapter then uses the same implementation as node reload. +* Move store loading from `createKeyManagers()` into `loadKeyMaterial()`, returning the loaded store and its settings. Move key-manager customization into `createKeyManagers(KeyMaterial)`. +* Remove `create()` overrides. Move store loading into `loadKeyMaterial()`. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. * Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. -A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override; the inherited bundle method uses it. +A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. If the source provides a ready-made `SSLContext` instead of a key store, use a plain `Factory`. It can support reload by returning a fresh context on every call. Returning the context already in use makes reload fail. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 6b60eeeab28c4..6f6e1f86caae5 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1822,8 +1822,8 @@ Status does not test connections or verify that other nodes and clients trust th |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` |The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. -|`alias=...: certificate chain is unavailable` -|The source has this private key but did not supply its X.509 chain. Check the chain in the certificate source. +|`alias=...: certificate chain is absent or is not X.509` +|This private-key entry has no usable X.509 certificate chain. Add its X.509 chain or remove the unusable private-key entry, then run reload. Reload rejects a replacement that still contains such an entry. |`no private keys` |The loaded source contains no private key. Install the node's certificate and private key, then run reload. |`no SSL context factory is configured` diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 8662472341e3d..d27f24074001b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -109,8 +109,9 @@ public void setProtocols(String... protocols) { * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from * that material. The factory does not retain the result. *

- * The node uses this method at startup and for {@code control.sh --ssl reload}. Calls for the same factory on a node - * never overlap. A factory that fetches certificates remotely must bound each request and respond to thread interruption. + * The node uses this method at startup and for {@code control.sh --ssl reload}. A node never calls this method concurrently + * for one factory object. A factory shared by several nodes in one JVM, or also used by application code, must be thread-safe. + * A factory that fetches certificates remotely must bound each request and respond to thread interruption. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index 644566725f455..1be0f01692a68 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -93,7 +93,7 @@ private SslReloadJob(NoArg arg, boolean debug, UUID originNodeId) { lines.add(id + ": reloaded " + transports + (desc.isEmpty() ? "" : "; loaded " + desc)); } - catch (Exception e) { + catch (Exception | LinkageError e) { failed = true; String reason = reason(e); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 7970600f87af5..4a35ed3261409 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -87,7 +87,7 @@ else if (chains.isEmpty()) String alias = "alias=" + entry.getKey(); if (chain.isEmpty()) { - lines.add(DOUBLE_INDENT + alias + ": certificate chain is unavailable"); + lines.add(DOUBLE_INDENT + alias + ": certificate chain is absent or is not X.509"); continue; } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 2d60984c012db..2e56e6008c007 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -79,7 +79,7 @@ public static String describeChains(@Nullable Map> List descriptions = new ArrayList<>(); chains.forEach((alias, chain) -> descriptions.add("alias=" + alias + ", " + - (chain.isEmpty() ? "certificate chain is unavailable" : describe(chain.get(0))))); + (chain.isEmpty() ? "certificate chain is absent or is not X.509" : describe(chain.get(0))))); return String.join("; ", descriptions); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index e65ab128f7ae5..9ddb1813c8c54 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -96,7 +96,8 @@ private SslBundle createBundle() { /** * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no - * private key. If a certificate chain is supplied, every certificate must be within its validity period. + * private key or to contain a private key without an X.509 certificate chain. Every supplied certificate must be within its + * validity period. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * * @return Context and certificate metadata installed by this reload. @@ -128,20 +129,37 @@ public synchronized SslBundle reload() throws IgniteCheckedException { /** * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material is known to have no private key. + * @throws IgniteCheckedException If the loaded material has no private key or a key has no X.509 certificate chain. */ private SslBundle build() throws IgniteCheckedException { SslBundle rebuilt = createBundle(); - Map> chains = rebuilt.certificateChains(); - if (chains != null && chains.isEmpty()) - throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + checkKeyMaterial(rebuilt.certificateChains()); rebuilt.context().createSSLEngine(); return rebuilt; } + /** + * @param chains Chains by private-key alias, {@code null} if the factory supplies no metadata. + * @throws IgniteCheckedException If there is no private key or a key has no X.509 certificate chain. + */ + private static void checkKeyMaterial(@Nullable Map> chains) throws IgniteCheckedException { + if (chains == null) + return; + + if (chains.isEmpty()) + throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + + for (Map.Entry> entry : chains.entrySet()) { + if (entry.getValue().isEmpty()) { + throw new IgniteCheckedException("The private key has no X.509 certificate chain; remove the key or provide its " + + "X.509 certificate chain [alias=" + entry.getKey() + ']'); + } + } + } + /** * @param next Chains loaded with the new context, {@code null} if they are unknown. * @throws IgniteCheckedException If a certificate is outside its validity period. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 55c72f0350b4f..b3d1e7a464867 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -84,7 +84,7 @@ private void warnIfExpired(SslContextProvider provider) { X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), System.currentTimeMillis()); if (invalid != null) { - U.warn(log, "The TLS certificate chain in use is not valid now on " + provider.transports() + + U.warn(log, "The TLS certificate chain in use is not valid now" + "; put a valid certificate in place and run control.sh --ssl reload [alias=" + entry.getKey() + ", " + SslCertificates.describe(invalid) + ']'); } diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java index 8582915f28bb1..017ae837389d8 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java @@ -19,8 +19,6 @@ import java.util.UUID; import java.util.function.Supplier; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLEngine; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteLogger; import org.apache.ignite.cluster.ClusterNode; @@ -30,9 +28,6 @@ import org.apache.ignite.spi.IgniteSpiContext; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.communication.tcp.messages.NodeIdMessage; -import org.jetbrains.annotations.Nullable; - -import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; /** * The role of this is aggregate logic of cluster states. @@ -61,9 +56,6 @@ public class ClusterStateProvider { /** Ignite ex supplier. */ private final Supplier igniteExSupplier; - /** SSL context used for new communication connections, {@code null} if SSL is disabled. */ - private final @Nullable SSLContext sslCtx; - /** * @param ignite Ignite. * @param locNodeSupplier Local node supplier. @@ -89,9 +81,6 @@ public ClusterStateProvider( this.spiCtxWithoutLatchSupplier = spiCtxWithoutLatchSupplier; this.log = log; this.igniteExSupplier = igniteExSupplier; - - sslCtx = isSslEnabled() ? ((IgniteEx)ignite).context().sslContexts() - .register(ignite.configuration().getSslContextFactory(), COMMUNICATION) : null; } /** @@ -118,18 +107,6 @@ public boolean isSslEnabled() { return ignite.configuration().getSslContextFactory() != null; } - /** - * @return {@link SSLEngine} for ssl connections. - */ - public SSLEngine createSSLEngine() { - return sslCtx.createSSLEngine(); - } - - /** @return SSL context used for new communication connections, {@code null} if SSL is disabled. */ - public @Nullable SSLContext sslContext() { - return sslCtx; - } - /** * @return {@code true} if {@link TcpCommunicationSpi} stopped. */ diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index c6039ae6741d1..ce610587bbf38 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -40,6 +40,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteCheckedException; @@ -49,6 +50,7 @@ import org.apache.ignite.cluster.ClusterNode; import org.apache.ignite.configuration.IgniteConfiguration; import org.apache.ignite.internal.CoreMessagesProvider; +import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.IgniteTooManyOpenFilesException; import org.apache.ignite.internal.cluster.ClusterTopologyCheckedException; @@ -107,6 +109,7 @@ import org.jetbrains.annotations.Nullable; import static org.apache.ignite.internal.direct.IgniteMessageSerializationContext.buildForPeers; +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor; import static org.apache.ignite.internal.util.nio.GridNioServer.RECOVERY_DESC_META_KEY; import static org.apache.ignite.internal.util.nio.GridNioSessionMetaKey.MSG_SER_CTX; @@ -217,6 +220,9 @@ public class GridNioServerWrapper { private final int msgQueueWarningSize = IgniteSystemProperties.getInteger( IgniteSystemProperties.IGNITE_TCP_COMM_MSG_QUEUE_WARN_SIZE, 0); + /** Context shared by incoming and outgoing connections until the SPI stops. */ + private SSLContext sslCtx; + /** NIO server. */ private GridNioServer nioSrv; @@ -476,7 +482,7 @@ public GridNioSession createNioSession(ClusterNode node, int connIdx) throws Ign if (stateProvider.isSslEnabled()) { meta.put(SSL_META.ordinal(), sslMeta = new GridSslMeta()); - SSLEngine sslEngine = stateProvider.createSSLEngine(); + SSLEngine sslEngine = sslCtx.createSSLEngine(); sslEngine.setUseClientMode(true); @@ -803,6 +809,9 @@ public GridNioServer resetNioServer() throws IgniteCheckedException { if (cfg.boundTcpPort() >= 0) throw new IgniteCheckedException("Tcp NIO server was already created on port " + cfg.boundTcpPort()); + sslCtx = stateProvider.isSslEnabled() ? ((IgniteEx)stateProvider.ignite()).context().sslContexts() + .register(igniteCfg.getSslContextFactory(), COMMUNICATION) : null; + IgniteCheckedException lastEx = null; // If configured TCP port is busy, find first available in range. @@ -919,7 +928,7 @@ private IgniteMessageFactory get() { if (stateProvider.isSslEnabled()) { GridNioSslFilter sslFilter = U.sslFilter( - stateProvider.sslContext(), + sslCtx, true, ByteOrder.LITTLE_ENDIAN, log, diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 6ac957e1da088..481dda0bad109 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -49,6 +49,8 @@ import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** Tests manual reload validation and certificate sources that supply optional metadata. */ public class SslContextProviderTest extends GridCommonAbstractTest { @@ -74,11 +76,7 @@ public class SslContextProviderTest extends GridCommonAbstractTest { /** The node retains a bundle until reload and uses metadata from the factory's store loader. */ @Test public void testNodeKeepsBundleUntilReload() throws Exception { - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey) { - @Override public SSLContext create() { - throw new AssertionError("The node must use createSslBundle()"); - } - }; + ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey); SslContextProvider provider = new SslContextProvider(factory); SSLContext first = provider.context(); @@ -279,6 +277,47 @@ public void testEveryPrivateKeyIsValidated() throws Exception { assertEquals(originalChains, provider.certificateChains()); } + /** A known private key without an X.509 chain cannot replace the working material. */ + @Test + public void testMissingCertificateChainNotApplied() throws Exception { + String alias = "missing-chain"; + + assertMissingChainRejected(Map.of(alias, List.of()), alias); + } + + /** A valid key does not hide another key whose X.509 chain is absent. */ + @Test + public void testMissingChainAmongOtherKeysNotApplied() throws Exception { + String validAlias = "a-valid"; + String missingAlias = "z-missing"; + List validChain = List.of(TestCertificateAuthority.certificateChain(ca.keyStoreWithPrivateKey(validAlias))); + + assertMissingChainRejected(Map.of(validAlias, validChain, missingAlias, List.of()), missingAlias); + } + + /** + * @param candidateChains Explicit metadata supplied by a factory, with a known key lacking an X.509 chain. + * @param missingAlias Alias of that key. + */ + private void assertMissingChainRejected(Map> candidateChains, String missingAlias) throws Exception { + SslBundle init = TestCertificateAuthority.bundle(ca.keyStoreWithPrivateKey(), ca.trustStore()); + SSLContext candidate = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); + AbstractSslContextFactory factory = mock(AbstractSslContextFactory.class); + + when(factory.createSslBundle()).thenReturn(init, new SslBundle(candidate, candidateChains)); + + SslContextProvider provider = new SslContextProvider(factory); + + Throwable failure = GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, + "The private key has no X.509 certificate chain"); + + assertContains(log, failure.getMessage(), "alias=" + missingAlias); + assertContains(log, failure.getMessage(), "remove the key or provide its X.509 certificate chain"); + assertSame(init.context(), provider.context()); + assertEquals(init.certificateChains(), provider.certificateChains()); + assertEquals(1, provider.reloadOutcome().failures()); + } + /** An unsupported protocol in a replacement context leaves the working context in use. */ @Test public void testUnsupportedProtocolNotApplied() { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index fa0dd2f8873a4..0ac21977f111b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -386,6 +387,45 @@ public void testEqualFactoriesReloadIndependently() throws Exception { assertEquals(certificateChain(nextKeys)[0], certificateAt(g.context().clientListener().port())); } + /** A factory linkage failure is audited and does not prevent reloading the remaining factories on the node. */ + @Test + public void testLinkageErrorDoesNotSkipRemainingFactory() throws Exception { + String failureReason = "Certificate provider class is unavailable"; + AtomicBoolean fail = new AtomicBoolean(); + TestFactory nodeFactory = new TestFactory() { + @Override public SSLContext create() { + if (fail.get()) + throw new NoClassDefFoundError(failureReason); + + return super.create(); + } + }; + TestFactory clientFactory = new TestFactory(); + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(nodeFactory); + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory); + }); + + assertEquals(COMMUNICATION + ", " + DISCOVERY, g.context().sslContexts().providers().iterator().next().transports()); + + LogListener audited = LogListener.matches(msg -> msg.startsWith("Failed to reload TLS certificates") && + msg.contains("initiator=management command, originNodeId=" + g.localNode().id()) && + msg.contains("reason=" + failureReason)).build(); + + nodeLog.registerListener(audited); + placeKeys(newKeys); + fail.set(true); + + String res = reloadFailure(g); + + assertFailed(res, g, COMMUNICATION + ", " + DISCOVERY); + assertContains(log, res, failureReason); + assertReloaded(res, g, CLIENT_CONNECTOR); + assertEquals(oldCert, certificateAt(discoveryPort(g))); + assertEquals(newCert, certificateAt(g.context().clientListener().port())); + assertTrue(audited.check()); + } + /** An expired certificate stays out of use, and the report, log and status identify the rejected chain. */ @Test public void testExpiredCertificateNotApplied() throws Exception { @@ -605,7 +645,7 @@ public void testStatus() throws Exception { public void testExpiredCertificateWarnedAtStart() throws Exception { TestCertificateAuthority.save(expiredKeys(), keyStore(getTestIgniteInstanceName(0))); - LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now").build(); + LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now; put a valid certificate").build(); nodeLog.registerListener(expired); From 6a7c34ec2287ba020368441fee62ac09eebceca8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 22:12:23 +0300 Subject: [PATCH 066/242] IGNITE-29211 Keep reload contract within the line limit --- .../org/apache/ignite/internal/ssl/SslContextProvider.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 0dee28c841218..027f29e117240 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -143,7 +143,8 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { /** * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no - * private key or a key without an X.509 certificate chain. If a certificate chain is supplied, every certificate must be within its validity period. + * private key or a key without an X.509 certificate chain. If a certificate chain is supplied, every certificate must be within + * its validity period. * Certificate metadata is required when automatic renewal is enabled. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * From 5fa46cb047e54c8119a96516e5fae711e90c1130 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:27:06 +0300 Subject: [PATCH 067/242] IGNITE-29209 State that keys without an X.509 chain are never presented Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/org/apache/ignite/ssl/KeyMaterial.java | 3 ++- .../src/main/java/org/apache/ignite/ssl/SslBundle.java | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index 5c0f46df5fb2a..9165bb7a8b23c 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -86,7 +86,8 @@ public KeyManager[] keyManagers() throws SSLException { /** * @return Immutable X.509 chains by private-key alias, own certificate first. An empty map means there are no private keys. - * An empty list retains an alias whose chain is absent or contains a non-X.509 certificate. + * An empty list retains an alias whose chain is absent or contains a non-X.509 certificate; key managers never present + * such a key. * @throws SSLException If the loaded store cannot be read. */ public Map> certificateChains() throws SSLException { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java index 3646806c3f618..ee5206e5f0d6a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -41,7 +41,8 @@ public final class SslBundle { * @param ctx Initialized SSL context. * @param chains Chains by private-key alias, own certificate first. {@code null} means the aliases are unknown; * an empty map means there are no private keys. An empty list means the alias has a private key but its chain is absent - * or contains a non-X.509 certificate. Trusted certificate entries and secret keys must not be included. + * or contains a non-X.509 certificate, so key managers never present that key. Trusted certificate entries and secret keys + * must not be included. */ public SslBundle(SSLContext ctx, @Nullable Map> chains) { A.notNull(ctx, "ctx"); @@ -66,7 +67,7 @@ public SSLContext context() { /** * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private keys; - * an empty list means the alias's chain is absent or contains a non-X.509 certificate. + * an empty list means the alias's chain is absent or contains a non-X.509 certificate, so that key is never presented. */ public @Nullable Map> certificateChains() { return chains; From e761ac1387c5323759d537d4844e1de850113d16 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:27:54 +0300 Subject: [PATCH 068/242] IGNITE-29210 Warn at startup about key material that reload rejects Co-Authored-By: Claude Opus 5.5 --- docs/_docs/security/ssl-tls.adoc | 2 +- .../internal/ssl/SslContextRegistry.java | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 835c16b5bcdc1..ac05dfcdde515 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -183,7 +183,7 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects a store with no private keys or a private-key entry without a usable X.509 certificate chain. If there are no private keys, install the node's certificate chain and private key. If the error identifies an alias, add its missing X.509 chain or remove that unusable private-key entry, then run reload again. +With `SslContextFactory`, reload also rejects a store with no private keys, or with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can start with one; the node log and `--ssl status` then name its alias, and reload rejects the store until the entry is fixed. If there are no private keys, install the node's certificate chain and private key. If the error names an alias, add the X.509 certificate chain of that key or remove the key, then run reload again. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index b3d1e7a464867..dc027838fb974 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -63,7 +63,7 @@ public synchronized SSLContext register(Factory factory, String tran if (provider == null) { provider = new SslContextProvider(factory); provider.addTransport(transport); - warnIfExpired(provider); + warnIfUnusable(provider); providers.add(provider); } @@ -73,14 +73,28 @@ public synchronized SSLContext register(Factory factory, String tran return provider.sharedContext(); } - /** @param provider Newly registered provider. */ - private void warnIfExpired(SslContextProvider provider) { + /** + * Warns about key material the node starts with but reload would reject. + * + * @param provider Newly registered provider. + */ + private void warnIfUnusable(SslContextProvider provider) { Map> chains = provider.certificateChains(); if (chains == null) return; + if (chains.isEmpty()) + U.warn(log, "The key material has no private key, so the node presents no certificate; reload rejects such key material"); + for (Map.Entry> entry : chains.entrySet()) { + if (entry.getValue().isEmpty()) { + U.warn(log, "The private key has no X.509 certificate chain, so the node never presents it; reload rejects " + + "this key store until the key is removed or its X.509 certificate chain is provided [alias=" + entry.getKey() + ']'); + + continue; + } + X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), System.currentTimeMillis()); if (invalid != null) { From 09fb08dbebf39bf1ac73f104d7084b8641f0e12d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:27:54 +0300 Subject: [PATCH 069/242] IGNITE-29211 Keep IGNITE-29210 contract wording and define the renewal window Co-Authored-By: Claude Opus 5.5 --- docs/_docs/security/ssl-tls.adoc | 2 +- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 9 +++++---- .../apache/ignite/internal/ssl/SslContextProvider.java | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 026c8127246e9..ad4b7d1fa11d5 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -259,7 +259,7 @@ For manual file replacement, use <> and run reload after === Prepare Certificate Delivery Renewal follows the first certificate to expire across all private-key chains, including CA certificates. -For that chain, the renewal window is the fraction `renewBeforeFraction` of the time from its leaf certificate's start date to its earliest expiry. Ignite starts requesting replacements when this window opens; `control.sh --ssl status` shows the next attempt time. If several chains expire together, the earliest window applies. +For that chain, the renewal window is the last `renewBeforeFraction` share of the time from its leaf certificate's start date to its earliest expiry, so the window ends at that expiry. Ignite starts requesting replacements when this window opens; `control.sh --ssl status` shows the next attempt time. If several chains expire together, the earliest window applies. The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 53fc601e9bbcb..a93fe8fded3ab 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -121,7 +121,8 @@ public boolean isRenewalEnabled() { /** * Sets whether a node calls {@link #createSslBundle()} before the certificates expire to pick up replacements. * A file-based factory rereads its files. It does not replace them. - * When enabled, the factory must provide a nonempty X.509 chain for every private key or the node does not start. Disabled by default. + * When enabled, the factory must provide at least one private key and a nonempty X.509 chain for every private key, or the node + * does not start. Disabled by default. * Set this before starting the node; changing it on a running node takes effect after a restart. * * @param renewalEnabled Whether renewal is enabled. @@ -155,9 +156,9 @@ public void setRenewBeforeFraction(double renewBeforeFraction) { * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from * that material. The factory does not retain the result. *

- * The node uses this method at startup, for {@code control.sh --ssl reload} and for automatic renewal. A node serializes its - * calls for each factory object. A factory shared by several nodes in one JVM, or also called by application code, must support - * concurrent calls. A factory that fetches certificates remotely must bound each request and respond to thread interruption. + * The node uses this method at startup, for {@code control.sh --ssl reload} and for automatic renewal. A node never calls this + * method concurrently for one factory object. A factory shared by several nodes in one JVM, or also used by application code, + * must be thread-safe. A factory that fetches certificates remotely must bound each request and respond to thread interruption. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 027f29e117240..3afd0805c4809 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -143,8 +143,8 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { /** * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no - * private key or a key without an X.509 certificate chain. If a certificate chain is supplied, every certificate must be within - * its validity period. + * private key or to contain a private key without an X.509 certificate chain. Every supplied certificate must be within its + * validity period. * Certificate metadata is required when automatic renewal is enabled. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * From c7f4aaebcc3f2f19781e8bc721872199041bebf5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:52:23 +0300 Subject: [PATCH 070/242] IGNITE-29209 Make SSL API tests state one guarantee each Co-Authored-By: Claude Opus 5.5 --- .../ignite/ssl/SslContextMetadataTest.java | 400 ++++++------------ .../ignite/ssl/SslContextReuseTest.java | 66 +-- .../org/apache/ignite/ssl/SslTestUtils.java | 51 +++ .../TestCertificateAuthority.java | 122 +----- 4 files changed, 202 insertions(+), 437 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 91a09d242b9ba..8a1b632a144ff 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -18,26 +18,19 @@ package org.apache.ignite.ssl; import java.net.InetAddress; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.KeyStore; -import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; -import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSocket; @@ -52,18 +45,23 @@ import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; -import static org.junit.Assert.assertArrayEquals; +import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.trustManagers; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -/** Tests that each context loads its key store once and retains its certificate metadata. */ +/** + * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, + * and checks parameters before reading. {@link KeyMaterial} reports private keys only, each with its own chain (an empty store + * gives an empty map, a key whose chain contains a non-X.509 certificate keeps its alias with an empty chain), and keeps its + * own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}. Custom key managers take + * part in handshakes and cannot be {@code null}. {@link SslContextFactory} names the store whose key it cannot open. + */ public class SslContextMetadataTest extends GridCommonAbstractTest { - /** Store and key password of the generated test material. */ - private static final char[] PASSWORD = TestCertificateAuthority.PWD; - /** Authority that signs the test certificates. */ private TestCertificateAuthority ca; @@ -72,273 +70,155 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each create or bundle request loads fresh material once. */ + /** Each bundle reads the key store once, builds a new context and takes its metadata from that read. */ @Test - public void testFreshContextsLoadMaterialOnce() throws Exception { - String originalAlias = "original"; - KeyStore originalKeys = ca.keyStoreWithPrivateKey(originalAlias); - List originalChain = Arrays.asList(certificateChain(originalKeys)); - InMemoryStoreFactory factory = new InMemoryStoreFactory(originalKeys); - SslBundle original = factory.createSslBundle(); - - assertEquals(Map.of(originalAlias, originalChain), original.certificateChains()); - assertEquals(1, factory.loadKeyStoreCalls); - assertEquals(1, factory.parameterChecks); + public void testEachBundleReadsKeyStoreOnce() throws Exception { + KeyStore first = ca.keyStoreWithPrivateKey("first"); + InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - SSLContext created = factory.create(); + SslBundle firstBundle = factory.createSslBundle(); - assertNotSame(original.context(), created); - assertEquals(2, factory.loadKeyStoreCalls); - assertEquals(2, factory.parameterChecks); + assertEquals(1, factory.loads); + assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); - String replacementAlias = "replacement"; - KeyStore replacementKeys = ca.keyStoreWithPrivateKey(replacementAlias); - List replacementChain = Arrays.asList(certificateChain(replacementKeys)); + KeyStore second = ca.keyStoreWithPrivateKey("second"); - factory.store = replacementKeys; + factory.store = second; - SslBundle fresh = factory.createSslBundle(); + SslBundle secondBundle = factory.createSslBundle(); - assertNotSame(created, fresh.context()); - assertNotSame(original.context(), fresh.context()); - assertEquals(Map.of(replacementAlias, replacementChain), fresh.certificateChains()); - assertEquals(3, factory.loadKeyStoreCalls); - assertEquals(3, factory.parameterChecks); - - assertNotSame(fresh.context(), factory.create()); - assertEquals(4, factory.loadKeyStoreCalls); - assertEquals(4, factory.parameterChecks); - assertEquals(4, factory.bundleCalls); + assertEquals(2, factory.loads); + assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); + assertNotSame(firstBundle.context(), secondBundle.context()); } - /** TLS parameters apply to the context while its metadata remains available separately. */ + /** A failed read is thrown instead of reusing an earlier context, and the next call reads the store again. */ @Test - public void testMetadataWithTlsParameters() throws Exception { - String alias = "node"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - String[] protocols = {"TLSv1.2"}; - String[] ciphers = {"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}; + public void testFailedLoadCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); - factory.setProtocols(protocols); - factory.setCipherSuites(ciphers); + factory.create(); - SslBundle bundle = factory.createSslBundle(); + factory.loadFailure = "Key store is unavailable"; - assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "Key store is unavailable"); - for (SSLContext ctx : new SSLContext[] {bundle.context(), factory.create()}) { - SSLEngine engine = ctx.createSSLEngine(); + assertTrue(err.getCause() instanceof SSLException); - assertArrayEquals(protocols, engine.getEnabledProtocols()); - assertArrayEquals(ciphers, engine.getEnabledCipherSuites()); - } + factory.loadFailure = null; + + factory.create(); } - /** Every private-key alias retains its own chain. */ + /** Missing parameters are reported before the key store is read. */ @Test - public void testMultiplePrivateKeysRetainAllChains() throws Exception { - String firstKey = "first-key"; - String secondKey = "second-key"; - KeyStore store = ca.keyStoreWithPrivateKey(firstKey); - KeyStore other = ca.keyStoreWithPrivateKey(secondKey); - List firstChain = Arrays.asList(certificateChain(store)); - List secondChain = Arrays.asList(certificateChain(other)); + public void testInvalidParametersDoNotLoadStore() throws Exception { + KeyStore empty = KeyStore.getInstance("JKS"); - store.setKeyEntry(secondKey, other.getKey(secondKey, PASSWORD), PASSWORD, other.getCertificateChain(secondKey)); + empty.load(null, null); - SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); + InMemoryStoreFactory factory = new InMemoryStoreFactory(empty); - assertEquals(Map.of(firstKey, firstChain, secondKey, secondChain), bundle.certificateChains()); - assertEquals(List.of(firstKey, secondKey), new ArrayList<>(bundle.certificateChains().keySet())); + factory.setTrustManagers((TrustManager[])null); + + GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "either trustStoreFilePath or trustManagers must be provided"); + assertEquals(0, factory.loads); } - /** Trusted certificates and secret keys do not count as private keys. */ + /** Metadata lists every private key with its own chain and skips trusted certificates and secret keys. */ @Test - public void testOnlyPrivateKeyEntriesCount() throws Exception { - String keyAlias = "private-key"; - KeyStore keys = ca.keyStoreWithPrivateKey(keyAlias); + public void testOnlyPrivateKeysWithTheirChains() throws Exception { + KeyStore first = ca.keyStoreWithPrivateKey("first-key"); + KeyStore second = ca.keyStoreWithPrivateKey("second-key"); + KeyStore.PasswordProtection prot = new KeyStore.PasswordProtection(PWD); // JCEKS can hold both private keys and secret keys. KeyStore store = KeyStore.getInstance("JCEKS"); - store.load(null, PASSWORD); - store.setCertificateEntry("trusted", keys.getCertificate(keyAlias)); - store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), - new KeyStore.PasswordProtection(PASSWORD)); - store.setKeyEntry(keyAlias, keys.getKey(keyAlias, PASSWORD), PASSWORD, keys.getCertificateChain(keyAlias)); + store.load(null, PWD); + store.setEntry("first-key", first.getEntry("first-key", prot), prot); + store.setEntry("second-key", second.getEntry("second-key", prot), prot); + store.setCertificateEntry("trusted", first.getCertificate("first-key")); + store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), prot); - SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); + KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); - assertEquals(Map.of(keyAlias, Arrays.asList(certificateChain(keys))), bundle.certificateChains()); + assertEquals(Map.of("first-key", chain(first), "second-key", chain(second)), material.certificateChains()); } - /** An empty loaded store creates a context without private keys. */ + /** An empty key store yields a context without private keys and an empty map. */ @Test public void testEmptyStoreCreatesKeylessContext() throws Exception { - KeyStore store = KeyStore.getInstance("JKS"); + KeyStore empty = KeyStore.getInstance("JKS"); - store.load(null, null); + empty.load(null, null); - SslBundle bundle = new InMemoryStoreFactory(store).createSslBundle(); + SslBundle bundle = new InMemoryStoreFactory(empty).createSslBundle(); assertEquals(Map.of(), bundle.certificateChains()); - assertNotNull(bundle.context().getSocketFactory()); } - /** A private key with a non-X.509 chain retains its alias with an empty chain. */ + /** A private key whose chain contains a non-X.509 certificate keeps its alias with an empty chain. */ @Test public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { - String alias = "private-key"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - X509Certificate cert = (X509Certificate)store.getCertificate(alias); - Certificate otherType = new Certificate("test-certificate") { - /** {@inheritDoc} */ - @Override public byte[] getEncoded() throws CertificateEncodingException { - return cert.getEncoded(); - } - - /** {@inheritDoc} */ - @Override public void verify(PublicKey key) { - throw new UnsupportedOperationException(); - } - - /** {@inheritDoc} */ - @Override public void verify(PublicKey key, String provider) { - throw new UnsupportedOperationException(); - } - - /** {@inheritDoc} */ - @Override public PublicKey getPublicKey() { - return cert.getPublicKey(); - } - - /** {@inheritDoc} */ - @Override public String toString() { - return "Test certificate with a non-X.509 type"; - } - }; - - store.setKeyEntry(alias, store.getKey(alias, PASSWORD), PASSWORD, new Certificate[] {otherType}); + KeyStore store = ca.keyStoreWithPrivateKey("private-key"); - KeyMaterial material = new KeyMaterial(store, PASSWORD, SslContextFactory.DFLT_KEY_ALGORITHM); + store.setKeyEntry("private-key", store.getKey("private-key", PWD), PWD, + new Certificate[] {store.getCertificate("private-key"), mock(Certificate.class)}); - assertEquals(Map.of(alias, List.of()), material.certificateChains()); - } - - /** Invalid parameters are reported before loading the store. */ - @Test - public void testInvalidParametersDoNotLoadStore() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); - factory.setTrustManagers((TrustManager[])null); - - GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, - IgniteException.class, "trustManagers must be provided"); - - assertEquals(1, factory.parameterChecks); - assertEquals(0, factory.loadKeyStoreCalls); + assertEquals(Map.of("private-key", List.of()), material.certificateChains()); } - /** A failed load is reported instead of reusing an earlier context, and the next request can recover. */ - @Test - public void testFailedLoadCanBeRetried() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); - SSLContext original = factory.create(); - String failure = "Key material is unavailable"; - - factory.loadFailure = failure; - - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, failure); - - assertTrue(err.getCause() instanceof SSLException); - - GridTestUtils.assertThrows(log, (Callable)factory::createSslBundle, IgniteException.class, failure); - - factory.loadFailure = null; - - SSLContext recovered = factory.create(); - - assertNotSame(original, recovered); - assertNotNull(recovered.createSSLEngine()); - assertEquals(4, factory.loadKeyStoreCalls); - assertEquals(4, factory.parameterChecks); - } - - /** Mutating the caller's password does not change how loaded material opens its private keys. */ + /** Material keeps its own copy of the key password, so clearing the caller's array does not lock the keys. */ @Test public void testLoadedMaterialCopiesKeyPassword() throws Exception { - String alias = "node"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - char[] pwd = PASSWORD.clone(); - KeyMaterial material = new KeyMaterial(store, pwd, SslContextFactory.DFLT_KEY_ALGORITHM); + char[] pwd = PWD.clone(); + KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey("node"), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); Arrays.fill(pwd, '?'); X509KeyManager mgr = (X509KeyManager)material.keyManagers()[0]; - assertNotNull(mgr.getPrivateKey(alias)); - assertArrayEquals(certificateChain(store), mgr.getCertificateChain(alias)); + assertNotNull(mgr.getPrivateKey("node")); } - /** Mutating the source map or its lists cannot change the bundle's certificate metadata. */ + /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ @Test - public void testImmutableCertificateMetadata() throws Exception { - String alias = "node"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - SslBundle loaded = new InMemoryStoreFactory(store).createSslBundle(); - List expected = Arrays.asList(certificateChain(store)); - List inputChain = new ArrayList<>(expected); - Map> input = new HashMap<>(); + public void testBundleKeepsSortedImmutableCopy() throws Exception { + X509Certificate aCert = mock(X509Certificate.class); + X509Certificate bCert = mock(X509Certificate.class); + List aChain = new ArrayList<>(List.of(aCert)); + Map> input = new LinkedHashMap<>(); - input.put(alias, inputChain); + input.put("b", List.of(bCert)); + input.put("a", aChain); - SslBundle bundle = new SslBundle(loaded.context(), input); + SslBundle bundle = new SslBundle(SSLContext.getDefault(), input); - inputChain.clear(); + aChain.clear(); input.clear(); - assertEquals(Map.of(alias, expected), bundle.certificateChains()); - assertSame(loaded.context(), bundle.context()); + Map> chains = bundle.certificateChains(); - GridTestUtils.assertThrows(log, () -> bundle.certificateChains().put("other", List.of()), - UnsupportedOperationException.class, null); - GridTestUtils.assertThrows(log, () -> bundle.certificateChains().get(alias).set(0, expected.get(1)), - UnsupportedOperationException.class, null); + assertEquals(List.of("a", "b"), new ArrayList<>(chains.keySet())); + assertEquals(Map.of("a", List.of(aCert), "b", List.of(bCert)), chains); + GridTestUtils.assertThrows(log, () -> chains.put("c", List.of()), UnsupportedOperationException.class, null); + GridTestUtils.assertThrows(log, () -> chains.get("a").set(0, bCert), UnsupportedOperationException.class, null); } - /** A factory that cannot report its private-key aliases keeps that distinction in the bundle. */ + /** Unknown metadata stays {@code null}, so it is not mistaken for a store without private keys. */ @Test public void testBundleKeepsUnknownMetadata() throws Exception { - SSLContext ctx = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()).create(); - - assertNull(new SslBundle(ctx, null).certificateChains()); - } - - /** Alias order does not depend on the map supplied by a factory. */ - @Test - public void testBundleOrdersAliases() throws Exception { - String firstAlias = "a"; - String secondAlias = "b"; - KeyStore store = ca.keyStoreWithPrivateKey(firstAlias); - List chain = Arrays.asList(certificateChain(store)); - - store.setKeyEntry(secondAlias, store.getKey(firstAlias, PASSWORD), PASSWORD, store.getCertificateChain(firstAlias)); - - SSLContext ctx = new InMemoryStoreFactory(store).create(); - Map> chains = new LinkedHashMap<>(); - - chains.put(secondAlias, chain); - chains.put(firstAlias, chain); - - assertEquals(List.of(firstAlias, secondAlias), new ArrayList<>(new SslBundle(ctx, chains).certificateChains().keySet())); + assertNull(new SslBundle(SSLContext.getDefault(), null).certificateChains()); } - /** A manager decorator participates in TLS without a second store load. */ + /** Key managers returned by an overridden {@code createKeyManagers} present the certificate in a handshake. */ @Test public void testCustomKeyManagerUsedByContext() throws Exception { - String alias = "server-key"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); + KeyStore store = ca.keyStoreWithPrivateKey("server-key"); AtomicReference customMgr = new AtomicReference<>(); InMemoryStoreFactory factory = new InMemoryStoreFactory(store) { /** {@inheritDoc} */ @@ -350,14 +230,13 @@ public void testCustomKeyManagerUsedByContext() throws Exception { return new KeyManager[] {mgr}; } }; - SslBundle bundle = factory.createSslBundle(); - SSLContext clientCtx = SSLContext.getInstance("TLS"); + SSLContext probe = SSLContext.getInstance("TLS"); - clientCtx.init(new KeyManager[0], TestCertificateAuthority.trustManagers(ca.trustStore()), null); + probe.init(new KeyManager[0], trustManagers(ca.trustStore()), null); int timeout = 10_000; - try (SSLServerSocket listener = (SSLServerSocket)bundle.context().getServerSocketFactory() + try (SSLServerSocket listener = (SSLServerSocket)factory.create().getServerSocketFactory() .createServerSocket(0, 0, InetAddress.getLoopbackAddress())) { listener.setSoTimeout(timeout); @@ -366,27 +245,20 @@ public void testCustomKeyManagerUsedByContext() throws Exception { server.setSoTimeout(timeout); server.startHandshake(); } - - return null; }); - try (SSLSocket client = (SSLSocket)clientCtx.getSocketFactory() - .createSocket(listener.getInetAddress(), listener.getLocalPort())) { - client.setSoTimeout(timeout); - client.startHandshake(); - - assertArrayEquals(certificateChain(store), client.getSession().getPeerCertificates()); - } + assertEquals(store.getCertificate("server-key"), servedCertificate(probe, listener.getLocalPort())); accepted.get(timeout); } - verify(customMgr.get(), atLeastOnce()).getPrivateKey(alias); - assertEquals(1, factory.loadKeyStoreCalls); - assertEquals(Map.of(alias, Arrays.asList(certificateChain(store))), bundle.certificateChains()); + verify(customMgr.get(), atLeastOnce()).getPrivateKey("server-key"); } - /** A null manager result is rejected instead of silently using the JVM's default keys. */ + /** + * A {@code null} result of {@code createKeyManagers} is rejected: {@code SSLContext.init} would accept it and install an + * empty key manager, so the context would present no certificate. + */ @Test public void testNullKeyManagersRejected() throws Exception { InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()) { @@ -396,87 +268,57 @@ public void testNullKeyManagersRejected() throws Exception { } }; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "keyManagers"); + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "parameter cannot be null): keyManagers"); assertTrue(err.getCause() instanceof SSLException); } - /** An unreadable private key reports the source store and preserves the original cause. */ + /** A key the store password cannot open is reported with the store type and path and keeps the original cause. */ @Test public void testKeyPasswordFailureNamesStore() throws Exception { - String alias = "private-key"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - - store.setKeyEntry(alias, store.getKey(alias, PASSWORD), "different-key-password".toCharArray(), store.getCertificateChain(alias)); + KeyStore store = ca.keyStoreWithPrivateKey("private-key"); - Path path = Files.createTempFile("unreadable-key", ".jks"); + store.setKeyEntry("private-key", store.getKey("private-key", PWD), "other-key-password".toCharArray(), + store.getCertificateChain("private-key")); - try { - TestCertificateAuthority.save(store, path); - - SslContextFactory factory = new SslContextFactory(); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - factory.setKeyStoreType(store.getType()); - factory.setKeyStoreFilePath(path.toString()); - factory.setKeyStorePassword(PASSWORD); - factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + factory.setKeyStoreFilePath("unreadable-key.jks"); - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, null); + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "[type=JKS, keyStorePath=unreadable-key.jks]"); - assertTrue(err.getCause() instanceof SSLException); - assertTrue(err.getCause().getMessage().contains(path.toString())); - assertTrue(err.getCause().getMessage().contains("type=" + store.getType())); - assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); - } - finally { - Files.delete(path); - } + assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); } - /** A file factory whose store-loading hook supplies the test material. */ + /** + * File factory that reads its key store from memory and counts the reads. Trust is disabled, so the trust store is never + * read and every read is the key store's. + */ private static class InMemoryStoreFactory extends SslContextFactory { - /** */ + /** Store returned by the next read. */ private KeyStore store; - /** */ - private int loadKeyStoreCalls; - - /** */ - private int parameterChecks; + /** Number of key store reads. */ + private int loads; - /** */ - private int bundleCalls; - - /** Failure to report while the store is unavailable. */ + /** Message of the read failure, or {@code null} while the store is readable. */ private String loadFailure; - /** */ + /** @param store Store returned by reads. */ InMemoryStoreFactory(KeyStore store) { this.store = store; setKeyStoreType(store.getType()); - setKeyStoreFilePath("test-store"); - setKeyStorePassword(PASSWORD); + setKeyStoreFilePath("in-memory"); + setKeyStorePassword(PWD); setTrustManagers(getDisabledTrustManager()); } - /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { - bundleCalls++; - - return super.createSslBundle(); - } - - /** {@inheritDoc} */ - @Override protected void checkParameters() throws SSLException { - parameterChecks++; - - super.checkParameters(); - } - /** {@inheritDoc} */ @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { - loadKeyStoreCalls++; + loads++; if (loadFailure != null) throw new SSLException(loadFailure); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 657415be1cbba..19792613b6650 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -17,19 +17,17 @@ package org.apache.ignite.ssl; -import java.security.KeyStore; import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; -import org.apache.ignite.testframework.TestCertificateAuthority; +import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -/** Tests that communication connections reuse the context loaded at startup. */ +/** Communication keeps one SSL context: new connections do not call the SSL context factory on either node. */ public class SslContextReuseTest extends GridCommonAbstractTest { /** {@inheritDoc} */ @Override protected void afterTest() throws Exception { @@ -38,62 +36,30 @@ public class SslContextReuseTest extends GridCommonAbstractTest { super.afterTest(); } - /** Opening and reopening a communication connection must not invoke the SSL factory again. */ + /** Reopening a communication connection does not call the SSL context factory on either node. */ @Test public void testCommunicationReusesContextOnReconnect() throws Exception { - TestCertificateAuthority ca = new TestCertificateAuthority("communication-ca"); - CountingFactory firstFactory = new CountingFactory(ca); - CountingFactory secondFactory = new CountingFactory(ca); + Factory sslFactory = GridTestUtils.sslFactory(); + AtomicInteger created = new AtomicInteger(); + Factory counting = () -> { + created.incrementAndGet(); + + return sslFactory.create(); + }; + IgniteEx first = startGrid(0, cfg -> { - cfg.setSslContextFactory(firstFactory); + cfg.setSslContextFactory(counting); }); IgniteEx second = startGrid(1, cfg -> { - cfg.setSslContextFactory(secondFactory); + cfg.setSslContextFactory(counting); }); - int firstStartupCalls = firstFactory.calls.get(); - int secondStartupCalls = secondFactory.calls.get(); - - assertEquals((Integer)1, first.compute(first.cluster().forNodeId(second.localNode().id())).call(() -> 1)); + int createdAtStart = created.get(); ConnectionClientPool pool = U.field(first.configuration().getCommunicationSpi(), "clientPool"); pool.forceCloseConnection(second.localNode().id()); + first.compute(first.cluster().forNodeId(second.localNode().id())).run(() -> {}); - assertEquals((Integer)1, first.compute(first.cluster().forNodeId(second.localNode().id())).call(() -> 1)); - assertEquals(firstStartupCalls, firstFactory.calls.get()); - assertEquals(secondStartupCalls, secondFactory.calls.get()); - } - - /** A factory that builds a fresh context on every call from mutually trusted test material. */ - private static class CountingFactory implements Factory { - /** */ - private static final long serialVersionUID = 0L; - - /** */ - private final KeyStore keys; - - /** */ - private final KeyStore trust; - - /** */ - private final AtomicInteger calls = new AtomicInteger(); - - /** @param ca Authority shared by both nodes. */ - CountingFactory(TestCertificateAuthority ca) throws Exception { - keys = ca.keyStoreWithPrivateKey(); - trust = ca.trustStore(); - } - - /** {@inheritDoc} */ - @Override public SSLContext create() { - calls.incrementAndGet(); - - try { - return TestCertificateAuthority.context(keys, trust); - } - catch (Exception e) { - throw new IgniteException(e); - } - } + assertEquals(createdAtStart, created.get()); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java new file mode 100644 index 0000000000000..7869ecd68c16b --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java @@ -0,0 +1,51 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.cert.X509Certificate; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; + +/** Probes the certificates that TLS servers present. */ +public class SslTestUtils { + /** Timeout for a probe connection or TLS handshake. */ + private static final int SOCKET_TIMEOUT = 30_000; + + /** */ + private SslTestUtils() { + // No-op. + } + + /** + * @param probe Context to connect with. + * @param port Loopback port to connect to. + * @return Certificate the server presents on a new TLS connection to that port. + */ + public static X509Certificate servedCertificate(SSLContext probe, int port) throws Exception { + try (SSLSocket sock = (SSLSocket)probe.getSocketFactory().createSocket()) { + sock.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), SOCKET_TIMEOUT); + sock.setSoTimeout(SOCKET_TIMEOUT); + + sock.startHandshake(); + + return (X509Certificate)sock.getSession().getPeerCertificates()[0]; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 4cd2231af81a3..efafae52791ae 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -17,10 +17,7 @@ package org.apache.ignite.testframework; -import java.io.OutputStream; import java.math.BigInteger; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; @@ -30,16 +27,12 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.security.auth.x500.X500Principal; -import org.apache.ignite.ssl.KeyMaterial; -import org.apache.ignite.ssl.SslBundle; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; @@ -52,7 +45,7 @@ /** * Issues JKS stores protected by {@link #PWD}, each with an EC P-256 private key and its certificate chain. * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, - * so the same material works for node-to-node TLS. Tests choose the issuer and validity dates when those are part of the scenario. + * so the same material works for node-to-node TLS. */ public class TestCertificateAuthority { /** Password of every store made here. */ @@ -79,19 +72,12 @@ public class TestCertificateAuthority { * @param cn Common name of the authority. */ public TestCertificateAuthority(String cn) throws Exception { - this(cn, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365)); - } + long now = System.currentTimeMillis(); - /** - * @param cn Common name of the authority. - * @param notBefore Time the authority's certificate becomes valid; rounded down to a second. - * @param notAfter Time the authority's certificate expires; rounded down to a second. - */ - public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws Exception { name = new X500Principal("CN=" + cn); keys = keyPair(); - cert = sign(name, keys, notBefore, notAfter, true); + cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); } /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ @@ -103,97 +89,31 @@ public KeyStore keyStoreWithPrivateKey() throws Exception { * Issues a certificate valid from an hour ago until a day from now, for tests that do not check expiry. * * @param cn Common name, also used as the key alias. - * @return Store with the private key and certificate chain. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. */ public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { long now = System.currentTimeMillis(); + KeyPair pair = keyPair(); - return keyStoreWithPrivateKey(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); - } - - /** - * @param notBefore Time the certificate becomes valid; rounded down to a second. - * @param notAfter Time it expires; rounded down to a second. - * @return Store with one private key and a certificate with the requested validity dates. - */ - public KeyStore keyStoreWithPrivateKey(long notBefore, long notAfter) throws Exception { - return keyStoreWithPrivateKey(DEFAULT_NAME, notBefore, notAfter); - } - - /** - * @param cn Common name of the certificate, also used as the key alias. - * @param notBefore Time it becomes valid; rounded down to a second. - * @param notAfter Time it expires; rounded down to a second. - * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. - */ - public KeyStore keyStoreWithPrivateKey(String cn, long notBefore, long notAfter) throws Exception { - return keyStoreWithPrivateKey(cn, new X500Principal("CN=" + cn), keyPair(), notBefore, notAfter); - } - - /** - * @param alias Alias for the private key and certificate chain. - * @param subject Subject of the certificate. - * @param pair Key pair of the certificate owner. - * @param notBefore Time it becomes valid; rounded down to a second. - * @param notAfter Time it expires; rounded down to a second. - * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. - */ - private KeyStore keyStoreWithPrivateKey(String alias, X500Principal subject, KeyPair pair, long notBefore, long notAfter) - throws Exception { - X509Certificate leaf = sign(subject, pair, notBefore, notAfter, false); + X509Certificate leaf = sign(new X500Principal("CN=" + cn), pair, now - TimeUnit.HOURS.toMillis(1), + now + TimeUnit.DAYS.toMillis(1), false); KeyStore store = emptyStore(); - store.setKeyEntry(alias, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); + store.setKeyEntry(cn, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); return store; } /** @return Trust store with this authority alone. */ public KeyStore trustStore() throws Exception { - return trustStore(this); - } - - /** - * @param authorities Authorities to trust. - * @return Trust store with them. - */ - public static KeyStore trustStore(TestCertificateAuthority... authorities) throws Exception { KeyStore store = emptyStore(); - for (TestCertificateAuthority a : authorities) - store.setCertificateEntry(a.name.getName(), a.cert); + store.setCertificateEntry(name.getName(), cert); return store; } - /** - * @param keyStore Store with the certificate to present. - * @param trustStore Store with the authorities to trust. - * @return Context using the supplied key and trust stores. - */ - public static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { - SSLContext ctx = SSLContext.getInstance("TLS"); - - ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); - - return ctx; - } - - /** - * @param keyStore Store with the certificates to present. - * @param trustStore Store with the authorities to trust. - * @return Context and certificate chains from the supplied stores. - */ - public static SslBundle bundle(KeyStore keyStore, KeyStore trustStore) throws Exception { - KeyMaterial material = new KeyMaterial(keyStore, PWD, KeyManagerFactory.getDefaultAlgorithm()); - SSLContext ctx = SSLContext.getInstance("TLS"); - - ctx.init(material.keyManagers(), trustManagers(trustStore), null); - - return new SslBundle(ctx, material.certificateChains()); - } - /** * @param keyStore Store with at most one private key. * @return Its certificate chain, or {@code null} if the store has no private key. @@ -217,15 +137,11 @@ public static X509Certificate[] certificateChain(KeyStore keyStore) throws Excep } /** - * @param keyStore Store with the certificate to present. - * @return Key managers that present it. + * @param keyStore Store with exactly one private key. + * @return Its certificate chain in the form certificate metadata uses. */ - public static KeyManager[] keyManagers(KeyStore keyStore) throws Exception { - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - - kmf.init(keyStore, PWD); - - return kmf.getKeyManagers(); + public static List chain(KeyStore keyStore) throws Exception { + return List.of(certificateChain(keyStore)); } /** @@ -240,16 +156,6 @@ public static TrustManager[] trustManagers(KeyStore trustStore) throws Exception return tmf.getTrustManagers(); } - /** - * @param store Store. - * @param path File to write or replace. - */ - public static void save(KeyStore store, Path path) throws Exception { - try (OutputStream out = Files.newOutputStream(path)) { - store.store(out, PWD); - } - } - /** * @param subj Subject. * @param subjKeys Key pair of the subject. From afca753054b7ea8195e72a33e07e33337a538de2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 00:53:35 +0300 Subject: [PATCH 071/242] IGNITE-29210 Make reload tests state one guarantee each Co-Authored-By: Claude Opus 5.5 --- .../util/GridCommandHandlerSslReloadTest.java | 56 +- .../internal/ssl/SslContextProviderTest.java | 597 ++++--------- .../ignite/ssl/SslContextReloadTest.java | 798 ++++++------------ .../org/apache/ignite/ssl/SslTestUtils.java | 161 +--- .../TestCertificateAuthority.java | 121 ++- 5 files changed, 635 insertions(+), 1098 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 02f0ade39a17c..59585a0ab06a3 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -17,9 +17,15 @@ package org.apache.ignite.util; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.internal.IgniteEx; @@ -30,16 +36,17 @@ import org.junit.Assume; import org.junit.Test; -import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_OK; import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_UNEXPECTED_ERROR; import static org.apache.ignite.ssl.SslTestUtils.node; -import static org.apache.ignite.ssl.SslTestUtils.place; -import static org.apache.ignite.ssl.SslTestUtils.servedSubject; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; -/** Tests {@code --ssl reload} and {@code --ssl status} through the command line handler. */ +/** + * {@code --ssl reload} and {@code --ssl status} through control.sh. Only the CLI handler runs the test: it connects to a node over + * the client connector TLS that the command reloads, while the JMX handler calls the node in process and would only repeat + * SslContextReloadTest. + */ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractTest { /** Key store the nodes run on; replaced on disk to rotate the certificate. */ private Path keyStore; @@ -83,12 +90,23 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractT Files.deleteIfExists(keyStore); } - /** One command reloads every node, client ones included. Status shows the new certificate. A broken store fails the command. */ + /** + * The reload asks for confirmation. One command reloads every node, client ones included, and status shows the new + * certificate and the time of that reload; a broken store fails the command. + */ @Test public void testReloadAndStatus() throws Exception { assertNotNull("The reload must ask for confirmation", new SslReloadCommand().confirmationPrompt(new NoArg())); - List nodes = List.of(startGrid(0), startGrid(1), startClientGrid(2)); + IgniteEx srv0 = startGrid(0); + IgniteEx srv1 = startGrid(1); + IgniteEx cli = startClientGrid(CLIENT_NODE_NAME_PREFIX); + + // Transports are listed alphabetically; a client node runs no binary REST. + Map transports = Map.of( + srv0, "binary REST, client connector, communication, discovery", + srv1, "binary REST, client connector, communication, discovery", + cli, "client connector, communication, discovery"); String replacement = "node02"; String replacementSubj = "CN=" + replacement; @@ -96,30 +114,34 @@ public void testReloadAndStatus() throws Exception { place(replacement, keyStore); + long start = System.currentTimeMillis(); String reloadOutput = executeCommand(EXIT_CODE_OK, "--ssl", "reload"); - for (IgniteEx grid : nodes) - assertContains(log, reloadOutput, - node(grid) + ": reloaded " + transports(grid) + "; " + loaded); - - assertEquals(replacementSubj, servedSubject(nodes.get(0).localNode().attribute(ATTR_REST_TCP_PORT))); + transports.forEach((g, t) -> assertContains(log, reloadOutput, node(g) + ": reloaded " + t + "; " + loaded)); String statusOutput = executeCommand(EXIT_CODE_OK, "--ssl", "status"); - for (IgniteEx grid : nodes) - assertContains(log, statusOutput, node(grid) + ": " + transports(grid)); + transports.forEach((g, t) -> assertContains(log, statusOutput, node(g) + ": " + t + '\n')); assertContains(log, statusOutput, loaded); assertNotContains(log, statusOutput, "subject=CN=node01"); - assertContains(log, statusOutput, "last reload succeeded at"); + + List succeeded = Pattern.compile("last reload succeeded at (\\S+)").matcher(statusOutput).results() + .map(m -> Instant.parse(m.group(1))).collect(Collectors.toList()); + + assertEquals(statusOutput, transports.size(), succeeded.size()); + succeeded.forEach(t -> assertFalse(statusOutput, t.isBefore(Instant.ofEpochMilli(start)))); Files.write(keyStore, "not a key store".getBytes()); assertContains(log, executeCommand(EXIT_CODE_UNEXPECTED_ERROR, "--ssl", "reload"), "Failed to initialize key store"); } - /** @return Transports sharing the node's factory. Client nodes do not run binary REST. */ - private static String transports(IgniteEx node) { - return (node.localNode().isClient() ? "" : "binary REST, ") + "client connector, communication, discovery"; + /** + * @param name Test store, as {@code tests.properties} names it. + * @param dest File to replace. + */ + private static void place(String name, Path dest) throws IOException { + Files.copy(Path.of(GridTestUtils.keyStorePath(name)), dest, StandardCopyOption.REPLACE_EXISTING); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 481dda0bad109..a7ef2fc8ca7f1 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -17,524 +17,271 @@ package org.apache.ignite.internal.ssl; -import java.io.Serial; +import java.io.IOException; import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicReference; +import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; -import org.apache.ignite.IgniteCheckedException; +import javax.net.ssl.SSLParameters; import org.apache.ignite.IgniteException; -import org.apache.ignite.configuration.ClientConnectorConfiguration; -import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; +import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.KeyMaterial; +import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.ListeningTestLogger; +import org.apache.ignite.testframework.LogListener; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Assert; import org.junit.Test; +import static org.apache.ignite.internal.ssl.SslContextRegistry.BINARY_REST; import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; -import static org.apache.ignite.ssl.SslContextFactory.DFLT_KEY_ALGORITHM; -import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; -import static org.apache.ignite.ssl.SslTestUtils.reload; -import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; -import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; -import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; +import static org.apache.ignite.testframework.TestCertificateAuthority.bundle; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.context; +import static org.apache.ignite.testframework.TestCertificateAuthority.merged; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** Tests manual reload validation and certificate sources that supply optional metadata. */ +/** + * What {@link SslContextProvider#reload()} puts in use and records, what it refuses; the registry keeps one provider per factory + * object and warns at registration about key material that reload refuses. Reload refusals follow the order of the checks in the + * code: usable keys, an engine from the new context, a context not in use, certificates valid now. + */ public class SslContextProviderTest extends GridCommonAbstractTest { /** */ - private static final long MINUTE = 60_000L; - - /** */ - private static final long HOUR = 60 * MINUTE; + private static final long HOUR = 60 * 60_000L; - /** Default certificate authority for the tests. */ + /** Authority that signs the certificates the providers start on. */ private TestCertificateAuthority ca; /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { - ca = new TestCertificateAuthority("reloadca"); - } - - /** {@inheritDoc} */ - @Override protected void afterTest() { - stopAllGrids(); - } - - /** The node retains a bundle until reload and uses metadata from the factory's store loader. */ - @Test - public void testNodeKeepsBundleUntilReload() throws Exception { - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey); - SslContextProvider provider = new SslContextProvider(factory); - SSLContext first = provider.context(); - - assertSame(first, provider.context()); - assertNotNull(provider.certificateChains()); - assertEquals(1, factory.calls); - - provider.reload(); - - assertNotSame(first, provider.context()); - assertNotNull(provider.certificateChains()); - assertEquals(2, factory.calls); - } - - /** Nodes with different certificates from one authority keep communicating when only one node reloads. */ - @Test - public void testReloadWithDifferentCertificatesFromSameAuthority() throws Exception { - assertNodesReconnectAfterRotationAndRestart(ca); - } - - /** Nodes need to trust each other's authorities, not their own, when one node reloads its certificate. */ - @Test - public void testReloadWithDifferentAuthorities() throws Exception { - assertNodesReconnectAfterRotationAndRestart(new TestCertificateAuthority("peerca")); - } - - /** Reload installs the administrator's material; an actual TLS connection still rejects an authority the peer does not trust. */ - @Test - public void testReloadDoesNotBypassPeerTrust() throws Exception { - String untrustedCaName = "untrusted-ca"; - TestCertificateAuthority peerCa = new TestCertificateAuthority("peerca"); - TestCertificateAuthority untrusted = new TestCertificateAuthority(untrustedCaName); - AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey()); - KeyStore peerKeys = peerCa.keyStoreWithPrivateKey(); - - ScriptedSslContextFactory factory = factory(keys::get, peerCa.trustStore()); - ScriptedSslContextFactory peerFactory = factory(() -> peerKeys, ca.trustStore()); - - IgniteEx g0 = startNode(0, factory); - IgniteEx g1 = startNode(1, peerFactory); - - X509Certificate peerCert = certificate(provider(g1)); - - assertEquals(certificate(provider(g0)), servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0))); - - keys.set(untrusted.keyStoreWithPrivateKey()); - - assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); - assertTrue(provider(g0).reloadOutcome().successTime() > 0); - assertEquals("CN=" + untrustedCaName, certificate(provider(g0)).getIssuerX500Principal().getName()); - assertEquals(peerCert, certificate(provider(g1))); - - assertThrowsWithCause(() -> servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0)), SSLException.class); - } - - /** Commands report both private-key aliases before and after reloading a store with multiple keys. */ - @Test - public void testCommandsReportEveryPrivateKey() throws Exception { - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(this::twoKeys); - - IgniteEx g = startClientConnectorOn(factory); - - SslContextProvider p = provider(g, CLIENT_CONNECTOR); - - assertEquals(2, p.certificateChains().size()); - - for (String report : List.of(status(g), reload(g))) { - for (String alias : p.certificateChains().keySet()) - assertContains(log, report, "alias=" + alias); - } + ca = new TestCertificateAuthority("provider-ca"); } - /** Replacing one key with several keys must not prevent manual reload. */ + /** + * Reload installs the context and metadata of a new factory call; the factory is called once on creation and once per reload. + * The new keys come from another authority: reload does not check them against the trust of the node. + */ @Test - public void testReloadCanAddPrivateKeys() throws Exception { - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, this::twoKeys); + public void testReloadInstallsNewBundle() throws Exception { + TestCertificateAuthority otherCa = new TestCertificateAuthority("other-ca"); + KeyStore a = otherCa.keyStoreWithPrivateKey("a"); + KeyStore b = otherCa.keyStoreWithPrivateKey("b"); + SslBundle next = bundle(merged(a, b), ca.trustStore()); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); - SslContextProvider p = new SslContextProvider(factory); - SSLContext inUse = p.context(); + when(f.createSslBundle()).thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next); - assertEquals(1, p.certificateChains().size()); + SslContextProvider p = new SslContextProvider(f); p.reload(); - assertNotSame(inUse, p.context()); - assertEquals(2, p.certificateChains().size()); + assertSame(next.context(), p.context()); + assertEquals(Map.of("a", chain(a), "b", chain(b)), p.certificateChains()); + verify(f, times(2)).createSslBundle(); assertTrue(p.reloadOutcome().successTime() > 0); - assertEquals(0, p.reloadOutcome().failures()); } - /** Reload failures keep the active context and last success time. A successful retry clears the failure history. */ + /** + * A failed reload keeps the context, metadata and success time in use, counts failures in a row and records the time and the + * reason without the messages of wrappers; a successful reload clears the failures. + */ @Test public void testReloadFailureRetainsContextUntilRecovery() throws Exception { - String failureReason = "Certificate source is unavailable"; - Callable unavailable = () -> { - throw new IgniteException(failureReason); - }; - - Callable startupKeys = freshKeysValidFor(HOUR); - Callable successfulReloadKeys = freshKeysValidFor(HOUR); - Callable recoveryKeys = freshKeysValidFor(2 * HOUR); - ScriptedSslContextFactory factory = new ScriptedSslContextFactory( - startupKeys, successfulReloadKeys, unavailable, unavailable, recoveryKeys); - SslContextProvider p = new SslContextProvider(factory); - - p.reload(); - - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); - long lastSuccess = p.reloadOutcome().successTime(); + SslBundle reloaded = bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore()); + KeyStore recoveredKeys = ca.keyStoreWithPrivateKey("recovered"); + IgniteException failure = new IgniteException("Failed to load", new SSLException(new IOException("bad file"))); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); - assertTrue(lastSuccess > 0); + when(f.createSslBundle()) + .thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), reloaded) + .thenThrow(failure, failure) + .thenReturn(bundle(recoveredKeys, ca.trustStore())); - for (int failures = 1; failures <= 2; failures++) { - long attemptStart = System.currentTimeMillis(); + SslContextProvider p = new SslContextProvider(f); - GridTestUtils.assertThrows(log, p::reload, IgniteException.class, failureReason); + // A successful reload first, so that the success time the failures must keep is not 0. + p.reload(); - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - assertEquals(lastSuccess, p.reloadOutcome().successTime()); - assertEquals(failures, p.reloadOutcome().failures()); - assertTrue(p.reloadOutcome().failureTime() >= attemptStart); - assertContains(log, p.reloadOutcome().failureReason(), failureReason); - } + long successTime = p.reloadOutcome().successTime(); + long failureStart = System.currentTimeMillis(); - long recoveryStart = System.currentTimeMillis(); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, "Failed to load"); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, "Failed to load"); - SslBundle recovered = p.reload(); + ReloadOutcome failed = p.reloadOutcome(); - assertNotSame(inUse, p.context()); - assertSame(recovered.context(), p.context()); - assertEquals(recovered.certificateChains(), p.certificateChains()); - assertTrue(certificate(p).getNotAfter().after(chains.values().iterator().next().get(0).getNotAfter())); - assertTrue(p.reloadOutcome().successTime() >= recoveryStart); - assertEquals(0, p.reloadOutcome().failures()); - assertEquals(0, p.reloadOutcome().failureTime()); - assertNull(p.reloadOutcome().failureReason()); - } + assertSame(reloaded.context(), p.context()); + assertEquals(reloaded.certificateChains(), p.certificateChains()); + assertTrue(failed.failureTime() >= failureStart); + assertEquals(new ReloadOutcome(successTime, failed.failureTime(), "Failed to load: bad file", 2), failed); - /** A not yet valid candidate leaves the working context and its metadata in use. */ - @Test - public void testNotYetValidCertificateNotApplied() throws Exception { - long now = System.currentTimeMillis(); - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, - () -> ca.keyStoreWithPrivateKey("future", now + HOUR, now + 2 * HOUR)); - SslContextProvider p = new SslContextProvider(factory); - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); + p.reload(); - GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "The new certificate chain is not valid now"); + ReloadOutcome recovered = p.reloadOutcome(); - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - assertEquals(1, p.reloadOutcome().failures()); + assertEquals(Map.of("recovered", chain(recoveredKeys)), p.certificateChains()); + assertTrue(recovered.successTime() >= failed.failureTime()); + assertEquals(new ReloadOutcome(recovered.successTime(), 0, null, 0), recovered); } - /** A valid leaf does not make a replacement usable when its issuer has expired. */ + /** + * Key material without a private key, such as a trust store put in place of the key store, or with a private key that has no + * X.509 chain is not applied. + */ @Test - public void testExpiredIssuerNotApplied() throws Exception { - long now = System.currentTimeMillis(); - String expiredCaName = "expired-ca"; - TestCertificateAuthority expiredCa = new TestCertificateAuthority(expiredCaName, now - 2 * HOUR, now - HOUR); - KeyStore candidate = expiredCa.keyStoreWithPrivateKey(); - - TestCertificateAuthority.certificateChain(candidate)[0].checkValidity(); - - SslContextProvider p = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, () -> candidate)); - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); - - GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, "subject=CN=" + expiredCaName); - - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - assertEquals(1, p.reloadOutcome().failures()); + public void testKeyMaterialWithoutUsableKeyNotApplied() throws Exception { + // Aliases are sorted: the "z-" prefix puts the broken key after the valid one, so the check does not stop at the first key. + KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); + SSLContext ctx = context(valid, ca.trustStore()); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), + bundle(ca.trustStore(), ca.trustStore()), + new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); + + SslContextProvider p = new SslContextProvider(f); + + assertReloadRejected(p, "The new key material has no private key, so the node would present no certificate"); + assertReloadRejected(p, "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate " + + "chain [alias=z-no-chain]"); } - /** A valid first key must not hide an expired certificate belonging to another key. */ + /** A context that cannot create an engine, here with a protocol the JVM does not support, is not applied. */ @Test - public void testEveryPrivateKeyIsValidated() throws Exception { - long now = System.currentTimeMillis(); - String validAlias = "a-valid"; - String expiredAlias = "z-expired"; - KeyStore keys = ca.keyStoreWithPrivateKey(validAlias); - KeyStore expired = ca.keyStoreWithPrivateKey(expiredAlias, now - 2 * HOUR, now - HOUR); - - keys.setKeyEntry(expiredAlias, expired.getKey(expiredAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - expired.getCertificateChain(expiredAlias)); + public void testUnsupportedProtocolNotApplied() throws Exception { + SslBundle supported = bundle(ca.keyStoreWithPrivateKey("next"), ca.trustStore()); + SSLParameters params = new SSLParameters(); - SslContextProvider provider = new SslContextProvider(new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey, () -> keys)); - SSLContext original = provider.context(); - Map> originalChains = provider.certificateChains(); + params.setProtocols(new String[] {"SSLvDoesNotExist"}); - GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, "alias=" + expiredAlias); - - assertSame(original, provider.context()); - assertEquals(originalChains, provider.certificateChains()); - } + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); - /** A known private key without an X.509 chain cannot replace the working material. */ - @Test - public void testMissingCertificateChainNotApplied() throws Exception { - String alias = "missing-chain"; + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), + new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())); - assertMissingChainRejected(Map.of(alias, List.of()), alias); + assertReloadRejected(new SslContextProvider(f), "SSLvDoesNotExist"); } - /** A valid key does not hide another key whose X.509 chain is absent. */ + /** A factory that returns the context in use, as a caching custom factory does, gives the node no new certificates. */ @Test - public void testMissingChainAmongOtherKeysNotApplied() throws Exception { - String validAlias = "a-valid"; - String missingAlias = "z-missing"; - List validChain = List.of(TestCertificateAuthority.certificateChain(ca.keyStoreWithPrivateKey(validAlias))); - - assertMissingChainRejected(Map.of(validAlias, validChain, missingAlias, List.of()), missingAlias); - } - - /** - * @param candidateChains Explicit metadata supplied by a factory, with a known key lacking an X.509 chain. - * @param missingAlias Alias of that key. - */ - private void assertMissingChainRejected(Map> candidateChains, String missingAlias) throws Exception { - SslBundle init = TestCertificateAuthority.bundle(ca.keyStoreWithPrivateKey(), ca.trustStore()); - SSLContext candidate = TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - AbstractSslContextFactory factory = mock(AbstractSslContextFactory.class); - - when(factory.createSslBundle()).thenReturn(init, new SslBundle(candidate, candidateChains)); - - SslContextProvider provider = new SslContextProvider(factory); - - Throwable failure = GridTestUtils.assertThrows(log, provider::reload, IgniteCheckedException.class, - "The private key has no X.509 certificate chain"); + public void testContextInUseNotReloaded() throws Exception { + SSLContext ctx = context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - assertContains(log, failure.getMessage(), "alias=" + missingAlias); - assertContains(log, failure.getMessage(), "remove the key or provide its X.509 certificate chain"); - assertSame(init.context(), provider.context()); - assertEquals(init.certificateChains(), provider.certificateChains()); - assertEquals(1, provider.reloadOutcome().failures()); + assertReloadRejected(new SslContextProvider(() -> ctx), "The SSL context factory returns the context in use"); } - /** An unsupported protocol in a replacement context leaves the working context in use. */ + /** Every certificate of every chain must be valid now: a later key that is not valid yet and an expired authority are refused. */ @Test - public void testUnsupportedProtocolNotApplied() { - String invalidProtocol = "SSLvDoesNotExist"; - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(ca::keyStoreWithPrivateKey); - SslContextProvider p = new SslContextProvider(factory); - SSLContext inUse = p.context(); - - factory.setProtocols(invalidProtocol); - - GridTestUtils.assertThrows(log, p::reload, IllegalArgumentException.class, invalidProtocol); - - assertSame(inUse, p.context()); - assertEquals(1, p.reloadOutcome().failures()); - } + public void testEveryCertificateMustBeValidNow() throws Exception { + long now = System.currentTimeMillis(); + KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); + KeyStore notYetValid = ca.keyStoreWithPrivateKey("z-not-yet-valid", now + HOUR, now + 2 * HOUR); + KeyStore expiredCaLeaf = new TestCertificateAuthority("expired-ca", now - 2 * HOUR, now - HOUR).keyStoreWithPrivateKey("leaf"); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); - /** @return Store with two private keys, each with its own valid certificate chain. */ - private KeyStore twoKeys() throws Exception { - String firstAlias = "first-key"; - String secondAlias = "second-key"; - KeyStore keys = ca.keyStoreWithPrivateKey(firstAlias); - KeyStore other = ca.keyStoreWithPrivateKey(secondAlias); + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), + bundle(merged(valid, notYetValid), ca.trustStore()), + bundle(expiredCaLeaf, ca.trustStore())); - keys.setKeyEntry(secondAlias, other.getKey(secondAlias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - other.getCertificateChain(secondAlias)); + SslContextProvider p = new SslContextProvider(f); - return keys; + assertReloadRejected(p, "The new certificate chain is not valid now [alias=z-not-yet-valid, subject=CN=z-not-yet-valid,"); + assertReloadRejected(p, "The new certificate chain is not valid now [alias=leaf, subject=CN=expired-ca,"); } /** - * @param factory SSL context factory of the client connector. - * @return Started node whose client connector runs on the factory and whose other transports run without SSL. + * The registry keeps one provider per factory object: transports of one factory share it, while an equal factory, here a record + * over the same context, gets its own. */ - private IgniteEx startClientConnectorOn(AbstractSslContextFactory factory) throws Exception { - return startGrid(getTestIgniteInstanceName(0), cfg -> cfg - .setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) - .setUseIgniteSslContextFactory(false).setSslContextFactory(factory))); - } - - /** - * @param idx Node index. - * @param factory SSL context factory of the node. - * @return Started node. - */ - private IgniteEx startNode(int idx, AbstractSslContextFactory factory) throws Exception { - return startGrid(getTestIgniteInstanceName(idx), cfg -> cfg.setSslContextFactory(factory)); - } + @Test + public void testRegistryKeepsProviderPerFactoryObject() throws Exception { + /** Factory that always returns the same context; records with the same context are equal. */ + record Fixed(SSLContext ctx) implements Factory { + /** {@inheritDoc} */ + @Override public SSLContext create() { + return ctx; + } + } - /** - * @param source Key store to use on each request. - * @param trust Authorities the node accepts. - * @return Configured factory. - */ - private ScriptedSslContextFactory factory(Callable source, KeyStore trust) { - ScriptedSslContextFactory factory = new ScriptedSslContextFactory(source); + SSLContext ctx = context(ca.keyStoreWithPrivateKey(), ca.trustStore()); + Fixed shared = new Fixed(ctx); + SslContextRegistry registry = new SslContextRegistry(new StandaloneGridKernalContext(log, null)); - factory.trust = trust; + SSLContext communication = registry.register(shared, COMMUNICATION); + SSLContext discovery = registry.register(shared, DISCOVERY); + SSLContext binaryRest = registry.register(new Fixed(ctx), BINARY_REST); - return factory; + assertSame(communication, discovery); + assertNotSame(communication, binaryRest); + assertEquals(2, registry.providers().size()); } /** - * Rotates one node's certificate, then restarts each node to check that they load their current certificates and reconnect. - * - * @param peerCa Authority of the peer whose certificate stays unchanged. + * Registration warns about key material that reload refuses: no private key, here a trust store put in place of the key store, + * or a private key without an X.509 chain. */ - private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthority peerCa) throws Exception { - AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey()); - KeyStore peerKeys = peerCa.keyStoreWithPrivateKey(); - ScriptedSslContextFactory factory = factory(keys::get, peerCa.trustStore()); - ScriptedSslContextFactory peerFactory = factory(() -> peerKeys, ca.trustStore()); - - IgniteEx g0 = startNode(0, factory); - IgniteEx g1 = startNode(1, peerFactory); - - X509Certificate peerCert = certificate(provider(g1)); - - assertFalse(certificate(provider(g0)).equals(peerCert)); - assertComputeWorks(g0, g1); - - KeyStore replacement = ca.keyStoreWithPrivateKey(); - X509Certificate replacementCert = TestCertificateAuthority.certificateChain(replacement)[0]; - - keys.set(replacement); - - assertContains(log, reload(g0), ": reloaded " + COMMUNICATION + ", " + DISCOVERY); - - assertEquals(replacementCert, certificate(provider(g0))); - assertEquals(peerCert, certificate(provider(g1))); - assertEquals(0, provider(g1).reloadOutcome().successTime()); - assertEquals(replacementCert, servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(g0))); - assertEquals(peerCert, servedCertificate(factory.createSslBundle().context(), discoveryPort(g1))); - - assertComputeWorks(g0, g1); - - stopGrid(1); - - g1 = startNode(1, factory(() -> peerKeys, ca.trustStore())); + @Test + public void testRegistryWarnsAboutKeysReloadRefuses() throws Exception { + KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); + SSLContext ctx = context(valid, ca.trustStore()); + AbstractSslContextFactory noKey = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory noChain = mock(AbstractSslContextFactory.class); - assertEquals(replacementCert, certificate(provider(g0))); - assertEquals(peerCert, certificate(provider(g1))); - assertComputeWorks(g0, g1); + when(noKey.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); + when(noChain.createSslBundle()).thenReturn(new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); - stopGrid(0); + ListeningTestLogger registryLog = new ListeningTestLogger(log); + LogListener noKeyWarned = LogListener.matches("The key material has no private key, so the node presents no certificate; " + + "reload rejects such key material").times(1).build(); + LogListener noChainWarned = LogListener.matches("The private key has no X.509 certificate chain, so the node never presents " + + "it; reload rejects this key store until the key is removed or its X.509 certificate chain is provided " + + "[alias=z-no-chain]").times(1).build(); - IgniteEx restarted = startNode(0, factory(keys::get, peerCa.trustStore())); + registryLog.registerAllListeners(noKeyWarned, noChainWarned); - assertEquals(replacementCert, certificate(provider(restarted))); - assertEquals(replacementCert, servedCertificate(peerFactory.createSslBundle().context(), discoveryPort(restarted))); - assertEquals(peerCert, certificate(provider(g1))); - assertComputeWorks(restarted, g1); - } + SslContextRegistry registry = new SslContextRegistry(new StandaloneGridKernalContext(registryLog, null)); - /** Runs a job from each node on its peer. */ - private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { - for (IgniteEx from : List.of(g0, g1)) { - IgniteEx to = from == g0 ? g1 : g0; + registry.register(noKey, CLIENT_CONNECTOR); + registry.register(noChain, BINARY_REST); - assertEquals((Integer)1, from.compute(from.cluster().forNodeId(to.localNode().id())).withTimeout(10_000).call(() -> 1)); - } + assertTrue("No private key", noKeyWarned.check()); + assertTrue("Key without a chain", noChainWarned.check()); } /** - * @param lifetime Lifetime of the certificate. - * @return Step that issues a certificate valid from the moment it is issued. - */ - private Callable freshKeysValidFor(long lifetime) { - return () -> { - long now = System.currentTimeMillis(); - - return ca.keyStoreWithPrivateKey("node", now, now + lifetime); - }; - } - - /** - * @param g Node. - * @return SSL context provider used by communication. - */ - private static SslContextProvider provider(IgniteEx g) { - return provider(g, COMMUNICATION); - } - - /** - * @param g Node. - * @param transport Transport. - * @return SSL context provider used by the transport. + * Checks that reload fails with the reason in its message and in the recorded outcome, keeps the context and metadata in use + * and counts one more failure. + * + * @param p Provider. + * @param reason Expected part of the failure message; also names the case in assertion messages. */ - private static SslContextProvider provider(IgniteEx g, String transport) { - for (SslContextProvider p : g.context().sslContexts().providers()) { - if (p.transports().contains(transport)) - return p; - } - - throw new AssertionError("Nothing serves " + transport); - } - - /** @return The sole leaf certificate of a provider used by a single-key test. */ - private static X509Certificate certificate(SslContextProvider provider) { - Map> chains = provider.certificateChains(); - - assertNotNull(chains); - assertEquals(1, chains.size()); - - return chains.values().iterator().next().get(0); - } - - /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ - private class ScriptedSslContextFactory extends AbstractSslContextFactory { - /** */ - @Serial - private static final long serialVersionUID = 0L; - - /** Answers to the requests in order, the last one repeated for the rest. */ - private final List> steps; - - /** Requests so far. */ - private int calls; - - /** Trust store for new contexts, or {@code null} to trust only the default test authority. */ - private volatile KeyStore trust; - - /** @param steps Answers to the requests in order, the last one repeated for the rest. */ - @SafeVarargs - private ScriptedSslContextFactory(Callable... steps) { - this.steps = List.of(steps); - } - - /** {@inheritDoc} */ - @Override protected void checkParameters() { - // No-op. - } - - /** {@inheritDoc} */ - @Override protected KeyMaterial loadKeyMaterial() { - Callable step = steps.get(Math.min(calls++, steps.size() - 1)); - - try { - KeyStore store = step.call(); + private void assertReloadRejected(SslContextProvider p, String reason) { + SSLContext inUse = p.context(); + Map> chains = p.certificateChains(); + int failures = p.reloadOutcome().failures(); - return new KeyMaterial(store, TestCertificateAuthority.PWD, DFLT_KEY_ALGORITHM); - } - catch (IgniteException e) { - throw e; - } - catch (Exception e) { - throw new IgniteException(e); - } - } + GridTestUtils.assertThrows(log, p::reload, Exception.class, reason); - /** {@inheritDoc} */ - @Override protected TrustManager[] createTrustManagers() throws SSLException { - try { - return TestCertificateAuthority.trustManagers(trust != null ? trust : ca.trustStore()); - } - catch (Exception e) { - throw new SSLException(e); - } - } + Assert.assertSame(reason, inUse, p.context()); + assertEquals(reason, chains, p.certificateChains()); + assertEquals(reason, failures + 1, p.reloadOutcome().failures()); + assertContains(log, p.reloadOutcome().failureReason(), reason); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 0ac21977f111b..266e4e2456f2f 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -26,6 +26,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -41,15 +42,13 @@ import org.apache.ignite.configuration.IgniteConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.management.api.NoArg; -import org.apache.ignite.internal.management.ssl.AbstractSslTask; import org.apache.ignite.internal.management.ssl.SslReloadTask; import org.apache.ignite.internal.management.ssl.SslStatusTask; -import org.apache.ignite.internal.ssl.SslContextProvider; -import org.apache.ignite.internal.ssl.SslContextRegistry; import org.apache.ignite.internal.util.typedef.G; import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.internal.visor.VisorTaskArgument; +import org.apache.ignite.lang.IgniteBiTuple; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; @@ -61,9 +60,9 @@ import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -import org.mockito.Mockito; import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; +import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; import static org.apache.ignite.internal.ssl.SslContextRegistry.BINARY_REST; import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; @@ -76,59 +75,61 @@ import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; -import static org.apache.ignite.testframework.TestCertificateAuthority.certificateChain; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.context; +import static org.apache.ignite.testframework.TestCertificateAuthority.merged; +import static org.apache.ignite.testframework.TestCertificateAuthority.save; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** - * Tests {@code --ssl reload} and {@code --ssl status} on running nodes. Each node has separate key and trust stores so that tests - * can replace or corrupt them independently. The tests generate the keys, trust roots and validity periods explicitly. + * {@code --ssl reload} and {@code --ssl status} on running nodes: new connections of every transport get the reloaded certificate, + * every node logs who reloaded it, a factory is loaded once per reload whatever transports share it, separate factories get + * providers of their own, and a failing factory does not stop the others. The report, the node log and status show refused + * reloads; the report shows unavailable nodes and lists failures first; both commands name every key; a node on an invalid + * certificate warns and fails status; without SSL both commands say that no factory is configured. The last test replaces the + * authority of a cluster. A node starts on old-key from old-ca and trusts both authorities unless a test says otherwise; its + * client connector shares the node factory and asks for no client certificate. Each node reads its own key and trust store + * files, which tests replace on disk. */ public class SslContextReloadTest extends GridCommonAbstractTest { - /** Transports of a node whose client connector shares the factory of the node. */ + /** Transports of a node whose client connector shares the node factory. */ private static final String ALL_TRANSPORTS = CLIENT_CONNECTOR + ", " + COMMUNICATION + ", " + DISCOVERY; - /** Validity margin for generated certificates. */ + /** */ private static final long HOUR = TimeUnit.HOURS.toMillis(1); - /** Alias and subject of the initial node key. */ - private static final String OLD_NODE = "old-node"; + /** Alias and subject of the key every node starts on. */ + private static final String OLD_KEY = "old-key"; - /** Alias and subject of the replacement node key. */ - private static final String NEW_NODE = "new-node"; + /** Alias and subject of the replacement key. */ + private static final String NEW_KEY = "new-key"; - /** Directory the stores of the nodes are placed in. */ + /** Directory with the store files of the nodes. */ private Path dir; - /** Whether the client connector runs on a factory of its own. */ - private boolean ownClientConnectorFactory; - - /** Time used to generate certificates for this test. */ - private long now; - - /** Initial authority. */ + /** Authority of the key every node starts on. */ private TestCertificateAuthority oldCa; /** Replacement authority. */ private TestCertificateAuthority newCa; - /** Initial node key and certificate. */ + /** Key every node starts on, issued by {@link #oldCa}. */ private KeyStore oldKeys; - /** Replacement node key and certificate. */ + /** Replacement key, issued by {@link #newCa}. */ private KeyStore newKeys; - /** Certificate initially served by the node. */ + /** Certificate of {@link #oldKeys}. */ private X509Certificate oldCert; - /** Certificate installed by a successful reload. */ + /** Certificate of {@link #newKeys}. */ private X509Certificate newCert; - /** Trust roots for probes and the transitional rotation step. */ + /** Both authorities: nodes trust them at start, probes always. */ private KeyStore trustBoth; - /** Trust store every node starts on, unless one is placed for it. */ - private KeyStore initTrustStore; - - /** Discovery addresses shared only by nodes in this test. */ + /** Discovery addresses shared only by nodes of this test. */ private TcpDiscoveryVmIpFinder ipFinder; /** */ @@ -140,39 +141,27 @@ public class SslContextReloadTest extends GridCommonAbstractTest { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); - if (!Files.exists(keyStore(igniteInstanceName))) - TestCertificateAuthority.save(oldKeys, keyStore(igniteInstanceName)); + if (!Files.exists(keyFile(igniteInstanceName))) + save(oldKeys, keyFile(igniteInstanceName)); - if (!Files.exists(trustStore(igniteInstanceName))) - TestCertificateAuthority.save(initTrustStore, trustStore(igniteInstanceName)); + if (!Files.exists(trustFile(igniteInstanceName))) + save(trustBoth, trustFile(igniteInstanceName)); - ClientConnectorConfiguration cliCfg = new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) - .setUseIgniteSslContextFactory(!ownClientConnectorFactory); - - if (ownClientConnectorFactory) - cliCfg.setSslContextFactory(storeFactory(igniteInstanceName)); - - return cfg.setSslContextFactory(storeFactory(igniteInstanceName)).setClientConnectorConfiguration(cliCfg); + return cfg.setSslContextFactory(storeFactory(keyFile(igniteInstanceName), trustFile(igniteInstanceName))) + .setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false)); } /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { ipFinder = new TcpDiscoveryVmIpFinder(true); dir = Files.createTempDirectory("ignite-ssl-reload-"); - now = System.currentTimeMillis(); oldCa = new TestCertificateAuthority("old-ca"); - - // The replacement issuer expires before its leaf, so status must show the chain's earlier expiry. - long issuerExpiry = now + HOUR; - long leafExpiry = now + 2 * HOUR; - - newCa = new TestCertificateAuthority("new-ca", now - HOUR, issuerExpiry); - oldKeys = oldCa.keyStoreWithPrivateKey(OLD_NODE); - newKeys = newCa.keyStoreWithPrivateKey(NEW_NODE, now - HOUR, leafExpiry); - oldCert = certificateChain(oldKeys)[0]; - newCert = certificateChain(newKeys)[0]; + newCa = new TestCertificateAuthority("new-ca"); + oldKeys = oldCa.keyStoreWithPrivateKey(OLD_KEY); + newKeys = newCa.keyStoreWithPrivateKey(NEW_KEY); + oldCert = chain(oldKeys).get(0); + newCert = chain(newKeys).get(0); trustBoth = TestCertificateAuthority.trustStore(oldCa, newCa); - initTrustStore = trustBoth; } /** {@inheritDoc} */ @@ -183,421 +172,300 @@ public class SslContextReloadTest extends GridCommonAbstractTest { } /** - * A rotation on a running cluster reaches new connections, the report, the node log and status, and leaves established - * connections working. + * After reload, new connections to the client connector, communication and discovery get the new certificate, while an open + * thin client connection keeps working. */ @Test - public void testReloadOnRunningCluster() throws Exception { - initTrustStore = trustBoth; - - X509Certificate[] replacementChain = certificateChain(newKeys); - long issuerExpiry = replacementChain[1].getNotAfter().getTime(); - - assertTrue("The issuer must expire before its leaf", issuerExpiry < newCert.getNotAfter().getTime()); - - IgniteEx g0 = startGrid(0); - IgniteEx g1 = startGrid(1); - - LogListener logged = LogListener.matches(Pattern.compile("TLS certificates reloaded \\[transports=" + ALL_TRANSPORTS + - ", alias=" + NEW_NODE + ", subject=" + Pattern.quote(newCert.getSubjectX500Principal().getName()) + - ", .*initiator=management command, originNodeId=" + g0.localNode().id())).times(2).build(); - - nodeLog.registerListener(logged); - + public void testNewConnectionsGetReloadedCertificate() throws Exception { + IgniteEx g = startGrid(0); + int clientPort = g.context().clientListener().port(); // The client trusts only the old CA, so it cannot reconnect after the node switches to a certificate from the new CA. - SSLContext clientCtx = TestCertificateAuthority.context(oldKeys, oldCa.trustStore()); + SSLContext clientCtx = context(oldKeys, oldCa.trustStore()); - try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() - .setAddresses("127.0.0.1:" + g0.context().clientListener().port()) + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration().setAddresses("127.0.0.1:" + clientPort) .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> clientCtx) .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { placeKeys(newKeys); - String res = reload(g0, g1); + reload(g); cli.cacheNames(); - - assertReloaded(res, g0, ALL_TRANSPORTS); - assertReloaded(res, g1, ALL_TRANSPORTS); - assertTrue(logged.check()); } - assertEquals(newCert, certificateAt(g0.context().clientListener().port())); - assertEquals(newCert, certificateAt(((TcpCommunicationSpi)g0.configuration().getCommunicationSpi()).boundPort())); - assertEquals(newCert, certificateAt(discoveryPort(g0))); - - SslContextProvider p = g0.context().sslContexts().providers().iterator().next(); - - assertEquals(newCert, p.certificateChains().get(NEW_NODE).get(0)); - assertTrue(p.reloadOutcome().successTime() > 0); - - String status = status(g0); - - assertContains(log, status, "chainNotAfter=" + Instant.ofEpochMilli(issuerExpiry)); - assertContains(log, status, "last reload succeeded at " + Instant.ofEpochMilli(p.reloadOutcome().successTime())); - - stopGrid(0); - - IgniteEx restarted = startGrid(0); + SSLContext probe = context(oldKeys, trustBoth); - assertEquals(newCert, certificateAt(restarted.context().clientListener().port())); - assertEquals(newCert, certificateAt(discoveryPort(restarted))); + assertEquals(newCert, servedCertificate(probe, clientPort)); + assertEquals(newCert, servedCertificate(probe, communicationPort(g))); + assertEquals(newCert, servedCertificate(probe, discoveryPort(g))); } - /** One node fails: the others still take the new certificate and appear in the report; the command fails. */ + /** Every node logs the alias and subject it loaded and the node that received the command. */ @Test - public void testPartialFailureAcrossNodes() throws Exception { + public void testReloadAudited() throws Exception { IgniteEx g0 = startGrid(0); - IgniteEx g1 = startGrid(1); - - TestCertificateAuthority.save(newKeys, keyStore(g0.name())); - Files.write(keyStore(g1.name()), "not a key store".getBytes()); - - String res = reloadFailure(g0, g1); - - assertReloaded(res, g0, ALL_TRANSPORTS); - assertFailed(res, g1, ALL_TRANSPORTS); - assertContains(log, res, "Failed to initialize key store"); - String failed = "Failed on 1 node(s):\n" + node(g1) + ": failed on "; - String succeeded = "\n\nSucceeded on 1 node(s):\n" + node(g0) + ": reloaded "; + startGrid(1); - assertTrue("The failed node must be listed first, in a separate block: " + res, - res.contains(failed) && res.indexOf(succeeded) > res.indexOf(failed)); + LogListener audited = LogListener.matches(Pattern.compile(Pattern.quote("TLS certificates reloaded [transports=" + + ALL_TRANSPORTS + ", alias=" + NEW_KEY + ", subject=CN=" + NEW_KEY + ", ") + ".*" + + Pattern.quote(", initiator=management command, originNodeId=" + g0.localNode().id() + ']'))).times(2).build(); - assertEquals(newCert, certificateAt(g0.context().clientListener().port())); - assertEquals(oldCert, certificateAt(g1.context().clientListener().port())); + nodeLog.registerListener(audited); + placeKeys(newKeys); - String status = status(g1); + reload(g0); - assertContains(log, status, "last reload failed 1 time(s) in a row"); - assertContains(log, status, "Failed to initialize key store"); - assertContains(log, status, "loaded alias=" + OLD_NODE + ", subject=" + oldCert.getSubjectX500Principal().getName() + ','); + assertTrue(audited.check()); } /** - * A factory that is not an {@link AbstractSslContextFactory} and returns the context in use fails the reload: the node gets no - * new certificates from it. + * A factory shared by every transport, binary REST included, is loaded once on start and once per reload; status does not load + * it. A factory that is not an {@link AbstractSslContextFactory} gives no certificate metadata but still reloads. */ @Test - public void testCachingFactoryNotReloaded() throws Exception { - IgniteEx g = startGrid(0, cfg -> { - SSLContext ctx = cfg.getSslContextFactory().create(); - - cfg.setSslContextFactory(() -> ctx); - }); - - placeKeys(newKeys); - - String res = reloadFailure(g); - - assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "returns the context in use"); - - assertEquals(oldCert, certificateAt(g.context().clientListener().port())); - } - - /** A shared custom factory loads once at startup and once per reload. Status does not load it. */ - @Test - public void testCustomFactoryReloaded() throws Exception { - TestFactory factory = new TestFactory(); - int loadsAfterStart = 1; - int loadsAfterReload = loadsAfterStart + 1; - + public void testSharedFactoryLoadedOncePerReload() throws Exception { + String name = getTestIgniteInstanceName(0); + CountingFactory factory = new CountingFactory(keyFile(name), trustFile(name)); IgniteEx g = startGrid(0, cfg -> { cfg.setSslContextFactory(factory); cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true)); }); - String transports = BINARY_REST + ", " + ALL_TRANSPORTS; - assertEquals("Startup must load the shared factory once", loadsAfterStart, factory.calls); - assertContains(log, status(g), node(g) + ": " + transports); - assertEquals("Status must not load the factory", loadsAfterStart, factory.calls); - - SslContextRegistry registry = g.context().sslContexts(); - SSLContext ctx = registry.register(factory, COMMUNICATION); - - assertSame(ctx, registry.register(factory, DISCOVERY)); - assertSame(ctx, registry.register(factory, CLIENT_CONNECTOR)); - assertSame(ctx, registry.register(factory, BINARY_REST)); - assertEquals(1, registry.providers().size()); - assertEquals(loadsAfterStart, factory.calls); + assertEquals("Start", 1, factory.loads.get()); + assertEquals(node(g) + ": " + transports + '\n' + DOUBLE_INDENT + "certificate metadata is unavailable\n", status(g)); + assertEquals("Status", 1, factory.loads.get()); placeKeys(newKeys); - assertReloaded(reload(g), g, transports); - assertEquals("Reload must load the shared factory once", loadsAfterReload, factory.calls); - assertSame(ctx, registry.register(factory, COMMUNICATION)); - assertContains(log, status(g), "certificate metadata is unavailable"); - assertEquals("Status must not load the factory", loadsAfterReload, factory.calls); + assertEquals(node(g) + ": reloaded " + transports + '\n', reload(g)); + assertEquals("Reload", 2, factory.loads.get()); - assertEquals(newCert, certificateAt(discoveryPort(g))); - assertEquals(newCert, certificateAt(g.context().clientListener().port())); - assertEquals(newCert, certificateAt(g.localNode().attribute(ATTR_REST_TCP_PORT))); + SSLContext probe = context(oldKeys, trustBoth); + + assertEquals(newCert, servedCertificate(probe, discoveryPort(g))); + assertEquals(newCert, servedCertificate(probe, g.context().clientListener().port())); + assertEquals(newCert, servedCertificate(probe, g.localNode().attribute(ATTR_REST_TCP_PORT))); } - /** Equal but distinct factories reload independently, including when one of them fails. */ + /** A client connector and binary REST with factories of their own get providers of their own, each reading its own store. */ @Test - public void testEqualFactoriesReloadIndependently() throws Exception { - TestFactory nodeFactory = new TestFactory(); - TestFactory clientFactory = new TestFactory(); - int loadsAfterStart = 1; - int loadsAfterReload = loadsAfterStart + 1; - int loadsAfterFailure = loadsAfterReload + 1; + public void testSeparateFactoriesReloadedSeparately() throws Exception { + KeyStore clientKeys = oldCa.keyStoreWithPrivateKey("client-connector-key"); + Path clientFile = dir.resolve("client-connector-key.jks"); + Path restFile = dir.resolve("binary-rest-key.jks"); - assertNotSame(nodeFactory, clientFactory); - assertEquals(nodeFactory, clientFactory); - assertEquals(nodeFactory.hashCode(), clientFactory.hashCode()); + save(clientKeys, clientFile); + save(oldCa.keyStoreWithPrivateKey("binary-rest-key"), restFile); IgniteEx g = startGrid(0, cfg -> { - cfg.setSslContextFactory(nodeFactory); - cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory); - }); - - SslContextRegistry registry = g.context().sslContexts(); + Path trust = trustFile(cfg.getIgniteInstanceName()); - assertEquals(2, registry.providers().size()); - assertNotSame(registry.register(nodeFactory, COMMUNICATION), registry.register(clientFactory, CLIENT_CONNECTOR)); - assertEquals(loadsAfterStart, nodeFactory.calls); - assertEquals(loadsAfterStart, clientFactory.calls); - - placeKeys(newKeys); - - String res = reload(g); - - assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); - assertReloaded(res, g, CLIENT_CONNECTOR); - assertEquals(loadsAfterReload, nodeFactory.calls); - assertEquals(loadsAfterReload, clientFactory.calls); - - nodeFactory.fail = true; + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) + .setSslContextFactory(storeFactory(clientFile, trust)); + cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslFactory(storeFactory(restFile, trust))); + }); - KeyStore nextKeys = newCa.keyStoreWithPrivateKey(); + save(newKeys, restFile); - placeKeys(nextKeys); + reload(g); - res = reloadFailure(g); + SSLContext probe = context(oldKeys, trustBoth); - assertFailed(res, g, COMMUNICATION + ", " + DISCOVERY); - assertReloaded(res, g, CLIENT_CONNECTOR); - assertEquals(loadsAfterFailure, nodeFactory.calls); - assertEquals(loadsAfterFailure, clientFactory.calls); - assertEquals(newCert, certificateAt(discoveryPort(g))); - assertEquals(certificateChain(nextKeys)[0], certificateAt(g.context().clientListener().port())); + assertEquals(oldCert, servedCertificate(probe, discoveryPort(g))); + assertEquals(chain(clientKeys).get(0), servedCertificate(probe, g.context().clientListener().port())); + assertEquals(newCert, servedCertificate(probe, g.localNode().attribute(ATTR_REST_TCP_PORT))); } - /** A factory linkage failure is audited and does not prevent reloading the remaining factories on the node. */ + /** A factory that fails, even with a linkage error, is logged and does not prevent reloading the other factories of the node. */ @Test - public void testLinkageErrorDoesNotSkipRemainingFactory() throws Exception { - String failureReason = "Certificate provider class is unavailable"; + public void testFailingFactoryDoesNotSkipOthers() throws Exception { + String name = getTestIgniteInstanceName(0); + SslContextFactory nodeFactory = storeFactory(keyFile(name), trustFile(name)); AtomicBoolean fail = new AtomicBoolean(); - TestFactory nodeFactory = new TestFactory() { - @Override public SSLContext create() { + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(() -> { if (fail.get()) - throw new NoClassDefFoundError(failureReason); + throw new NoClassDefFoundError("Certificate provider class is unavailable"); - return super.create(); - } - }; - TestFactory clientFactory = new TestFactory(); - IgniteEx g = startGrid(0, cfg -> { - cfg.setSslContextFactory(nodeFactory); - cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory); + return nodeFactory.create(); + }); + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) + .setSslContextFactory(storeFactory(keyFile(name), trustFile(name))); }); + LogListener logged = LogListener.matches("Failed to reload TLS certificates, the ones in use stay [transports=" + COMMUNICATION + + ", " + DISCOVERY + ", initiator=management command, originNodeId=" + g.localNode().id() + + ", reason=Certificate provider class is unavailable]").build(); - assertEquals(COMMUNICATION + ", " + DISCOVERY, g.context().sslContexts().providers().iterator().next().transports()); - - LogListener audited = LogListener.matches(msg -> msg.startsWith("Failed to reload TLS certificates") && - msg.contains("initiator=management command, originNodeId=" + g.localNode().id()) && - msg.contains("reason=" + failureReason)).build(); - - nodeLog.registerListener(audited); + nodeLog.registerListener(logged); placeKeys(newKeys); fail.set(true); String res = reloadFailure(g); - assertFailed(res, g, COMMUNICATION + ", " + DISCOVERY); - assertContains(log, res, failureReason); - assertReloaded(res, g, CLIENT_CONNECTOR); - assertEquals(oldCert, certificateAt(discoveryPort(g))); - assertEquals(newCert, certificateAt(g.context().clientListener().port())); - assertTrue(audited.check()); + assertContains(log, res, node(g) + ": failed on " + COMMUNICATION + ", " + DISCOVERY + + " (Certificate provider class is unavailable)"); + assertContains(log, res, node(g) + ": reloaded " + CLIENT_CONNECTOR + "; loaded alias=" + NEW_KEY + ", "); + assertTrue(logged.check()); } - /** An expired certificate stays out of use, and the report, log and status identify the rejected chain. */ + /** A reload the provider refuses shows its reason in the command report, the node log and status, with the failures in a row. */ @Test - public void testExpiredCertificateNotApplied() throws Exception { - KeyStore expiredStore = expiredKeys(); - String expiredSubj = certificateChain(expiredStore)[0].getSubjectX500Principal().getName(); - - String failureReason = "The new certificate chain is not valid now [alias=expired-node, subject=" + expiredSubj + ','; - LogListener expired = LogListener.matches(failureReason).build(); - - nodeLog.registerListener(expired); - + public void testRejectedReloadReported() throws Exception { + long now = System.currentTimeMillis(); + String reason = "The new certificate chain is not valid now [alias=expired-key, subject=CN=expired-key, "; + LogListener logged = LogListener.matches(msg -> msg.startsWith("Failed to reload TLS certificates") && msg.contains(reason)) + .build(); IgniteEx g = startGrid(0); - placeKeys(expiredStore); - - String res = reloadFailure(g); + nodeLog.registerListener(logged); + placeKeys(oldCa.keyStoreWithPrivateKey("expired-key", now - 2 * HOUR, now - HOUR)); - assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, failureReason); - assertTrue(expired.check()); - assertEquals(oldCert, certificateAt(discoveryPort(g))); + reloadFailure(g); + String res = reloadFailure(g); String status = status(g); - assertContains(log, status, "loaded alias=" + OLD_NODE + ", subject=" + oldCert.getSubjectX500Principal().getName() + ", issuer="); - assertContains(log, status, "last reload failed 1 time(s) in a row"); - assertContains(log, status, "The new certificate chain is not valid now"); + assertContains(log, res, node(g) + ": failed on " + ALL_TRANSPORTS + " (" + reason); + assertTrue(logged.check()); + assertTrue(status, Pattern.compile(Pattern.quote(DOUBLE_INDENT + "last reload failed 2 time(s) in a row, the last at ") + + "\\S+: " + Pattern.quote(reason)).matcher(status).find()); } - /** A trust-only store must not replace a working node's keys; new connections still receive the original certificate. */ + /** Both commands report a node that is unavailable when jobs are mapped and still run on the other nodes. */ @Test - public void testKeyStoreWithoutKeyNotApplied() throws Exception { + public void testUnavailableNodeReported() throws Exception { IgniteEx g = startGrid(0); + UUID unavailable = UUID.randomUUID(); + VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); + String report = "Failed on 1 node(s):\n" + unavailable + ": node was unavailable to run the command\n\n" + + "Succeeded on 1 node(s):\n" + node(g) + ": "; - placeKeys(trustBoth); - - String res = reloadFailure(g); + Throwable reloadErr = GridTestUtils.assertThrows(log, () -> g.compute().execute(SslReloadTask.class, arg).result(), + IgniteException.class, null); + Throwable statusErr = GridTestUtils.assertThrows(log, () -> g.compute().execute(SslStatusTask.class, arg).result(), + IgniteException.class, null); - assertFailed(res, g, ALL_TRANSPORTS); - assertContains(log, res, "The new key material has no private key, so the node would present no certificate"); - assertEquals(oldCert, certificateAt(g.context().clientListener().port())); - assertEquals(oldCert, certificateAt(discoveryPort(g))); - assertClientConnected(g, oldKeys); + assertContains(log, reloadErr.getMessage(), report); + assertContains(log, statusErr.getMessage(), report); } - /** A client connector and binary REST with factories of their own reload separately from the node-wide factory. */ + /** The report lists failures first, a failed job named by node UUID and consistent ID, then successes, and fails the command. */ @Test - public void testClientTransportsWithSeparateFactories() throws Exception { - initTrustStore = oldCa.trustStore(); - KeyStore clientKeys = oldCa.keyStoreWithPrivateKey(); - KeyStore restKeys = oldCa.keyStoreWithPrivateKey(); - KeyStore nextRestKeys = newCa.keyStoreWithPrivateKey(); - Path clientPath = dir.resolve("client-key.jks"); - Path restPath = dir.resolve("binary-rest-key.jks"); + public void testReportListsFailuresFirst() { + GridTestNode departed = new GridTestNode(UUID.randomUUID()); + ComputeJobResult succeeded = mock(ComputeJobResult.class); + ComputeJobResult thrown = mock(ComputeJobResult.class); + ComputeJobResult refused = mock(ComputeJobResult.class); - TestCertificateAuthority.save(clientKeys, clientPath); - TestCertificateAuthority.save(restKeys, restPath); + departed.setConsistentId("departed-node"); - IgniteEx g = startGrid(0, cfg -> { - Path trustPath = trustStore(cfg.getIgniteInstanceName()); - - cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) - .setSslContextFactory(storeFactory(clientPath, trustPath)); - cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true) - .setSslFactory(storeFactory(restPath, trustPath))); - }); - - int clientPort = g.context().clientListener().port(); - int restPort = g.localNode().attribute(ATTR_REST_TCP_PORT); - int commPort = ((TcpCommunicationSpi)g.configuration().getCommunicationSpi()).boundPort(); - - assertEquals(oldCert, certificateAt(discoveryPort(g))); - assertEquals(oldCert, certificateAt(commPort)); - assertEquals(certificateChain(clientKeys)[0], certificateAt(clientPort)); - assertEquals(certificateChain(restKeys)[0], certificateAt(restPort)); + when(succeeded.getNode()).thenReturn(new GridTestNode(UUID.randomUUID())); + when(succeeded.getData()).thenReturn(new IgniteBiTuple<>(true, "node-a: reloaded discovery")); + when(thrown.getNode()).thenReturn(departed); + when(thrown.getException()).thenReturn(new IgniteException("node left")); + when(refused.getNode()).thenReturn(new GridTestNode(UUID.randomUUID())); + when(refused.getData()).thenReturn(new IgniteBiTuple<>(false, "node-c: failed on discovery (expired)")); - TestCertificateAuthority.save(nextRestKeys, restPath); + Throwable err = GridTestUtils.assertThrows(log, () -> new SslReloadTask().reduce(List.of(succeeded, thrown, refused)).result(), + IgniteException.class, null); - String res = reload(g); - - assertReloaded(res, g, CLIENT_CONNECTOR); - assertReloaded(res, g, BINARY_REST); - assertReloaded(res, g, COMMUNICATION + ", " + DISCOVERY); - - assertEquals(oldCert, certificateAt(discoveryPort(g))); - assertEquals(oldCert, certificateAt(commPort)); - assertEquals(certificateChain(clientKeys)[0], certificateAt(clientPort)); - assertEquals(certificateChain(nextRestKeys)[0], certificateAt(restPort)); + assertEquals("Failed on 2 node(s):\n" + + departed.id() + " [consistentId=departed-node]: node left\n" + + "node-c: failed on discovery (expired)\n" + + "\n" + + "Succeeded on 1 node(s):\n" + + "node-a: reloaded discovery\n", err.getMessage()); } - /** Manual reload reports every private key. Client operations and new TLS connections still work. */ + /** + * Reload and status name every private key of the store; for a chain whose authority expires before its leaf, status shows the + * expiry of the authority. + */ @Test - public void testKeyStoreWithMultipleKeysReloaded() throws Exception { - ownClientConnectorFactory = true; - + public void testReportsNameEveryKey() throws Exception { + // X.509 keeps whole seconds. + long now = System.currentTimeMillis() / 1000 * 1000; + long caExpiry = now + HOUR; + TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - HOUR, caExpiry); IgniteEx g = startGrid(0); - SSLContext clientCtx = TestCertificateAuthority.context(oldKeys, trustBoth); - KeyStore multipleKeys = KeyStore.getInstance("JKS"); - multipleKeys.load(null, TestCertificateAuthority.PWD); - multipleKeys.setKeyEntry(OLD_NODE, oldKeys.getKey(OLD_NODE, TestCertificateAuthority.PWD), - TestCertificateAuthority.PWD, oldKeys.getCertificateChain(OLD_NODE)); - multipleKeys.setKeyEntry(NEW_NODE, newKeys.getKey(NEW_NODE, TestCertificateAuthority.PWD), - TestCertificateAuthority.PWD, newKeys.getCertificateChain(NEW_NODE)); + placeKeys(merged(oldKeys, shortCa.keyStoreWithPrivateKey(NEW_KEY, now - HOUR, now + 2 * HOUR))); - try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() - .setAddresses("127.0.0.1:" + g.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> clientCtx) - .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { - placeKeys(multipleKeys); + String reloaded = reload(g); + String status = status(g); - String res = reload(g); + assertContains(log, reloaded, "alias=" + OLD_KEY + ", subject=CN=" + OLD_KEY + ", "); + assertContains(log, reloaded, "alias=" + NEW_KEY + ", subject=CN=" + NEW_KEY + ", "); + assertContains(log, status, "loaded alias=" + OLD_KEY + ", subject=CN=" + OLD_KEY + ", "); + assertContains(log, status, "loaded alias=" + NEW_KEY + ", subject=CN=" + NEW_KEY + ", "); + assertContains(log, status, ", chainNotAfter=" + Instant.ofEpochMilli(caExpiry)); + } - assertContains(log, res, node(g) + ": reloaded " + CLIENT_CONNECTOR); - assertContains(log, res, node(g) + ": reloaded " + COMMUNICATION + ", " + DISCOVERY); + /** A node that starts on an expired certificate warns about it for each provider, and status fails with a problem for each. */ + @Test + public void testNodeOnInvalidCertificateReported() throws Exception { + long now = System.currentTimeMillis(); + String name = getTestIgniteInstanceName(0); + LogListener warned = LogListener.matches("The TLS certificate chain in use is not valid now; put a valid certificate in place " + + "and run control.sh --ssl reload [alias=expired-key, subject=CN=expired-key, ").times(2).build(); - cli.cacheNames(); - } + save(oldCa.keyStoreWithPrivateKey("expired-key", now - 2 * HOUR, now - HOUR), keyFile(name)); + nodeLog.registerListener(warned); - X509Certificate presented = certificateAt(g.context().clientListener().port()); + IgniteEx g = startGrid(0, cfg -> { + cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) + .setSslContextFactory(storeFactory(keyFile(name), trustFile(name))); + }); - assertTrue("The connection must use one of the stored keys", - List.of(oldCert, newCert).contains(presented)); + String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); + String problem = "\n" + DOUBLE_INDENT + "loaded alias=expired-key, subject=CN=expired-key, [^\\n]+\n" + DOUBLE_INDENT + + "PROBLEM: the loaded certificate is not valid now \\[alias=expired-key]"; - String status = status(g); + assertTrue(warned.check()); + assertTrue(res, Pattern.compile(Pattern.quote(node(g) + ": " + CLIENT_CONNECTOR) + problem).matcher(res).find()); + assertTrue(res, Pattern.compile(Pattern.quote(node(g) + ": " + COMMUNICATION + ", " + DISCOVERY) + problem).matcher(res).find()); + } - assertContains(log, status, "alias=" + OLD_NODE); - assertContains(log, status, "alias=" + NEW_NODE); - assertContains(log, status, "last reload succeeded at"); + /** Without SSL both commands report that no SSL context factory is configured. */ + @Test + public void testWithoutSsl() throws Exception { + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(null).setClientConnectorConfiguration(new ClientConnectorConfiguration()); + }); + + assertEquals(node(g) + ": no SSL context factory is configured\n", reload(g)); + assertEquals(node(g) + ": no SSL context factory is configured\n", status(g)); } /** - * The authority is replaced in steps: trust the new one, present its certificates, drop the old one. Every TCP transport - * accepts certificates from the new authority and rejects old certificates and clients without certificates. The nodes - * reconnect and a node of the new authority joins. + * The authority is replaced with three reloads: trust both authorities, present a key of the new one, trust only the new one. + * Then the discovery, communication, client connector and binary REST ports accept a client of the new authority and refuse + * one of the old authority and one without a certificate, a reopened communication connection works, and a node of the new + * authority joins. */ @Test public void testCertificateAuthorityReplaced() throws Exception { - initTrustStore = oldCa.trustStore(); + save(oldCa.trustStore(), trustFile(getTestIgniteInstanceName(0))); + save(oldCa.trustStore(), trustFile(getTestIgniteInstanceName(1))); IgniteEx g0 = startGrid(0, cfg -> { cfg.getClientConnectorConfiguration().setSslClientAuth(true); cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); }); - IgniteEx g1 = startGrid(1, cfg -> { - cfg.getClientConnectorConfiguration().setSslClientAuth(true); - cfg.setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); - }); - - assertTransportClientAuthentication(g0, oldKeys, newKeys); + IgniteEx g1 = startGrid(1); placeTrust(trustBoth); - reload(g0, g1); - - assertClientConnected(g0, oldKeys); - assertClientConnected(g0, newKeys); - + reload(g0); placeKeys(newKeys); - reload(g0, g1); - + reload(g0); placeTrust(newCa.trustStore()); - reload(g0, g1); - - String reloadedStatus = status(g0, g1); - String issuer = newCert.getIssuerX500Principal().getName(); + reload(g0); - assertTrue(reloadedStatus, Pattern.compile("issuer=[^;\\n]*" + Pattern.quote(issuer)).matcher(reloadedStatus).find()); - - for (IgniteEx node : List.of(g0, g1)) { - assertClientConnected(node, newKeys); - assertTransportClientAuthentication(node, newKeys, oldKeys); - } + assertClientAuthentication(discoveryPort(g0), newKeys, oldKeys, trustBoth); + assertClientAuthentication(communicationPort(g0), newKeys, oldKeys, trustBoth); + assertClientAuthentication(g0.context().clientListener().port(), newKeys, oldKeys, trustBoth); + assertClientAuthentication(g0.localNode().attribute(ATTR_REST_TCP_PORT), newKeys, oldKeys, trustBoth); ConnectionClientPool pool = U.field(g0.configuration().getCommunicationSpi(), "clientPool"); @@ -605,137 +473,41 @@ public void testCertificateAuthorityReplaced() throws Exception { assertEquals((Integer)1, g0.compute(g0.cluster().forNodeId(g1.localNode().id())).call(() -> 1)); - TestCertificateAuthority.save(newCa.keyStoreWithPrivateKey(), keyStore(getTestIgniteInstanceName(2))); - TestCertificateAuthority.save(newCa.trustStore(), trustStore(getTestIgniteInstanceName(2))); - - IgniteEx g2 = startGrid(2); - - assertEquals(3, g2.cluster().nodes().size()); - - g2.getOrCreateCache(DEFAULT_CACHE_NAME).put(1, 1); - - assertEquals((Integer)1, g0.cache(DEFAULT_CACHE_NAME).get(1)); - } - - /** Status names the certificate of every transport, a client connector on its own factory included, and fails on an expired one. */ - @Test - public void testStatus() throws Exception { - ownClientConnectorFactory = true; - - KeyStore expiredStore = expiredKeys(); - X509Certificate expired = certificateChain(expiredStore)[0]; - String expiredSubj = expired.getSubjectX500Principal().getName(); - - TestCertificateAuthority.save(expiredStore, keyStore(getTestIgniteInstanceName(0))); - - IgniteEx g = startGrid(0); - - String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); - String id = node(g); - - assertContains(log, res, id + ": " + CLIENT_CONNECTOR + "\n loaded alias=expired-node, subject=" + expiredSubj + ", issuer="); - assertContains(log, res, id + ": " + COMMUNICATION + ", " + DISCOVERY + - "\n loaded alias=expired-node, subject=" + expiredSubj + ", issuer="); - assertContains(log, res, expired.getIssuerX500Principal().getName()); - assertContains(log, res, "PROBLEM: the loaded certificate is not valid now"); - } - - /** A node that starts on an expired certificate logs a warning. */ - @Test - public void testExpiredCertificateWarnedAtStart() throws Exception { - TestCertificateAuthority.save(expiredKeys(), keyStore(getTestIgniteInstanceName(0))); - - LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now; put a valid certificate").build(); - - nodeLog.registerListener(expired); - - startGrid(0); - - assertTrue(expired.check()); - } - - /** A failed job result still identifies the node by UUID and consistent ID. */ - @Test - public void testFailedJobNamesNode() { - GridTestNode n = new GridTestNode(UUID.randomUUID()); - String consistentId = "departed-node"; - String reason = "node left"; - - n.setConsistentId(consistentId); - - ComputeJobResult res = Mockito.mock(ComputeJobResult.class); - - Mockito.when(res.getNode()).thenReturn(n); - Mockito.when(res.getException()).thenReturn(new IgniteException(reason)); - - Throwable e = GridTestUtils.assertThrows(log, () -> new SslReloadTask().reduce(List.of(res)).result(), IgniteException.class, null); + save(newCa.keyStoreWithPrivateKey("joining-key"), keyFile(getTestIgniteInstanceName(2))); + save(newCa.trustStore(), trustFile(getTestIgniteInstanceName(2))); - assertContains(log, e.getMessage(), "Failed on 1 node(s):\n" + n.id() + " [consistentId=" + consistentId + "]: " + reason); + assertEquals(3, startGrid(2).cluster().nodes().size()); } - /** A target missing at mapping fails both commands without preventing work on the remaining node. */ - @Test - public void testUnavailableNodeDoesNotSkipRemainingNode() throws Exception { - IgniteEx g = startGrid(0); - UUID unavailable = UUID.randomUUID(); - - placeKeys(newKeys); - - String expectedSubj = newCert.getSubjectX500Principal().getName(); - VisorTaskArgument arg = new VisorTaskArgument<>(List.of(g.localNode().id(), unavailable), new NoArg(), false); - - for (Class task : List.of(SslReloadTask.class, SslStatusTask.class)) { - Throwable err = GridTestUtils.assertThrows(log, () -> g.compute().execute(task, arg).result(), IgniteException.class, null); - - assertContains(log, err.getMessage(), "Failed on 1 node(s):\n" + unavailable + - ": node was unavailable to run the command"); - assertContains(log, err.getMessage(), "Succeeded on 1 node(s):\n" + node(g) + ':'); - assertContains(log, err.getMessage(), "subject=" + expectedSubj + ','); - } - - assertEquals(newCert, certificateAt(g.context().clientListener().port())); + /** @param store Key store to write for every running node. */ + private void placeKeys(KeyStore store) throws Exception { + for (Ignite node : G.allGrids()) + save(store, keyFile(node.name())); } - /** Both commands report that no SSL context factory is configured. */ - @Test - public void testWithoutSsl() throws Exception { - IgniteEx g = startGrid(getTestIgniteInstanceName(0), cfg -> cfg.setSslContextFactory(null) - .setClientConnectorConfiguration(new ClientConnectorConfiguration())); - - assertContains(log, reload(g), node(g) + ": no SSL context factory is configured"); - assertContains(log, status(g), node(g) + ": no SSL context factory is configured"); + /** @param store Trust store to write for every running node. */ + private void placeTrust(KeyStore store) throws Exception { + for (Ignite node : G.allGrids()) + save(store, trustFile(node.name())); } - /** Checks client authentication on discovery, communication, the client connector and binary REST. */ - private void assertTransportClientAuthentication(IgniteEx node, KeyStore trustedKeys, KeyStore untrustedKeys) throws Exception { - for (int port : new int[] { - discoveryPort(node), - ((TcpCommunicationSpi)node.configuration().getCommunicationSpi()).boundPort(), - node.context().clientListener().port(), - node.localNode().attribute(ATTR_REST_TCP_PORT) - }) - assertClientAuthentication(port, trustedKeys, untrustedKeys, trustBoth); + /** @return Key store file of the node. */ + private Path keyFile(String igniteInstanceName) { + return dir.resolve(igniteInstanceName + "-key.jks"); } - /** Opens a new authenticated client connection; its own trust store accepts both test authorities. */ - private void assertClientConnected(IgniteEx node, KeyStore keys) throws Exception { - SSLContext ctx = TestCertificateAuthority.context(keys, trustBoth); - - try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> ctx) - .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { - cli.cacheNames(); - } + /** @return Trust store file of the node. */ + private Path trustFile(String igniteInstanceName) { + return dir.resolve(igniteInstanceName + "-trust.jks"); } - /** @return Factory reading the stores of the node. */ - private Factory storeFactory(String igniteInstanceName) { - return storeFactory(keyStore(igniteInstanceName), trustStore(igniteInstanceName)); + /** @return Communication port of the node. */ + private static int communicationPort(IgniteEx node) { + return ((TcpCommunicationSpi)node.configuration().getCommunicationSpi()).boundPort(); } - /** @return Factory reading the specified JKS files. */ - private SslContextFactory storeFactory(Path keys, Path trust) { + /** @return Factory reading the JKS files. */ + private static SslContextFactory storeFactory(Path keys, Path trust) { SslContextFactory factory = new SslContextFactory(); factory.setKeyStoreType("JKS"); @@ -748,74 +520,30 @@ private SslContextFactory storeFactory(Path keys, Path trust) { return factory; } - /** */ - private Path keyStore(String igniteInstanceName) { - return dir.resolve(igniteInstanceName + "-key.jks"); - } + /** Factory that is not an {@link AbstractSslContextFactory}, so it gives no certificate metadata; counts its loads. */ + private static class CountingFactory implements Factory { + /** */ + private static final long serialVersionUID = 0L; - /** */ - private Path trustStore(String igniteInstanceName) { - return dir.resolve(igniteInstanceName + "-trust.jks"); - } + /** Factory that reads the stores. */ + private final SslContextFactory delegate; - /** @param store Key store to write for every running node. */ - private void placeKeys(KeyStore store) throws Exception { - for (Ignite node : G.allGrids()) - TestCertificateAuthority.save(store, keyStore(node.name())); - } - - /** @param store Trust store to write for every running node. */ - private void placeTrust(KeyStore store) throws Exception { - for (Ignite node : G.allGrids()) - TestCertificateAuthority.save(store, trustStore(node.name())); - } - - /** @return Certificate served on a fresh connection, using a probe that trusts both authorities. */ - private X509Certificate certificateAt(int port) throws Exception { - return servedCertificate(TestCertificateAuthority.context(oldKeys, trustBoth), port); - } - - /** @return Store with an expired leaf issued by the replacement authority. */ - private KeyStore expiredKeys() throws Exception { - return newCa.keyStoreWithPrivateKey("expired-node", now - 2 * HOUR, now - HOUR); - } - - /** */ - private void assertReloaded(String res, IgniteEx node, String transports) { - assertContains(log, res, node(node) + ": reloaded " + transports); - } + /** Calls of {@link #create()}. */ + private final AtomicInteger loads = new AtomicInteger(); - /** */ - private void assertFailed(String res, IgniteEx node, String transports) { - assertContains(log, res, node(node) + ": failed on " + transports + " ("); - } - - /** Counts loads and deliberately considers distinct instances equal. */ - private class TestFactory implements Factory { - /** Factory calls, including failed loads. */ - private int calls; - - /** Whether loads fail. */ - private boolean fail; - - /** {@inheritDoc} */ - @Override public SSLContext create() { - calls++; - - if (fail) - throw new IgniteException("Certificate source is unavailable"); - - return storeFactory(getTestIgniteInstanceName(0)).create(); + /** + * @param keys Key store file. + * @param trust Trust store file. + */ + private CountingFactory(Path keys, Path trust) { + delegate = storeFactory(keys, trust); } /** {@inheritDoc} */ - @Override public boolean equals(Object obj) { - return obj instanceof TestFactory; - } + @Override public SSLContext create() { + loads.incrementAndGet(); - /** {@inheritDoc} */ - @Override public int hashCode() { - return 0; + return delegate.create(); } } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java index ff7a2a80720a7..490d6984e6371 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java @@ -18,30 +18,23 @@ package org.apache.ignite.ssl; import java.io.IOException; -import java.io.InputStream; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.security.KeyStore; import java.security.Principal; import java.security.PrivateKey; import java.security.cert.X509Certificate; -import java.util.Arrays; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; -import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509KeyManager; -import org.apache.ignite.cluster.ClusterGroup; +import org.apache.ignite.cluster.ClusterNode; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.management.ssl.AbstractSslTask; @@ -55,12 +48,10 @@ import org.apache.ignite.testframework.TestCertificateAuthority; import org.jetbrains.annotations.Nullable; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; -/** Executes SSL management tasks, probes transport certificates and prepares test stores. */ +/** Runs the {@code --ssl} commands on every node of a cluster, as control.sh does, and probes the certificates TLS servers present. */ public class SslTestUtils { /** Timeout for a probe connection or TLS handshake. */ private static final int SOCKET_TIMEOUT = 30_000; @@ -71,33 +62,33 @@ private SslTestUtils() { } /** - * @param nodes Nodes whose certificates are reloaded. The first node submits the command. - * @return Report of the command. + * @param node Node that receives the command; it runs on every server and client node of the cluster. + * @return Report of {@code --ssl reload}. */ - public static String reload(IgniteEx... nodes) throws Exception { - return execute(SslReloadTask.class, nodes); + public static String reload(IgniteEx node) throws Exception { + return execute(SslReloadTask.class, node); } /** - * @param nodes Nodes whose certificate status is requested. The first node submits the command. - * @return Report of the command. + * @param node Node that receives the command; it runs on every server and client node of the cluster. + * @return Report of {@code --ssl status}. */ - public static String status(IgniteEx... nodes) throws Exception { - return execute(SslStatusTask.class, nodes); + public static String status(IgniteEx node) throws Exception { + return execute(SslStatusTask.class, node); } /** - * @param nodes Nodes whose certificates are reloaded. The first node submits the command. + * @param node Node that receives {@code --ssl reload}, which must fail. * @return Exception stack trace with all causes, including any wrappers added by compute. */ - public static String reloadFailure(IgniteEx... nodes) { - return X.getFullStackTrace(GridTestUtils.assertThrows(null, () -> reload(nodes), Exception.class, null)); + public static String reloadFailure(IgniteEx node) { + return X.getFullStackTrace(GridTestUtils.assertThrows(null, () -> reload(node), Exception.class, null)); } /** - * @param probe Context to connect with; discovery asks the client for a certificate it trusts. - * @param port Port to connect to. - * @return Certificate the node presents on a new TLS connection to that port. + * @param probe Context to connect with; discovery and communication ask the client for a certificate they trust. + * @param port Loopback port to connect to. + * @return Certificate the server presents on a new TLS connection to that port. */ public static X509Certificate servedCertificate(SSLContext probe, int port) throws Exception { try (SSLSocket sock = (SSLSocket)probe.getSocketFactory().createSocket()) { @@ -110,85 +101,44 @@ public static X509Certificate servedCertificate(SSLContext probe, int port) thro } } - /** - * @param port Port to connect to; the probe presents node01 and trusts both test authorities. - * @return Subject of the certificate the node presents on a new TLS connection to that port. - */ - public static String servedSubject(int port) throws Exception { - return servedCertificate(GridTestUtils.sslTrustedFactory("node01", "trustboth").create(), port).getSubjectX500Principal().getName(); - } - /** * Checks that the server accepts a trusted client and rejects both an untrusted certificate and no certificate. * Each probe uses a fresh context to prevent session resumption. TLS 1.2 completes the handshake only after the server * has checked the client certificate. * * @param port Server port. - * @param trustedKey Test key store trusted by the server. - * @param untrustedKey Test key store not trusted by the server. - */ - public static void assertClientAuthentication(int port, String trustedKey, String untrustedKey) throws Exception { - assertClientAuthentication(port, clientContext(trustedKey), clientContext(untrustedKey), clientContext(null)); - } - - /** - * Checks trusted, untrusted and absent client certificates using generated stores. - * - * @param port Server port. * @param trustedKeys Single-key store trusted by the server. * @param untrustedKeys Single-key store not trusted by the server. * @param trustStore Authorities trusted by all three clients. */ public static void assertClientAuthentication(int port, KeyStore trustedKeys, KeyStore untrustedKeys, KeyStore trustStore) throws Exception { - assertClientAuthentication(port, clientContext(trustedKeys, trustStore), clientContext(untrustedKeys, trustStore), - clientContext(null, trustStore)); - } - - /** Checks all three clients using fresh contexts, so a resumed session cannot hide an authentication failure. */ - private static void assertClientAuthentication(int port, SSLContext trusted, SSLContext untrusted, SSLContext withoutKey) - throws Exception { - try (SSLSocket sock = tls12Socket(trusted, port)) { + try (SSLSocket sock = tls12Socket(clientContext(trustedKeys, trustStore), port)) { sock.startHandshake(); } catch (IOException e) { throw new AssertionError("Trusted client must connect [port=" + port + ']', e); } - assertHandshakeRefused(untrusted, port, "untrusted certificate"); - assertHandshakeRefused(withoutKey, port, "no certificate"); + assertHandshakeRefused(clientContext(untrustedKeys, trustStore), port, "untrusted certificate"); + assertHandshakeRefused(clientContext(null, trustStore), port, "no certificate"); } /** - * Creates a client that trusts both test authorities and presents the selected certificate even if the server does not - * advertise its authority. This lets tests distinguish an untrusted certificate from a missing certificate. - * - * @param keyStore Single-key test store, or {@code null} for a client without a certificate. - * @return Fresh client context. - */ - public static SSLContext clientContext(@Nullable String keyStore) throws Exception { - return clientContext(keyStore == null ? null : store(keyStore), keyStorePassword().toCharArray(), store("trustboth")); - } - - /** - * Creates a client from generated stores, forcing the sole private-key alias even if its issuer is not advertised. + * Creates a client that presents the sole private key of the store even if the server does not advertise its authority. + * This lets tests distinguish an untrusted certificate from a missing certificate. * * @param keys Single-key store, or {@code null} to present no certificate. * @param trustStore Authorities trusted by the client. * @return Fresh client context. */ public static SSLContext clientContext(@Nullable KeyStore keys, KeyStore trustStore) throws Exception { - return clientContext(keys, TestCertificateAuthority.PWD, trustStore); - } - - /** Creates the client while retaining the selected private-key alias and the supplied trust roots. */ - private static SSLContext clientContext(@Nullable KeyStore keys, char[] pwd, KeyStore trustStore) throws Exception { KeyManager[] keyManagers = new KeyManager[0]; if (keys != null) { KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keys, pwd); + kmf.init(keys, TestCertificateAuthority.PWD); X509KeyManager km = (X509KeyManager)kmf.getKeyManagers()[0]; @@ -197,26 +147,32 @@ private static SSLContext clientContext(@Nullable KeyStore keys, char[] pwd, Key assertNotNull("No private key for test alias " + alias, km.getPrivateKey(alias)); X509KeyManager always = new X509KeyManager() { + /** {@inheritDoc} */ @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket sock) { return km.chooseClientAlias(keyType, null, sock); } + /** {@inheritDoc} */ @Override public String[] getClientAliases(String keyType, Principal[] issuers) { return km.getClientAliases(keyType, null); } + /** {@inheritDoc} */ @Override public String[] getServerAliases(String keyType, Principal[] issuers) { return km.getServerAliases(keyType, issuers); } + /** {@inheritDoc} */ @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket sock) { return km.chooseServerAlias(keyType, issuers, sock); } + /** {@inheritDoc} */ @Override public X509Certificate[] getCertificateChain(String alias) { return km.getCertificateChain(alias); } + /** {@inheritDoc} */ @Override public PrivateKey getPrivateKey(String alias) { return km.getPrivateKey(alias); } @@ -225,26 +181,32 @@ private static SSLContext clientContext(@Nullable KeyStore keys, char[] pwd, Key keyManagers = new KeyManager[] {always}; } - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - tmf.init(trustStore); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(keyManagers, tmf.getTrustManagers(), null); + ctx.init(keyManagers, TestCertificateAuthority.trustManagers(trustStore), null); return ctx; } - /** @param name Test store. */ - private static KeyStore store(String name) throws Exception { - KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + /** @return Node UUID and consistent ID in command output format. */ + public static String node(IgniteEx node) { + return node.localNode().id() + " [consistentId=" + node.localNode().consistentId() + ']'; + } - try (InputStream in = Files.newInputStream(Path.of(keyStorePath(name)))) { - store.load(in, keyStorePassword().toCharArray()); - } + /** @return Discovery port of the node. */ + public static int discoveryPort(IgniteEx node) { + return ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); + } + + /** + * @param task Task of the command. + * @param node Node that receives the command. + * @return Report of the command. + */ + private static String execute(Class task, IgniteEx node) throws Exception { + List ids = node.cluster().nodes().stream().map(ClusterNode::id).toList(); - return store; + return node.compute(node.cluster()).execute(task, new VisorTaskArgument<>(ids, new NoArg(), false)).result(); } /** @@ -289,35 +251,4 @@ private static SSLSocket tls12Socket(SSLContext ctx, int port) throws IOExceptio throw e; } } - - /** @return Node UUID and consistent ID in command output format. */ - public static String node(IgniteEx node) { - return node.localNode().id() + " [consistentId=" + node.localNode().consistentId() + ']'; - } - - /** @return Discovery port of the node. */ - public static int discoveryPort(IgniteEx node) { - return ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); - } - - /** - * @param name Test store, as {@code tests.properties} names it. - * @param dest File to replace. - */ - public static void place(String name, Path dest) throws IOException { - Files.copy(Path.of(GridTestUtils.keyStorePath(name)), dest, StandardCopyOption.REPLACE_EXISTING); - } - - /** - * @param task Task of the command. - * @param nodes Nodes to run on. - * @return Report of the command. - */ - private static String execute(Class task, IgniteEx... nodes) throws Exception { - List ids = Arrays.stream(nodes).map(n -> n.localNode().id()).collect(Collectors.toList()); - - ClusterGroup serversAndClients = nodes[0].cluster(); - - return nodes[0].compute(serversAndClients).execute(task, new VisorTaskArgument<>(ids, new NoArg(), false)).result(); - } } diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index efafae52791ae..e39bed4fd93bc 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -17,7 +17,10 @@ package org.apache.ignite.testframework; +import java.io.OutputStream; import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; @@ -30,9 +33,14 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.security.auth.x500.X500Principal; +import org.apache.ignite.ssl.KeyMaterial; +import org.apache.ignite.ssl.SslBundle; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; import org.bouncycastle.asn1.x509.Extension; @@ -45,7 +53,7 @@ /** * Issues JKS stores protected by {@link #PWD}, each with an EC P-256 private key and its certificate chain. * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, - * so the same material works for node-to-node TLS. + * so the same material works for node-to-node TLS. Tests choose the issuer and validity dates when those are part of the scenario. */ public class TestCertificateAuthority { /** Password of every store made here. */ @@ -72,12 +80,19 @@ public class TestCertificateAuthority { * @param cn Common name of the authority. */ public TestCertificateAuthority(String cn) throws Exception { - long now = System.currentTimeMillis(); + this(cn, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365)); + } + /** + * @param cn Common name of the authority. + * @param notBefore Time the authority's certificate becomes valid; rounded down to a second. + * @param notAfter Time the authority's certificate expires; rounded down to a second. + */ + public TestCertificateAuthority(String cn, long notBefore, long notAfter) throws Exception { name = new X500Principal("CN=" + cn); keys = keyPair(); - cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); + cert = sign(name, keys, notBefore, notAfter, true); } /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ @@ -93,10 +108,29 @@ public KeyStore keyStoreWithPrivateKey() throws Exception { */ public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { long now = System.currentTimeMillis(); + + return keyStoreWithPrivateKey(cn, now - TimeUnit.HOURS.toMillis(1), now + TimeUnit.DAYS.toMillis(1)); + } + + /** + * @param notBefore Time the certificate becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Store with one private key and a certificate with the requested validity dates whose name is irrelevant to the test. + */ + public KeyStore keyStoreWithPrivateKey(long notBefore, long notAfter) throws Exception { + return keyStoreWithPrivateKey(DEFAULT_NAME, notBefore, notAfter); + } + + /** + * @param cn Common name of the certificate, also used as the key alias. + * @param notBefore Time it becomes valid; rounded down to a second. + * @param notAfter Time it expires; rounded down to a second. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + public KeyStore keyStoreWithPrivateKey(String cn, long notBefore, long notAfter) throws Exception { KeyPair pair = keyPair(); - X509Certificate leaf = sign(new X500Principal("CN=" + cn), pair, now - TimeUnit.HOURS.toMillis(1), - now + TimeUnit.DAYS.toMillis(1), false); + X509Certificate leaf = sign(new X500Principal("CN=" + cn), pair, notBefore, notAfter, false); KeyStore store = emptyStore(); @@ -107,13 +141,39 @@ public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { /** @return Trust store with this authority alone. */ public KeyStore trustStore() throws Exception { + return trustStore(this); + } + + /** + * @param authorities Authorities to trust. + * @return Trust store with them. + */ + public static KeyStore trustStore(TestCertificateAuthority... authorities) throws Exception { KeyStore store = emptyStore(); - store.setCertificateEntry(name.getName(), cert); + for (TestCertificateAuthority a : authorities) + store.setCertificateEntry(a.name.getName(), a.cert); return store; } + /** + * @param stores Stores issued here, with distinct aliases. + * @return Store with the private keys and chains of all of them. + */ + public static KeyStore merged(KeyStore... stores) throws Exception { + KeyStore res = emptyStore(); + + for (KeyStore store : stores) { + for (String alias : Collections.list(store.aliases())) { + if (store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + res.setKeyEntry(alias, store.getKey(alias, PWD), PWD, store.getCertificateChain(alias)); + } + } + + return res; + } + /** * @param keyStore Store with at most one private key. * @return Its certificate chain, or {@code null} if the store has no private key. @@ -144,6 +204,33 @@ public static List chain(KeyStore keyStore) throws Exception { return List.of(certificateChain(keyStore)); } + /** + * @param keyStore Store with the certificate to present. + * @param trustStore Store with the authorities to trust. + * @return Context using the supplied key and trust stores. + */ + public static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws Exception { + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(keyManagers(keyStore), trustManagers(trustStore), null); + + return ctx; + } + + /** + * @param keyStore Store with the certificates to present. + * @param trustStore Store with the authorities to trust. + * @return Context and certificate chains from the supplied stores. + */ + public static SslBundle bundle(KeyStore keyStore, KeyStore trustStore) throws Exception { + KeyMaterial material = new KeyMaterial(keyStore, PWD, KeyManagerFactory.getDefaultAlgorithm()); + SSLContext ctx = SSLContext.getInstance("TLS"); + + ctx.init(material.keyManagers(), trustManagers(trustStore), null); + + return new SslBundle(ctx, material.certificateChains()); + } + /** * @param trustStore Store with the authorities to trust. * @return Trust managers that trust them. @@ -156,6 +243,28 @@ public static TrustManager[] trustManagers(KeyStore trustStore) throws Exception return tmf.getTrustManagers(); } + /** + * @param store Store. + * @param path File to write or replace. + */ + public static void save(KeyStore store, Path path) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + store.store(out, PWD); + } + } + + /** + * @param keyStore Store with the certificate to present. + * @return Key managers that present it. + */ + private static KeyManager[] keyManagers(KeyStore keyStore) throws Exception { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + kmf.init(keyStore, PWD); + + return kmf.getKeyManagers(); + } + /** * @param subj Subject. * @param subjKeys Key pair of the subject. From e5f76d5df6abe9b34f8cc63b85673cb17d5beadd Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 01:02:17 +0300 Subject: [PATCH 072/242] IGNITE-29210 Point the reload rejection paragraph to the status table Co-Authored-By: Claude Opus 5.5 --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index ac05dfcdde515..1278671b5d9b3 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -183,7 +183,7 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects a store with no private keys, or with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can start with one; the node log and `--ssl status` then name its alias, and reload rejects the store until the entry is fixed. If there are no private keys, install the node's certificate chain and private key. If the error names an alias, add the X.509 certificate chain of that key or remove the key, then run reload again. +With `SslContextFactory`, reload also rejects a store with no private keys. It also rejects a store with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can still start with such a store. The node then logs a warning, and `--ssl status` shows the problem. To fix the store, see the `no private keys` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates]. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. From 5bfffb531924802f7ffc890e59a83c6f67debbd5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 01:59:48 +0300 Subject: [PATCH 073/242] IGNITE-29211 Make renewal tests state one guarantee each Renewal reads the same clock as the provider: System.currentTimeMillis() instead of the coarse U.currentTimeMillis(). Co-Authored-By: Claude Opus 5.5 --- .../ignite/internal/ssl/SslRenewal.java | 6 +- .../internal/ssl/SslRenewalNodeTest.java | 311 +++ .../ignite/internal/ssl/SslRenewalTest.java | 1885 +++++------------ .../ignite/testsuites/SecurityTestSuite.java | 2 + 4 files changed, 866 insertions(+), 1338 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java index 8df2d422dc300..c5681f309fb4d 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -122,7 +122,7 @@ private void plan() { window = (long)(lifetime * renewBeforeFraction); - long at = Math.max(chainNotAfter - window, Math.max(provider.reloadOutcome().successTime() + minPause, U.currentTimeMillis())); + long at = Math.max(chainNotAfter - window, Math.max(provider.reloadOutcome().successTime() + minPause, System.currentTimeMillis())); schedule(at, planned.context()); @@ -163,7 +163,7 @@ private void retry(SSLContext planned, @Nullable Throwable e) { if (exec.isShutdown()) return; - long now = U.currentTimeMillis(); + long now = System.currentTimeMillis(); pause = pause == 0 ? minPause : Math.min(pause * 2, maxPause); @@ -205,7 +205,7 @@ private void schedule(long at, SSLContext planned) { nextRenewalTime = at; - next = submit(() -> attempt(planned), Math.max(0, at - U.currentTimeMillis())); + next = submit(() -> attempt(planned), Math.max(0, at - System.currentTimeMillis())); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java new file mode 100644 index 0000000000000..58afc85d41a28 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -0,0 +1,311 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.ssl; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteInterruptedException; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.metric.IntMetric; +import org.apache.ignite.spi.metric.Metric; +import org.apache.ignite.spi.metric.ReadOnlyMetricRegistry; +import org.apache.ignite.ssl.SslBundle; +import org.apache.ignite.ssl.SslContextFactory; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.MemorizingAppender; +import org.apache.ignite.testframework.TestCertificateAuthority; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.apache.logging.log4j.Level; +import org.junit.Test; + +import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; +import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; +import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; +import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; +import static org.apache.ignite.ssl.SslTestUtils.status; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.context; +import static org.apache.ignite.testframework.TestCertificateAuthority.save; + +/** + * Renewal on a node: a factory without certificate metadata stops the node from starting with renewal; the node exposes the sum of + * unsuccessful renewals of its factories as the only SSL metric; stopping the node interrupts an attempt without a failure and + * stops the renewal thread; the file-based factory puts a replaced key store in use with no command. Nodes run + * {@link SslContextFactory} on key store files of the test and trust the test authority. A node starting with renewal starts on + * a certificate with 10 of its 120 minutes left, inside the default window of 18 minutes, so the first attempt comes at once. + */ +public class SslRenewalNodeTest extends GridCommonAbstractTest { + /** */ + private static final long SECOND = 1_000L; + + /** */ + private static final long MINUTE = 60 * SECOND; + + /** */ + private static final long HOUR = 60 * MINUTE; + + /** How renewal logs an attempt that got the chains in use, before the expiry state. */ + private static final String WAITING = "The SSL context factory has no newer TLS certificates yet, the ones in use stay"; + + /** Authority of the test certificates. */ + private TestCertificateAuthority ca; + + /** Directory with the key store files of the test. */ + private Path dir; + + /** What renewal logs, with levels. */ + private final MemorizingAppender appender = new MemorizingAppender(); + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + ca = new TestCertificateAuthority("renewal-ca"); + dir = Files.createTempDirectory("ignite-ssl-renewal-"); + + appender.start(); + appender.installSelfOn(SslRenewal.class); + } + + /** {@inheritDoc} */ + @Override protected void afterTest() { + try { + stopAllGrids(); + } + finally { + appender.removeSelfFrom(SslRenewal.class); + appender.stop(); + + U.delete(dir); + } + } + + /** A node whose factory enables renewal but gives no certificate metadata does not start. */ + @Test + public void testUnknownCertificateRefusedAtStart() throws Exception { + Path keys = dir.resolve("node.jks"); + SslContextFactory factory = readingKeys(new SslContextFactory() { + /** {@inheritDoc} */ + @Override public SslBundle createSslBundle() { + return new SslBundle(super.createSslBundle().context(), null); + } + }, keys); + + save(ca.keyStoreWithPrivateKey("node"), keys); + factory.setRenewalEnabled(true); + + GridTestUtils.assertThrowsAnyCause(log, () -> startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + }), IgniteException.class, "The SSL context factory must provide a nonempty certificate chain for every private key"); + } + + /** + * The node exposes the unsuccessful renewals of all its factories as {@code ssl.ConsecutiveRenewalFailures}, the only metric + * of the {@code ssl} registry; a renewal clears the count of its factory only. Renewal is off, so that renewal attempts of the + * node do not change the counts while the test renews by hand; the node then plans no renewal and runs no renewal thread. + */ + @Test + public void testRenewalFailuresMetric() throws Exception { + long now = System.currentTimeMillis(); + Path nodeKeys = dir.resolve("node.jks"); + Path clientKeys = dir.resolve("client-connector.jks"); + KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); + SslContextFactory nodeFactory = readingKeys(new SslContextFactory(), nodeKeys); + SslContextFactory clientFactory = readingKeys(new SslContextFactory(), clientKeys); + + save(inUse, nodeKeys); + save(inUse, clientKeys); + + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(nodeFactory).setClientConnectorConfiguration(new ClientConnectorConfiguration() + .setSslEnabled(true).setSslClientAuth(false).setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory)); + }); + SslContextProvider node = provider(g, COMMUNICATION); + SslContextProvider client = provider(g, CLIENT_CONNECTOR); + ReadOnlyMetricRegistry ssl = g.context().metric().registry("ssl"); + Iterator metrics = ssl.iterator(); + IntMetric failures = ssl.findMetric("ConsecutiveRenewalFailures"); + + assertEquals("ssl.ConsecutiveRenewalFailures", metrics.next().name()); + assertFalse(metrics.hasNext()); + + node.renew(node.context()); + client.renew(client.context()); + client.renew(client.context()); + + assertEquals("Both factories", 3, failures.value()); + + save(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), nodeKeys); + + node.renew(node.context()); + + assertEquals("Node factory renewed", 2, failures.value()); + assertEquals(0, node.nextRenewalTime()); + assertFalse(renewalThreadAlive(g.name())); + } + + /** + * Stopping a node interrupts an attempt in progress without counting or logging a failure, and the renewal thread stops with + * the node. + */ + @Test + public void testStopDuringRenewalLogsNoFailure() throws Exception { + long now = System.currentTimeMillis(); + Path keys = dir.resolve("node.jks"); + CountDownLatch attempting = new CountDownLatch(1); + SslContextFactory factory = readingKeys(new SslContextFactory() { + /** {@inheritDoc} */ + @Override public SslBundle createSslBundle() { + if (Thread.currentThread().getName().startsWith("ssl-renewal")) { + attempting.countDown(); + + try { + Thread.sleep(Long.MAX_VALUE); + } + catch (InterruptedException e) { + throw new IgniteInterruptedException(e); + } + } + + return super.createSslBundle(); + } + }, keys); + + save(ca.keyStoreWithPrivateKey("in-window", now - 110 * MINUTE, now + 10 * MINUTE), keys); + factory.setRenewalEnabled(true); + + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + }); + String name = g.name(); + SslContextProvider p = provider(g, COMMUNICATION); + + assertTrue(attempting.await(10, TimeUnit.SECONDS)); + assertTrue(renewalThreadAlive(name)); + + stopGrid(0); + + assertEquals(0, p.renewalFailures()); + assertEquals(new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); + assertEquals(List.of(), logged(Level.WARN)); + assertEquals(List.of(), logged(Level.ERROR)); + assertTrue(waitForCondition(() -> !renewalThreadAlive(name), 10_000)); + } + + /** + * The file-based factory with renewal enabled waits while its key store file holds the certificate in use and puts the file + * replaced on disk in use with no command: the node logs the renewal, new discovery connections get the new certificate, and + * status shows the next renewal at the default window of the new certificate, 18 minutes before its expiry. + */ + @Test + public void testReplacedKeyStoreFileRenewed() throws Exception { + // X.509 keeps whole seconds. + long now = System.currentTimeMillis() / SECOND * SECOND; + Path keys = dir.resolve("node.jks"); + KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now, now + 2 * HOUR); + long nextRenewal = now + 2 * HOUR - 18 * MINUTE; + SslContextFactory factory = readingKeys(new SslContextFactory(), keys); + long minPause = GridTestUtils.getFieldValue(SslRenewal.class, "minPause"); + long maxPause = GridTestUtils.getFieldValue(SslRenewal.class, "maxPause"); + + save(ca.keyStoreWithPrivateKey("in-window", now - 110 * MINUTE, now + 10 * MINUTE), keys); + factory.setRenewalEnabled(true); + + // Retries at least a minute apart would make the test wait that long for the replaced file. + GridTestUtils.setFieldValue(SslRenewal.class, "minPause", 100L); + GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", 1_000L); + + try { + IgniteEx g = startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + }); + SslContextProvider p = provider(g, COMMUNICATION); + + assertTrue(waitForCondition(() -> logged(Level.WARN).stream().anyMatch(msg -> msg.startsWith(WAITING)), 10_000)); + + save(renewed, keys); + + assertTrue(waitForCondition(() -> logged(Level.INFO).stream().anyMatch(msg -> msg.startsWith("TLS certificates reloaded " + + "[transports=" + COMMUNICATION + ", " + DISCOVERY + ", alias=renewed, subject=CN=renewed, ") && + msg.endsWith(", initiator=automatic renewal]")), 10_000)); + + assertEquals(chain(renewed).get(0), servedCertificate(context(ca.keyStoreWithPrivateKey("probe"), ca.trustStore()), + discoveryPort(g))); + + // The renewal thread plans the next attempt after it logs the renewal. + assertTrue(waitForCondition(() -> p.nextRenewalTime() == nextRenewal, 10_000)); + assertContains(log, status(g), "next automatic renewal at " + Instant.ofEpochMilli(nextRenewal)); + } + finally { + GridTestUtils.setFieldValue(SslRenewal.class, "minPause", minPause); + GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", maxPause); + } + } + + /** + * @param factory Factory to set up. + * @param keys Key store file. + * @return The factory, reading the key store file and trusting the test authority. + */ + private F readingKeys(F factory, Path keys) throws Exception { + factory.setKeyStoreType("JKS"); + factory.setKeyStoreFilePath(keys.toString()); + factory.setKeyStorePassword(TestCertificateAuthority.PWD); + factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); + + return factory; + } + + /** + * @param level Level. + * @return Messages renewal logged at the level, in log order. + */ + private List logged(Level level) { + return appender.events().stream().filter(e -> e.getLevel() == level).map(e -> e.getMessage().getFormattedMessage()).toList(); + } + + /** + * @param g Node. + * @param transport Transport. + * @return Provider of the transport. + */ + private static SslContextProvider provider(IgniteEx g, String transport) { + return g.context().sslContexts().providers().stream().filter(p -> p.transports().contains(transport)).findFirst() + .orElseThrow(); + } + + /** + * @param name Node name. + * @return Whether the renewal thread of the node is alive. + */ + private static boolean renewalThreadAlive(String name) { + return Thread.getAllStackTraces().keySet().stream() + .anyMatch(t -> t.isAlive() && t.getName().startsWith("ssl-renewal") && t.getName().contains(name)); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 10b908069bf30..6d5c2f3f14f60 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -17,46 +17,30 @@ package org.apache.ignite.internal.ssl; -import java.io.Serial; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.KeyStore; -import java.security.cert.X509Certificate; -import java.time.Instant; -import java.util.Iterator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.RunnableScheduledFuture; +import java.util.concurrent.Delayed; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; -import org.apache.ignite.configuration.ClientConnectorConfiguration; -import org.apache.ignite.internal.IgniteEx; -import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; import org.apache.ignite.internal.thread.context.Scope; -import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.spi.metric.IntMetric; -import org.apache.ignite.spi.metric.Metric; -import org.apache.ignite.spi.metric.ReadOnlyMetricRegistry; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.KeyMaterial; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; @@ -65,22 +49,36 @@ import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; -import org.jetbrains.annotations.Nullable; +import org.junit.Assert; import org.junit.Test; -import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; -import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; -import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; -import static org.apache.ignite.ssl.AbstractSslContextFactory.DFLT_RENEW_BEFORE_FRACTION; -import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; -import static org.apache.ignite.ssl.SslTestUtils.reload; -import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; -import static org.apache.ignite.ssl.SslTestUtils.status; +import static org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertThrowsWithCause; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; - -/** Tests that a node renews the certificates of a factory with renewal enabled before they expire. */ +import static org.apache.ignite.testframework.TestCertificateAuthority.bundle; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.context; +import static org.apache.ignite.testframework.TestCertificateAuthority.merged; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Renewal without nodes: its settings, {@link SslContextProvider#renew(SSLContext)}, the count of unsuccessful renewals and the + * scheduler. Renewal is off by default. renew installs all new chains, an expired certificate included, only if the earliest + * expiry advances and the checks of reload pass; it refuses a cached context with new metadata, waits on the chains in use, + * ignores a replaced context and changes nothing when interrupted. Reload keeps the metadata renewal needs, renewal needs chains + * to start, and unsuccessful renewals count until the earliest expiry advances, by renewal or reload. The scheduler tries first + * at the window of the earliest chain, then no sooner than a minute after a success, backs off on failures and logs each one, + * replans on reload, drops an outdated attempt and runs without the operator context. The log names the installed certificate, + * sets the level of a failure by the time left and reports a wait once per lateness, again on expiry. Tests enable renewal on + * a factory mock and script its bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in + * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. Only two + * tests wait: the operator context test runs on the executor of a node and waits for its attempt, and the last test waits for + * a certificate to expire. An appender on the logger of {@link SslRenewal} captures what renewal logs. + */ public class SslRenewalTest extends GridCommonAbstractTest { /** */ private static final long SECOND = 1_000L; @@ -91,1184 +89,554 @@ public class SslRenewalTest extends GridCommonAbstractTest { /** */ private static final long HOUR = 60 * MINUTE; - /** Tolerance in milliseconds for differences in clock precision when checking scheduled times. */ - private static final long CLOCK_SLACK = 50; + /** Renewal window of a quarter of the lifetime: the last 30 minutes of a certificate that lives 2 hours. */ + private static final double QUARTER = 0.25; - /** Failure of an issuer that cannot be reached. */ + /** Failure of a factory whose issuer cannot be reached. */ private static final String ISSUER_DOWN = "Issuer is unavailable"; - /** What the node logs while the factory returns the certificates in use. */ - private static final String WAITING = "The SSL context factory has no newer TLS certificates yet, the ones in use stay"; + /** How renewal logs a failed attempt, before the expiry state. */ + private static final String FAILED = "Failed to reload TLS certificates, the ones in use stay"; - /** Transports that share the node-wide SSL context, as the log lists them. */ - private static final String NODE_TRANSPORTS = "transports=communication, discovery"; + /** How renewal logs an attempt that got the chains in use, before the expiry state. */ + private static final String WAITING = "The SSL context factory has no newer TLS certificates yet, the ones in use stay"; - /** Operation context attribute standing for whoever runs a reload. */ + /** Operation context attribute standing for an operator. */ private static final OperationContextAttribute OPERATOR = OperationContextAttribute.newInstance(); - /** Default certificate authority for the tests. */ + /** Authority of the test certificates. */ private TestCertificateAuthority ca; - /** What the renewals log, with the levels. */ + /** Logger renewal writes to; {@link #appender} captures it. */ + private IgniteLogger renewalLog; + + /** What renewal logs, with levels. */ private final MemorizingAppender appender = new MemorizingAppender(); /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { - ca = new TestCertificateAuthority("renewalca"); + ca = new TestCertificateAuthority("renewal-ca"); + renewalLog = log.getLogger(SslRenewal.class); appender.start(); appender.installSelfOn(SslRenewal.class); - - pauses(100, 1_000); } /** {@inheritDoc} */ @Override protected void afterTest() { - try { - stopAllGrids(); - } - finally { - appender.removeSelfFrom(SslRenewal.class); - appender.stop(); - - pauses(MINUTE, HOUR); - } - } - - /** Renewal starts within its window, serves the new certificate, schedules its next renewal and stops with the node. */ - @Test - public void testRenewsBeforeExpiry() throws Exception { - long now = System.currentTimeMillis(); - long renewalDelay = 5 * SECOND; - long lifetime = 5 * MINUTE; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - AtomicLong due = new AtomicLong(); - - Issuer issuer = new Issuer(() -> { - long issued = System.currentTimeMillis(); - - // Match certificate timestamp precision while keeping the renewal window ahead of this first load. - long notAfter = (issued + renewalDelay + window) / SECOND * SECOND; - long notBefore = notAfter - lifetime; - - due.set(notAfter - window); - - return ca.keyStoreWithPrivateKey(notBefore, notAfter); - }, fresh(HOUR)); - - IgniteEx g = startNode(issuer); - - assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); - - assertTrue("The certificate must have been renewed", chainNotAfter(g) > now + 30 * MINUTE); - assertTrue("The renewal must not come before the window opens", - issuer.calls.get(1).time >= due.get() - CLOCK_SLACK); - assertEquals("New connections must get the renewed certificate", chainNotAfter(g), - servedCertificate(probe(), discoveryPort(g)).getNotAfter().getTime()); - - long next = dueTime(g); - - assertTrue(waitForCondition(() -> nextRenewalTime(g) == next, 10_000)); - - assertTrue("A node renewing certificates must run the renewal thread", renewalThreadAlive(g.name())); - - stopGrid(0); - - assertTrue("The renewal thread must stop with the node", waitForCondition(() -> !renewalThreadAlive(g.name()), 10_000)); + appender.removeSelfFrom(SslRenewal.class); + appender.stop(); } - /** Plans renewal from the authority's expiry when it expires before the leaf certificate. */ + /** Renewal is off by default, and its window must be a share of the lifetime greater than 0 and less than 1. */ @Test - public void testRenewalPlannedBeforeAuthorityExpiry() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; - long notBefore = now - HOUR; - long authorityExpiry = now + HOUR; - - ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); - - KeyStore keys = ca.keyStoreWithPrivateKey(notBefore, authorityExpiry + HOUR); - - long due = authorityExpiry - (long)((authorityExpiry - notBefore) * DFLT_RENEW_BEFORE_FRACTION); - - IgniteEx g = startNode(new Issuer(() -> keys)); - - assertTrue("Renewal must be planned by the authority's expiry", - waitForCondition(() -> nextRenewalTime(g) == due, 10_000)); - } - - /** Every private key affects the renewal date; equal expiries use the earliest leaf validity start. */ - @Test - public void testRenewalPlannedByEarliestKey() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; - long firstExpiry = now + 2 * HOUR; - long secondNotBefore = now - 2 * HOUR; - String firstAlias = "first"; - String secondAlias = "second"; - - for (long secondExpiry : List.of(now + HOUR, firstExpiry)) { - KeyStore keys = ca.keyStoreWithPrivateKey(firstAlias, now - HOUR, firstExpiry); - - addKey(keys, ca.keyStoreWithPrivateKey(secondAlias, secondNotBefore, secondExpiry), secondAlias); - - Issuer issuer = new Issuer(() -> keys); - SslContextProvider p = new SslContextProvider(issuer); - ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1); - - try { - p.startRenewal(exec, log); - - exec.submit(() -> {}).get(10, TimeUnit.SECONDS); - - long expected = secondExpiry - (long)((secondExpiry - secondNotBefore) * DFLT_RENEW_BEFORE_FRACTION); - - assertEquals(expected, p.nextRenewalTime()); - assertEquals(2, p.certificateChains().size()); - assertEquals(1, issuer.calls.size()); - } - finally { - exec.shutdownNow(); + public void testInvalidSettingsRefused() { + SslContextFactory factory = new SslContextFactory(); - assertTrue(exec.awaitTermination(10, TimeUnit.SECONDS)); - } - } + assertFalse(factory.isRenewalEnabled()); + assertThrowsWithCause(() -> factory.setRenewBeforeFraction(0), IllegalArgumentException.class); + assertThrowsWithCause(() -> factory.setRenewBeforeFraction(1), IllegalArgumentException.class); } - /** Rotation replaces all chains together and does not require old and new private-key aliases to match. */ + /** renew installs all new chains together; their aliases need not match the ones in use. */ @Test - public void testAllKeysRenewedTogether() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; - long originalExpiry = now + HOUR; - long replacementExpiry = now + 2 * HOUR; - String firstAlias = "first"; - String secondAlias = "second"; - String replacementAlias = "replacement"; - String otherReplacementAlias = "other-replacement"; - - KeyStore original = ca.keyStoreWithPrivateKey(firstAlias, now - HOUR, originalExpiry); - KeyStore replacement = ca.keyStoreWithPrivateKey(replacementAlias, now - HOUR, replacementExpiry); - - addKey(original, ca.keyStoreWithPrivateKey(secondAlias, now - HOUR, originalExpiry), secondAlias); - addKey(replacement, ca.keyStoreWithPrivateKey(otherReplacementAlias, now - HOUR, replacementExpiry), otherReplacementAlias); - - SslContextProvider p = new SslContextProvider(new Issuer(() -> original, () -> original, () -> replacement)); - SslBundle before = p.bundle(); - - assertNull(p.renew(before.context())); - assertEquals(1, p.renewalFailures()); - - SslBundle renewed = p.renew(before.context()); - - assertSame(renewed, p.bundle()); - assertNotSame(before.context(), renewed.context()); - assertEquals(2, renewed.certificateChains().size()); - assertEquals(replacement.getCertificate(replacementAlias), renewed.certificateChains().get(replacementAlias).get(0)); - assertEquals(replacement.getCertificate(otherReplacementAlias), renewed.certificateChains().get(otherReplacementAlias).get(0)); - assertEquals(replacementExpiry, SslCertificates.chainNotAfter(SslContextProvider.renewalChain(renewed))); - assertEquals(0, p.renewalFailures()); - } + public void testRenewInstallsAllNewChains() throws Exception { + long now = System.currentTimeMillis(); + KeyStore old1 = ca.keyStoreWithPrivateKey("old-1", now - HOUR, now + HOUR); + KeyStore old2 = ca.keyStoreWithPrivateKey("old-2", now - HOUR, now + HOUR); + KeyStore new1 = ca.keyStoreWithPrivateKey("new-1", now - HOUR, now + 2 * HOUR); + KeyStore new2 = ca.keyStoreWithPrivateKey("new-2", now - HOUR, now + 2 * HOUR); + SslBundle renewed = bundle(merged(new1, new2), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); - /** A replacement cannot leave another key at the original earliest expiry. */ - @Test - public void testPartialRotationDoesNotAdvanceEarliestExpiry() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; - long originalExpiry = now + HOUR; - String firstAlias = "first"; - String unchangedAlias = "unchanged"; - KeyStore unchanged = ca.keyStoreWithPrivateKey(unchangedAlias, now - HOUR, originalExpiry); - KeyStore original = ca.keyStoreWithPrivateKey(firstAlias, now - HOUR, originalExpiry); - KeyStore replacement = ca.keyStoreWithPrivateKey(firstAlias, now - HOUR, originalExpiry + HOUR); + when(f.createSslBundle()).thenReturn(bundle(merged(old1, old2), ca.trustStore()), renewed); - addKey(original, unchanged, unchangedAlias); - addKey(replacement, unchanged, unchangedAlias); + SslContextProvider p = new SslContextProvider(f); - SslContextProvider p = new SslContextProvider(new Issuer(() -> original, () -> replacement)); + p.renew(p.context()); - assertRenewalRejected(p, "The new certificate chains expire no later than the ones in use"); + assertSame(renewed.context(), p.context()); + assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), p.certificateChains()); } - /** Every candidate chain must be valid even when another key has been renewed successfully. */ + /** renew replaces an expired certificate in use with a valid one. */ @Test - public void testInvalidSecondaryChainRejected() throws Exception { + public void testExpiredCertificateRenewed() throws Exception { long now = System.currentTimeMillis(); - String validAlias = "first"; - String invalidAlias = "second"; - KeyStore original = ca.keyStoreWithPrivateKey(validAlias, now - HOUR, now + HOUR); - KeyStore replacement = ca.keyStoreWithPrivateKey(validAlias, now - HOUR, now + 2 * HOUR); + KeyStore valid = ca.keyStoreWithPrivateKey("valid", now - HOUR, now + HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); + + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), ca.trustStore()), + bundle(valid, ca.trustStore())); - addKey(replacement, ca.keyStoreWithPrivateKey(invalidAlias, now + HOUR, now + 3 * HOUR), invalidAlias); + SslContextProvider p = new SslContextProvider(f); - SslContextProvider p = new SslContextProvider(new Issuer(() -> original, () -> replacement)); + p.renew(p.context()); - assertRenewalRejected(p, "The new certificate chain is not valid now [alias=" + invalidAlias); + assertEquals(Map.of("valid", chain(valid)), p.certificateChains()); } - /** With less than half of the window left, failures must be logged as errors and counted, and the status must show the next attempt. */ + /** + * renew refuses new chains unless the earliest expiry across them, authorities included, is later than the one in use: here + * another key with the same expiry, one of two keys renewed, and a later leaf from an authority that expires no later. + */ @Test - public void testLateFailureLoggedAsError() throws Exception { + public void testRenewalMustAdvanceEarliestExpiry() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = window / 4; - long notAfter = now + timeLeft; + String reason = "The new certificate chains expire no later than the ones in use"; + TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - HOUR, now + HOUR); + KeyStore a = ca.keyStoreWithPrivateKey("a", now - HOUR, now + HOUR); + KeyStore b = ca.keyStoreWithPrivateKey("b", now - HOUR, now + HOUR); + KeyStore renewedA = ca.keyStoreWithPrivateKey("a", now - HOUR, now + 2 * HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure()); + when(f.createSslBundle()).thenReturn( + bundle(merged(a, b), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("other", now - HOUR, now + HOUR), ca.trustStore()), + bundle(merged(renewedA, b), ca.trustStore()), + bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())); - IgniteEx g = startNode(issuer); + SslContextProvider p = new SslContextProvider(f); - String late = "Failed to reload TLS certificates, the ones in use stay and expire soon [" + NODE_TRANSPORTS + - ", initiator=automatic renewal, reason=" + ISSUER_DOWN; - - assertTrue(waitForCondition(() -> logged(Level.ERROR, late) >= 2, 30_000)); - - assertTrue(metrics(g).findMetric("ConsecutiveRenewalFailures").value() >= 2); - assertContains(log, status(g), "next automatic renewal at"); + assertRenewalRejected("Another key with the same expiry", p, reason); + assertRenewalRejected("One of two keys renewed", p, reason); + assertRenewalRejected("A later leaf from an authority that expires no later", p, reason); } - /** A certificate that expires no later than the one in use must not be put in use. */ + /** renew applies the checks of reload: here a candidate without a private key and one that is not valid yet are refused. */ @Test - public void testRenewalNotMovingExpiryRejected() throws Exception { + public void testRenewalAppliesReloadChecks() throws Exception { long now = System.currentTimeMillis(); - long notBefore = now - HOUR; - long notAfter = now + HOUR; - KeyStore startupKeys = ca.keyStoreWithPrivateKey("startup", notBefore, notAfter); - KeyStore replacement = ca.keyStoreWithPrivateKey("replacement", notBefore, notAfter); + AbstractSslContextFactory f = renewingFactory(QUARTER); - SslContextProvider p = new SslContextProvider(new Issuer(() -> startupKeys, () -> replacement)); + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()), + bundle(ca.trustStore(), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("not-yet-valid", now + 10 * MINUTE, now + 2 * HOUR), ca.trustStore())); - assertRenewalRejected(p, "The new certificate chains expire no later than the ones in use"); - } + SslContextProvider p = new SslContextProvider(f); - /** An automatic renewal preserves connections to a peer whose certificate and different authority stay unchanged. */ - @Test - public void testRenewalWithDifferentAuthorities() throws Exception { - assertNodesReconnectAfterRotationAndRestart(new TestCertificateAuthority("peerca")); + assertRenewalRejected("No private key", p, "The new key material has no private key, so the node would present no certificate"); + assertRenewalRejected("Not valid yet", p, "The new certificate chain is not valid now [alias=not-yet-valid, "); } - /** An expired certificate can be replaced with a valid chain from another authority. */ + /** New metadata cannot make the context in use, which a caching factory returns again, serve a renewed certificate. */ @Test - public void testExpiredCertificateRenewed() throws Exception { + public void testChangedMetadataCannotRenewCachedContext() throws Exception { long now = System.currentTimeMillis(); - String authorityName = "otherca"; - - TestCertificateAuthority other = new TestCertificateAuthority(authorityName); - - Issuer issuer = new Issuer(valid(now - 2 * HOUR, now - HOUR), () -> other.keyStoreWithPrivateKey(now, now + HOUR)); + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()); + KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); - SslContextProvider p = new SslContextProvider(issuer); + when(f.createSslBundle()).thenReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))); - p.addTransport(COMMUNICATION); - - issuer.trust = other.trustStore(); - - assertNotNull(p.renew(p.context())); - assertEquals("CN=" + authorityName, certificate(p).getIssuerX500Principal().getName()); + assertRenewalRejected("Cached context", new SslContextProvider(f), "The SSL context factory returns the context in use"); } - /** A renewal logs the certificate it installed even if a manual reload replaces it before the log is written. */ + /** + * The chains in use mean that the factory has nothing newer yet: renew returns {@code null} and counts an unsuccessful renewal, + * but records no reload failure. Here a caching factory returns the bundle in use, and another factory returns the expired + * chains in use, which the validity check would refuse. + */ @Test - public void testRenewalLogsAppliedCertificate() throws Exception { + public void testUnchangedCertificatesAwaited() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - String renewedName = "renewed"; - String manualName = "manual"; - - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), - () -> ca.keyStoreWithPrivateKey(renewedName, now, now + HOUR), - () -> ca.keyStoreWithPrivateKey(manualName, now, now + 2 * HOUR)); - - SslContextProvider p = new SslContextProvider(issuer) { - @Override SslBundle renew(SSLContext expected) throws IgniteCheckedException { - SslBundle renewed = super.renew(expected); - - if (renewed != null) - super.reload(); - - return renewed; - } - }; - - p.addTransport(COMMUNICATION); - - ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1); - - try { - p.startRenewal(exec, log.getLogger(SslRenewal.class)); - - assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 10_000)); - assertEquals("CN=" + manualName, certificate(p).getSubjectX500Principal().getName()); - assertEquals(1, logged(Level.INFO, - "TLS certificates reloaded [transports=" + COMMUNICATION + ", alias=" + renewedName + ", subject=CN=" + renewedName + ',')); - } - finally { - exec.shutdownNow(); + SslBundle cached = bundle(ca.keyStoreWithPrivateKey("cached"), ca.trustStore()); + KeyStore expired = ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR); + AbstractSslContextFactory caching = renewingFactory(QUARTER); + AbstractSslContextFactory expiredAgain = renewingFactory(QUARTER); - assertTrue(exec.awaitTermination(10, TimeUnit.SECONDS)); - } - } + when(caching.createSslBundle()).thenReturn(cached); + when(expiredAgain.createSslBundle()).thenReturn(bundle(expired, ca.trustStore()), bundle(expired, ca.trustStore())); - /** A certificate that is not valid yet must not be put in use. */ - @Test - public void testRenewalNotValidYetRejected() { - long now = System.currentTimeMillis(); + SslContextProvider cachingProvider = new SslContextProvider(caching); + SslContextProvider expiredProvider = new SslContextProvider(expiredAgain); - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), valid(now + 10 * MINUTE, now + 2 * HOUR))); + assertNull("Cached bundle", cachingProvider.renew(cachingProvider.context())); + assertEquals("Cached bundle", 1, cachingProvider.renewalFailures()); + assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), cachingProvider.reloadOutcome()); - assertRenewalRejected(p, "The new certificate chain is not valid now"); + assertNull("Expired chains", expiredProvider.renew(expiredProvider.context())); + assertEquals("Expired chains", 1, expiredProvider.renewalFailures()); + assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredProvider.reloadOutcome()); } - /** A renewal planned before a reload must not ask for a certificate or replace the one the reload has put in use. */ + /** renew planned for a context that a reload has replaced does not call the factory and changes nothing. */ @Test - public void testRenewalPlannedBeforeReloadSuperseded() throws Exception { - KeyStore inUse = ca.keyStoreWithPrivateKey(); - - Issuer issuer = new Issuer(() -> inUse); + public void testRenewForReplacedContextDoesNothing() throws Exception { + AbstractSslContextFactory f = renewingFactory(QUARTER); - SslContextProvider p = new SslContextProvider(issuer); + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())); + SslContextProvider p = new SslContextProvider(f); SSLContext planned = p.context(); - assertNull(p.renew(planned)); - assertEquals(1, p.renewalFailures()); - p.reload(); - SSLContext reloaded = p.context(); - int callsAfterReload = issuer.calls.size(); - - assertNotSame(planned, reloaded); + SslBundle reloaded = p.bundle(); assertNull(p.renew(planned)); - assertEquals(1, p.renewalFailures()); - assertSame(reloaded, p.context()); - assertEquals("The superseded renewal must not ask for another certificate", callsAfterReload, issuer.calls.size()); + assertSame(reloaded, p.bundle()); + assertEquals(0, p.renewalFailures()); + verify(f, times(2)).createSslBundle(); } - /** A reload during renewal planning prevents the outdated attempt from calling the factory or replacing the new context. */ + /** + * renew interrupted while the factory loads changes nothing and keeps the interrupt flag, whether the factory returns the chains + * in use or new ones: the interrupt is checked before both the comparison with the chains in use and the installation. + */ @Test - public void testReloadDuringRenewalPlanning() throws Exception { + public void testInterruptedRenewalChangesNothing() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(2 * HOUR), fresh(3 * HOUR)); - - CountDownLatch planRead = new CountDownLatch(1); - CountDownLatch continuePlanning = new CountDownLatch(1); - CountDownLatch reloaded = new CountDownLatch(1); - CountDownLatch allowReplan = new CountDownLatch(1); - CountDownLatch attemptStarted = new CountDownLatch(1); - - AtomicBoolean firstPlan = new AtomicBoolean(true); - AtomicReference reloadThread = new AtomicReference<>(); - - SslContextProvider p = new SslContextProvider(issuer) { - @Override public ReloadOutcome reloadOutcome() { - ReloadOutcome outcome = super.reloadOutcome(); - - if (firstPlan.getAndSet(false)) { - planRead.countDown(); - - U.awaitQuiet(continuePlanning); - } - - return outcome; - } - - @Override SslBundle renew(SSLContext expected) throws IgniteCheckedException { - attemptStarted.countDown(); - - return super.renew(expected); - } - }; - - ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1) { - @Override public ScheduledFuture schedule(Runnable task, long delay, TimeUnit unit) { - if (Thread.currentThread() == reloadThread.get()) { - reloaded.countDown(); - - U.awaitQuiet(allowReplan); - } - - return super.schedule(task, delay, unit); - } - }; - IgniteInternalFuture reload = null; - - try { - p.startRenewal(exec, log); - - assertTrue(planRead.await(10, TimeUnit.SECONDS)); - - reload = GridTestUtils.runAsync(() -> { - reloadThread.set(Thread.currentThread()); + KeyStore startup = ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR); + SslBundle same = bundle(startup, ca.trustStore()); + SslBundle renewed = bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + + when(f.createSslBundle()) + .thenReturn(bundle(startup, ca.trustStore())) + .thenAnswer(inv -> { + Thread.currentThread().interrupt(); - p.reload(); + return same; + }) + .thenAnswer(inv -> { + Thread.currentThread().interrupt(); - return null; + return renewed; }); - assertTrue(reloaded.await(10, TimeUnit.SECONDS)); + SslContextProvider p = new SslContextProvider(f); + SslBundle inUse = p.bundle(); - SSLContext inUse = p.context(); - - // Let the old plan start its attempt before the reload queues a new plan. - continuePlanning.countDown(); - - assertTrue(attemptStarted.await(10, TimeUnit.SECONDS)); - - allowReplan.countDown(); - - reload.get(10_000); - exec.submit(() -> {}).get(10, TimeUnit.SECONDS); - - assertEquals("Only the initial load and the manual reload may call the factory", 2, issuer.calls.size()); - assertSame(inUse, p.context()); - assertTrue("The next attempt must follow the new certificate", p.nextRenewalTime() > now + HOUR); + try { + GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); } finally { - continuePlanning.countDown(); - allowReplan.countDown(); - - exec.shutdownNow(); - - if (reload != null) - reload.get(10_000); - - assertTrue(exec.awaitTermination(10, TimeUnit.SECONDS)); + assertTrue("Same chains: the interrupt flag stays set", Thread.interrupted()); } - } - - /** A successful reload by the command must cancel the planned retry and plan the next renewal by the new certificate. */ - @Test - public void testReloadByCommandReplans() throws Exception { - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - pauses(MINUTE, MINUTE); - - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), failure(), fresh(2 * HOUR), fresh(HOUR)); - - IgniteEx g = startNode(issuer); - - assertTrue(waitForCondition(() -> logged(Level.WARN, ISSUER_DOWN) == 1, 30_000)); - - long retry = nextRenewalTime(g); - - assertTrue("A retry must be planned after the pause", - retry >= provider(g).reloadOutcome().failureTime() + MINUTE - CLOCK_SLACK); - - reload(g); - - long due = dueTime(g); - - assertTrue(waitForCondition(() -> nextRenewalTime(g) == due, 10_000)); - assertTrue("The next renewal must follow the new certificate", due > now + HOUR); + Assert.assertSame("Same chains", inUse, p.bundle()); + assertEquals("Same chains", 0, p.renewalFailures()); + assertEquals("Same chains", new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); - ScheduledThreadPoolExecutor exec = GridTestUtils.getFieldValue(g.context().sslContexts(), "renewalExec", "delegate"); - - assertTrue("The cancelled retry must not stay planned", exec.getQueue().stream().map(t -> (RunnableScheduledFuture)t) - .filter(f -> !f.isCancelled()).allMatch(f -> f.getDelay(TimeUnit.MILLISECONDS) > HOUR)); - } - - /** Renewals must run in an operation context of their own, not in the one of whoever ran a reload before them. */ - @Test - public void testReloadContextNotCarriedIntoRenewals() throws Exception { - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - String operator = "operator"; - - Issuer issuer = new Issuer(fresh(HOUR), valid(notAfter - lifetime, notAfter), fresh(HOUR)); - - IgniteEx g = startNode(issuer); - - try (Scope ignored = OperationContext.set(OPERATOR, operator)) { - provider(g).reload(); + try { + GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); + } + finally { + assertTrue("New chains: the interrupt flag stays set", Thread.interrupted()); } - assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); - - assertEquals(operator, issuer.calls.get(1).operator); - assertNull("The renewal must not run as the operator", issuer.calls.get(2).operator); + Assert.assertSame("New chains", inUse, p.bundle()); + assertEquals("New chains", 0, p.renewalFailures()); + assertEquals("New chains", new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); } /** - * A failed renewal must be retried until it succeeds. Each pause must double, from the shortest to the longest one. While more - * than half of the window is left, each failure must be logged as a warning. The success must clear the count of failures. + * With renewal enabled at creation, reload refuses a bundle without certificate metadata, even after the factory has turned + * renewal off: the renewal settings are fixed when the provider is created. renew refuses such a bundle too. */ @Test - public void testFailedRenewalRetried() throws Exception { - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - long minPause = 100; - long maxPause = 4 * minPause; - - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), - failure(), failure(), failure(), failure(), failure(), failure(), fresh(HOUR)); - - pauses(minPause, maxPause); - - IgniteEx g = startNode(issuer); - - assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) == 1, 30_000)); - - long[] pauses = {minPause, 2 * minPause, maxPause, maxPause, maxPause}; - - for (int i = 0; i < pauses.length; i++) { - long pause = issuer.calls.get(i + 2).time - issuer.calls.get(i + 1).time; - - assertTrue("Pause " + (i + 1) + " must last at least " + pauses[i] + " ms: " + pause, pause >= pauses[i] - CLOCK_SLACK); - } - - LogEvent fifth = events(Level.WARN, ISSUER_DOWN).get(4); - - assertTrue("The retry must stay within the maximum pause plus jitter", plannedPause(fifth) <= maxPause + maxPause / 2); - - assertEquals(8, issuer.calls.size()); - assertEquals(6, logged(Level.WARN, "initiator=automatic renewal, reason=" + ISSUER_DOWN)); - assertEquals(0, logged(Level.ERROR, ISSUER_DOWN)); - assertEquals(0, metrics(g).findMetric("ConsecutiveRenewalFailures").value()); - assertTrue(chainNotAfter(g) > now + 30 * MINUTE); - } - - /** A failure must be an error once the next attempt would come only after expiry, even with more than half of the window left. */ - @Test - public void testFailureBeforeExpiringRetryLoggedAsError() throws Exception { - long now = System.currentTimeMillis(); - long notBefore = now - HOUR; - long timeLeft = 2 * MINUTE; - long notAfter = now + timeLeft; - long window = timeLeft + timeLeft / 2; - long retryPause = 2 * timeLeft; - - Issuer issuer = new Issuer(valid(notBefore, notAfter), failure()); - - issuer.setRenewBeforeFraction((double)window / (notAfter - notBefore)); - pauses(retryPause, retryPause); - - startNode(issuer); - - assertTrue(waitForCondition(() -> logged(Level.ERROR, "initiator=automatic renewal, reason=" + ISSUER_DOWN) == 1, 30_000)); - } - - /** A certificate due for renewal as soon as it is issued must not make the node renew more often than allowed. */ - @Test - public void testRenewalsNoMoreOftenThanMinPause() throws Exception { - long minPause = 300; - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - AtomicInteger issued = new AtomicInteger(); - - Callable dueOnIssue = () -> { - // A second later each time: notAfter is rounded down to a second, and a renewal that does not move it is refused. - long notAfter = System.currentTimeMillis() + timeLeft + issued.incrementAndGet() * SECOND; - - return ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter); - }; - - Issuer issuer = new Issuer(dueOnIssue); - - pauses(minPause, minPause); - - startNode(issuer); - - assertTrue(waitForCondition(() -> automaticRenewals("transports=" + COMMUNICATION) >= 4, 30_000)); - assertTrue("Every attempt must renew the certificate", - appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); - - for (int i = 2; i < 5; i++) { - long pause = issuer.calls.get(i).time - issuer.calls.get(i - 1).time; - - assertTrue("Renewals must be at least " + minPause + " ms apart: " + pause, pause >= minPause - CLOCK_SLACK); - } - } - - /** A {@code renewBeforeFraction} out of range must be refused at once. */ - @Test - public void testInvalidSettingsRefused() { - SslContextFactory factory = new SslContextFactory(); - - assertThrowsWithCause(() -> factory.setRenewBeforeFraction(1), IllegalArgumentException.class); - assertThrowsWithCause(() -> factory.setRenewBeforeFraction(0), IllegalArgumentException.class); - } - - /** The client connector with a factory of its own must be renewed on its own. */ - @Test - public void testClientConnectorRenewed() throws Exception { - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter), fresh(HOUR)); - - IgniteEx g = startClientConnectorOn(issuer); + public void testReloadCannotLoseMetadataWithRenewal() throws Exception { + String reason = "The new SSL context must provide a nonempty certificate chain for every private key to renew automatically"; + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()); + SslBundle unknown = new SslBundle(context(ca.keyStoreWithPrivateKey("unknown"), ca.trustStore()), null); + AbstractSslContextFactory f = renewingFactory(QUARTER); - assertTrue(waitForCondition(() -> automaticRenewals("transports=" + CLIENT_CONNECTOR) == 1, 30_000)); + when(f.createSslBundle()).thenReturn(startup, unknown, unknown); - SslContextProvider p = provider(g, CLIENT_CONNECTOR); + SslContextProvider p = new SslContextProvider(f); - long renewedExpiry = SslCertificates.chainNotAfter(certificateChain(p)); + when(f.isRenewalEnabled()).thenReturn(false); - assertTrue(renewedExpiry > now + 30 * MINUTE); - assertTrue(waitForCondition(() -> p.nextRenewalTime() > now + 30 * MINUTE, 10_000)); + GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, reason); - assertEquals("New connections must get the renewed certificate", renewedExpiry, - servedCertificate(probe(), g.context().clientListener().port()).getNotAfter().getTime()); + assertSame(startup, p.bundle()); + assertRenewalRejected("Renewal", p, reason); } - /** Renewal must be off unless enabled: the node must not ask the factory again, plan a renewal or run the renewal thread. */ + /** Renewal does not start without a nonempty chain for every private key: no metadata, no private key, a key without a chain. */ @Test - public void testRenewalOffByDefault() throws Exception { - assertFalse(new SslContextFactory().isRenewalEnabled()); + public void testRenewalRefusedToStartWithoutChains() throws Exception { + KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); + SSLContext ctx = context(valid, ca.trustStore()); + AbstractSslContextFactory unknown = renewingFactory(QUARTER); + AbstractSslContextFactory noKey = renewingFactory(QUARTER); + AbstractSslContextFactory noChain = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; + when(unknown.createSslBundle()).thenReturn(new SslBundle(ctx, null)); + when(noKey.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); + // Aliases are sorted, so "z-" puts the key without a chain after a valid one: the check must go past the first key. + when(noChain.createSslBundle()).thenReturn(new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); - Issuer issuer = new Issuer(valid(notAfter - lifetime, notAfter)); + SslContextProvider unknownProvider = new SslContextProvider(unknown); + SslContextProvider noKeyProvider = new SslContextProvider(noKey); + SslContextProvider noChainProvider = new SslContextProvider(noChain); - issuer.setRenewalEnabled(false); - - IgniteEx g = startNode(issuer); - - assertEquals("A certificate in the window must stay", 1, issuer.calls.size()); - assertEquals(0, nextRenewalTime(g)); - assertFalse(renewalThreadAlive(g.name())); - assertTrue(appender.events().isEmpty()); + GridTestUtils.assertThrows(log, () -> unknownProvider.startRenewal(exec, renewalLog), IgniteException.class, + "The SSL context factory must provide a nonempty certificate chain for every private key to renew automatically"); + GridTestUtils.assertThrows(log, () -> noKeyProvider.startRenewal(exec, renewalLog), IgniteException.class, + "The new key material has no private key, so the node would present no certificate"); + GridTestUtils.assertThrows(log, () -> noChainProvider.startRenewal(exec, renewalLog), IgniteException.class, + "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate chain [alias=z-no-chain]"); } - /** The node sums failures once per factory, and a recovery clears only that factory's contribution. */ + /** Both the chains in use and a factory failure count as unsuccessful renewals, and a renewal clears the count. */ @Test - public void testRenewalFailuresMetric() throws Exception { - try (IgniteEx g = startGrid(0)) { - assertEquals(0, metrics(g).findMetric("ConsecutiveRenewalFailures").value()); - } - + public void testUnsuccessfulRenewalsCounted() throws Exception { long now = System.currentTimeMillis(); + KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); - KeyStore inUse = ca.keyStoreWithPrivateKey(now - HOUR, now + HOUR); - - Issuer nodeIssuer = new Issuer(() -> inUse, () -> inUse, fresh(2 * HOUR)); - Issuer clientIssuer = new Issuer(() -> inUse, () -> inUse, () -> inUse, fresh(2 * HOUR)); - - nodeIssuer.setRenewalEnabled(false); - clientIssuer.setRenewalEnabled(false); - - IgniteEx g = startGrid(0, cfg -> { - cfg.setSslContextFactory(nodeIssuer); - cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) - .setUseIgniteSslContextFactory(false).setSslContextFactory(clientIssuer)); - }); - - SslContextProvider node = provider(g); - SslContextProvider client = provider(g, CLIENT_CONNECTOR); + when(f.createSslBundle()) + .thenReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) + .thenThrow(new IgniteException(ISSUER_DOWN)) + .thenReturn(bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore())); - assertSame(node, provider(g, DISCOVERY)); + SslContextProvider p = new SslContextProvider(f); - for (ReadOnlyMetricRegistry reg : g.context().metric()) - assertFalse("Transport aliases must not be registered: " + reg.name(), reg.name().startsWith("ssl.")); - - IntMetric failures = metrics(g).findMetric("ConsecutiveRenewalFailures"); - - assertEquals(0, failures.value()); - - assertNull(node.renew(node.context())); - assertEquals(1, failures.value()); + assertNull(p.renew(p.context())); + assertEquals("Chains in use", 1, p.renewalFailures()); - assertNull(client.renew(client.context())); - assertNull(client.renew(client.context())); - assertEquals(3, failures.value()); + GridTestUtils.assertThrows(log, () -> p.renew(p.context()), IgniteException.class, ISSUER_DOWN); - assertNotNull(node.renew(node.context())); - assertEquals(2, failures.value()); + assertEquals("Factory failure", 2, p.renewalFailures()); - assertNotNull(client.renew(client.context())); - assertEquals(0, failures.value()); + assertNotNull(p.renew(p.context())); + assertEquals("Renewed", 0, p.renewalFailures()); } /** - * When the factory returns the current certificate, renewal retries with increasing pauses, counts each unsuccessful attempt - * and logs one warning without a stack trace. Manual reload resets the pause and allows a new warning. - * Only the warning after reload must name all transports: the first may be logged before discovery is registered. + * Reload is not a renewal attempt: a failed reload does not count, and a successful one clears the count only if the earliest + * expiry advances. Another certificate with the same expiry and a later leaf whose authority expires at the same time do not. */ @Test - public void testWaitsForNewerCertificate() throws Exception { + public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - long minPause = 100; - long fourthPause = minPause * 2 * 2 * 2; - - pauses(minPause, 10 * minPause); + TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - HOUR, now + HOUR); + KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); - KeyStore inUse = ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter); + when(f.createSslBundle()) + .thenReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) + .thenThrow(new IgniteException(ISSUER_DOWN)) + .thenReturn( + bundle(ca.keyStoreWithPrivateKey("same-expiry", now - HOUR, now + HOUR), ca.trustStore()), + bundle(shortCa.keyStoreWithPrivateKey("later-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("later-chain", now - HOUR, now + 2 * HOUR), ca.trustStore())); - Issuer issuer = new Issuer(() -> inUse); + SslContextProvider p = new SslContextProvider(f); - IgniteEx g = startNode(issuer); + p.renew(p.context()); - assertTrue(waitForCondition(() -> provider(g).renewalFailures() >= 5, 30_000)); - - long grown = issuer.calls.get(5).time - issuer.calls.get(4).time; - - assertTrue("The fifth attempt must follow three doublings of the initial pause: " + grown, - grown >= fourthPause - CLOCK_SLACK); - - assertEquals(1, logged(Level.WARN, WAITING)); - assertEquals(0, logged(Level.ERROR, WAITING)); - assertTrue("The wait must be logged without a stack trace", appender.events().stream().allMatch(e -> e.getThrown() == null)); - - ReadOnlyMetricRegistry reg = metrics(g); + GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); - assertTrue(reg.findMetric("ConsecutiveRenewalFailures").value() >= 5); - assertNull(provider(g).reloadOutcome().failureReason()); + assertEquals("Failed reload", 1, p.renewalFailures()); - Iterator metrics = reg.iterator(); + p.reload(); - assertEquals("ssl.ConsecutiveRenewalFailures", metrics.next().name()); - assertFalse("Only the renewal problem counter must be exposed", metrics.hasNext()); + assertEquals("Same expiry", 1, p.renewalFailures()); - reload(g); + p.reload(); - assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 2, 30_000)); + assertEquals("Later leaf, authority expires at the same time", 1, p.renewalFailures()); - LogEvent afterReload = events(Level.WARN, WAITING).get(1); + p.reload(); - assertContains(log, afterReload.getMessage().getFormattedMessage(), WAITING + " [" + NODE_TRANSPORTS); - assertTrue("The pause must reset to the initial pause plus jitter", plannedPause(afterReload) <= minPause + minPause / 2); + assertEquals("Later chain expiry", 0, p.renewalFailures()); } /** - * When no newer certificate is available, log an error once when less than half of the renewal window remains and once at - * expiry. Further attempts must not repeat these messages. + * The first attempt comes when the window of the chain that expires first opens: the window is a share of that chain's lifetime, + * which runs from its leaf validity start to its earliest expiry. Equal expiries go to the chain with the earliest leaf validity + * start, and an authority that expires before the leaf decides. Aliases put the expected chain second. */ @Test - public void testWaitTurningLateLoggedAsErrorAtOnce() throws Exception { - long lifetime = 5 * MINUTE; - long timeLeft = 20 * SECOND; - long window = timeLeft + 2 * SECOND; - AtomicReference inUse = new AtomicReference<>(); - - // Issued on the first request, so that the time left does not depend on how long the node takes to start. - Issuer issuer = new Issuer(() -> { - if (inUse.get() == null) { - long notAfter = System.currentTimeMillis() + timeLeft; - - inUse.set(ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter)); - } - - return inUse.get(); - }); - - issuer.setRenewBeforeFraction((double)window / lifetime); - - startNode(issuer); - - assertTrue(waitForCondition(() -> logged(Level.ERROR, WAITING + " and expire soon") == 1, 30_000)); - - int calls = issuer.calls.size(); - - assertTrue(waitForCondition(() -> issuer.calls.size() >= calls + 2, 30_000)); - - assertEquals(1, logged(Level.WARN, WAITING)); - assertEquals(1, logged(Level.ERROR, WAITING)); - - assertTrue(waitForCondition(() -> logged(Level.ERROR, WAITING + ", though they have expired") == 1, 30_000)); - - int expiredCalls = issuer.calls.size(); + public void testFirstAttemptAtWindowOfEarliestChain() throws Exception { + // X.509 keeps whole seconds. + long now = System.currentTimeMillis() / SECOND * SECOND; + TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - 2 * HOUR, now + HOUR); - assertTrue(waitForCondition(() -> issuer.calls.size() >= expiredCalls + 2, 30_000)); + KeyStore earliestExpiry = merged( + ca.keyStoreWithPrivateKey("a-later-expiry", now - HOUR, now + 3 * HOUR), + ca.keyStoreWithPrivateKey("b-earlier-expiry", now - HOUR, now + HOUR)); + KeyStore equalExpiry = merged( + ca.keyStoreWithPrivateKey("a-later-start", now - HOUR, now + 2 * HOUR), + ca.keyStoreWithPrivateKey("b-earlier-start", now - 2 * HOUR, now + 2 * HOUR)); - assertEquals(1, logged(Level.WARN, WAITING)); - assertEquals(2, logged(Level.ERROR, WAITING)); + assertEquals("Earliest expiry", now + HOUR - 30 * MINUTE, firstAttempt(QUARTER, earliestExpiry)); + assertEquals("Equal expiry, earlier start", now + 2 * HOUR - HOUR, firstAttempt(QUARTER, equalExpiry)); + assertEquals("Authority expires first", now + HOUR - 30 * MINUTE, + firstAttempt(QUARTER, shortCa.keyStoreWithPrivateKey("leaf", now - HOUR, now + 3 * HOUR))); } - /** A node that stops in the middle of a renewal attempt must interrupt it without recording or logging a failure. */ + /** + * After a successful renewal or reload, the next attempt comes no sooner than a minute later, even for a certificate in its + * window. Here every certificate is in its window: renewal plans, attempts at once and renews, then plans again, and a reload + * then replaces the renewed certificate. + */ @Test - public void testStopDuringRenewalLogsNoFailure() throws Exception { + public void testNextAttemptNotSoonerThanMinPause() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - CountDownLatch entered = new CountDownLatch(1); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - Issuer issuer = new Issuer(fresh(HOUR), valid(notAfter - lifetime, notAfter), () -> { - entered.countDown(); + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup", now - 100 * MINUTE, now + 20 * MINUTE), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("renewed", now - 95 * MINUTE, now + 25 * MINUTE), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("reloaded", now - 90 * MINUTE, now + 30 * MINUTE), ca.trustStore())); - Thread.sleep(Long.MAX_VALUE); + SslContextProvider p = new SslContextProvider(f); - return null; - }); + p.startRenewal(exec, renewalLog); + exec.runNext(); + exec.runNext(); + exec.runNext(); - IgniteEx g = startNode(issuer); - SslContextProvider p = provider(g); + assertEquals("Renewal", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); p.reload(); + exec.runNext(); - SslContextProvider.ReloadOutcome outcome = p.reloadOutcome(); - - assertTrue(outcome.successTime() > 0); - assertTrue(entered.await(30, TimeUnit.SECONDS)); - - stopGrid(0); - - assertEquals(0, p.renewalFailures()); - assertEquals(outcome, p.reloadOutcome()); - assertTrue(appender.events().stream().noneMatch(e -> e.getLevel().isMoreSpecificThan(Level.WARN))); + assertEquals("Reload", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); } - /** A loader returning after interruption must not replace the context or change recorded reload and renewal results. */ + /** + * Failed attempts are retried after 1, 2, 4 and so on up to 60 minutes, then every 60 minutes, each plus up to half of it; the + * executor gets the delay to the retry time, and every failure is logged, unlike a wait. The clock does not stop between the + * attempts, so each retry time and delay is checked against the clock before and after the attempt. + */ @Test - public void testInterruptedRenewalReturnPreservesState() throws Exception { - long now = System.currentTimeMillis(); - KeyStore original = ca.keyStoreWithPrivateKey("original", now - HOUR, now + HOUR); - KeyStore replacement = ca.keyStoreWithPrivateKey("replacement", now - MINUTE, now + 2 * HOUR); + public void testFailedAttemptsBackOff() throws Exception { + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - for (KeyStore candidate : List.of(original, replacement)) { - Issuer issuer = new Issuer(() -> original, () -> original, () -> original, failure(), () -> { - Thread.currentThread().interrupt(); - - return candidate; - }); - SslContextProvider p = new SslContextProvider(issuer); + when(f.createSslBundle()) + .thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())) + .thenThrow(new IgniteException(ISSUER_DOWN)); - p.reload(); - assertNull(p.renew(p.context())); - GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); + SslContextProvider p = new SslContextProvider(f); - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); - SslContextProvider.ReloadOutcome outcome = p.reloadOutcome(); - int failures = p.renewalFailures(); + p.startRenewal(exec, renewalLog); + exec.runNext(); - assertTrue(outcome.successTime() > 0); - assertEquals(1, outcome.failures()); - assertEquals(1, failures); + for (long pause : new long[] {1, 2, 4, 8, 16, 32, 60, 60}) { + long before = System.currentTimeMillis(); - IgniteInterruptedCheckedException err = null; - boolean interrupted; + exec.runNext(); - try { - p.renew(inUse); - } - catch (IgniteInterruptedCheckedException e) { - err = e; - } - finally { - interrupted = Thread.interrupted(); - } + long after = System.currentTimeMillis(); + long next = p.nextRenewalTime(); + long delay = exec.last().getDelay(TimeUnit.MILLISECONDS); - assertTrue(interrupted); - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - assertEquals(outcome, p.reloadOutcome()); - assertEquals(failures, p.renewalFailures()); - assertNotNull(err); + assertTrue("Pause of " + pause + " min: " + (next - before) + " ms", + next >= before + pause * MINUTE && next <= after + pause * MINUTE * 3 / 2); + assertTrue("Delay of the " + pause + "-minute retry: " + delay + " ms", delay >= next - after && delay <= next - before); } - } - - /** Cached and expired unchanged chains stay in use and count as unsuccessful renewal attempts. */ - @Test - public void testSameCertificatesUnchanged() throws Exception { - long now = System.currentTimeMillis(); - - SslBundle cached = TestCertificateAuthority.bundle(ca.keyStoreWithPrivateKey(), ca.trustStore()); - - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR)) { - @Override public SslBundle createSslBundle() { - return cached; - } - }); - - assertNull(p.renew(p.context())); - assertEquals(1, p.renewalFailures()); - assertNull(p.renew(p.context())); - assertEquals(2, p.renewalFailures()); - assertEquals(0, p.reloadOutcome().failures()); - - KeyStore expired = ca.keyStoreWithPrivateKey(now - 2 * HOUR, now - HOUR); - - SslContextProvider expiredProvider = new SslContextProvider(new Issuer(() -> expired)); - - SSLContext expiredCtx = expiredProvider.context(); - - assertNull(expiredProvider.renew(expiredCtx)); - assertSame(expiredCtx, expiredProvider.context()); - assertEquals(1, expiredProvider.renewalFailures()); - } - - /** New metadata cannot make a reused SSL context serve a replacement certificate. */ - @Test - public void testChangedMetadataCannotRenewCachedContext() throws Exception { - long now = System.currentTimeMillis(); - SslBundle original = TestCertificateAuthority.bundle(ca.keyStoreWithPrivateKey(now - HOUR, now + HOUR), ca.trustStore()); - SslBundle replacement = TestCertificateAuthority.bundle(ca.keyStoreWithPrivateKey(now - HOUR, now + 2 * HOUR), ca.trustStore()); - AtomicReference supplied = new AtomicReference<>(original); - - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR)) { - @Override public SslBundle createSslBundle() { - return supplied.get(); - } - }); - - supplied.set(new SslBundle(original.context(), replacement.certificateChains())); - - assertRenewalRejected(p, "The SSL context factory returns the context in use"); - } - - /** Returning the same certificate and throwing both count until a renewal extends the chain's lifetime. */ - @Test - public void testUnsuccessfulRenewalsCounted() throws Exception { - long now = System.currentTimeMillis(); - - KeyStore inUse = ca.keyStoreWithPrivateKey(now - HOUR, now + HOUR); - - SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, failure(), () -> inUse, fresh(2 * HOUR))); - - assertNull(p.renew(p.context())); - assertEquals(1, p.renewalFailures()); - - GridTestUtils.assertThrows(log, () -> p.renew(p.context()), IgniteException.class, ISSUER_DOWN); - - assertEquals(2, p.renewalFailures()); - assertNull(p.renew(p.context())); - assertEquals(3, p.renewalFailures()); - assertEquals(1, p.reloadOutcome().failures()); - - assertNotNull(p.renew(p.context())); - assertEquals(0, p.renewalFailures()); + assertEquals(Collections.nCopies(8, "WARN " + FAILED), problems()); } - /** Manual reloads neither count as renewal attempts nor clear their failures unless the chain expires later. */ - @Test - public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exception { - long now = System.currentTimeMillis(); - long notBefore = now - HOUR; - long notAfter = now + HOUR; - String alias = "node"; - - KeyStore inUse = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); - AtomicReference> keySupplier = new AtomicReference<>(() -> inUse); - Issuer issuer = new Issuer(() -> keySupplier.get().call()); - - SslContextProvider p = new SslContextProvider(issuer); - - assertNull(p.renew(p.context())); - assertEquals(1, p.renewalFailures()); - - // A failed manual reload is not an automatic renewal attempt. - keySupplier.set(failure()); - - GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); - - assertEquals(1, p.renewalFailures()); - - // Reloading the same certificate clears the reload error, but not the renewal failures. - keySupplier.set(() -> inUse); - - p.reload(); - - assertEquals(1, p.renewalFailures()); - assertNull(p.reloadOutcome().failureReason()); - - TestCertificateAuthority other = new TestCertificateAuthority("otherca"); - - issuer.trust = TestCertificateAuthority.trustStore(ca, other); - - // Updating only the trust store does not renew the certificate. - p.reload(); - - assertEquals(1, p.renewalFailures()); - - // A different certificate with the same expiry still does not extend the chain's lifetime. - KeyStore sameExpiry = ca.keyStoreWithPrivateKey(alias, notBefore, notAfter); - - keySupplier.set(() -> sameExpiry); - - p.reload(); - - assertFalse(inUse.getCertificate(alias).equals(certificate(p))); - assertEquals(1, p.renewalFailures()); - - // Only the final certificate extends the chain's lifetime. - keySupplier.set(() -> ca.keyStoreWithPrivateKey(notBefore, notAfter + HOUR)); - - p.reload(); - - assertEquals(0, p.renewalFailures()); - } - - /** Extending only the leaf does not clear failures when the authority still expires first. */ + /** + * Reload replans renewal: it cancels the planned retry, plans the next attempt at the window of the new certificate, resets + * the retry pause and lets renewal warn again that the factory has nothing newer. + */ @Test - public void testLaterLeafExpiryDoesNotClearRenewalFailures() throws Exception { + public void testReloadReplans() throws Exception { + // X.509 keeps whole seconds. long now = System.currentTimeMillis() / SECOND * SECOND; - long notBefore = now - HOUR; - long authorityExpiry = now + HOUR; - long leafExpiry = authorityExpiry + HOUR; - long laterLeafExpiry = leafExpiry + HOUR; - - ca = new TestCertificateAuthority("renewalca", notBefore - HOUR, authorityExpiry); + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - 100 * MINUTE, now + 20 * MINUTE), ca.trustStore()); + SslBundle reloaded = bundle(ca.keyStoreWithPrivateKey("reloaded", now, now + 2 * HOUR), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - KeyStore inUse = ca.keyStoreWithPrivateKey(notBefore, leafExpiry); + when(f.createSslBundle()).thenReturn(startup, startup, reloaded, reloaded); - SslContextProvider p = new SslContextProvider(new Issuer(() -> inUse, () -> inUse, valid(notBefore, laterLeafExpiry))); + SslContextProvider p = new SslContextProvider(f); - assertEquals(authorityExpiry, SslCertificates.chainNotAfter(certificateChain(p))); + p.startRenewal(exec, renewalLog); + exec.runNext(); + exec.runNext(); - assertNull(p.renew(p.context())); + ScheduledFuture retry = exec.last(); p.reload(); + exec.runNext(); - assertEquals(laterLeafExpiry, certificate(p).getNotAfter().getTime()); - assertEquals(authorityExpiry, SslCertificates.chainNotAfter(certificateChain(p))); - assertEquals(1, p.renewalFailures()); - } - - /** The file-based factory with renewal enabled must put in use the key store replaced on disk, with no command. */ - @Test - public void testReplacedKeyStoreFileRenewed() throws Exception { - long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - Path keyStore = Files.createTempFile("ignite-ssl-renewal-", ".jks"); - - try { - TestCertificateAuthority.save(ca.keyStoreWithPrivateKey(notAfter - lifetime, notAfter), keyStore); - - SslContextFactory factory = new SslContextFactory(); - - factory.setKeyStoreFilePath(keyStore.toString()); - factory.setKeyStorePassword(TestCertificateAuthority.PWD); - factory.setKeyStoreType("JKS"); - factory.setTrustManagers(TestCertificateAuthority.trustManagers(ca.trustStore())); - factory.setRenewalEnabled(true); + assertTrue(retry.isCancelled()); + assertEquals(now + 2 * HOUR - 30 * MINUTE, p.nextRenewalTime()); - IgniteEx g = startNode(factory); + long before = System.currentTimeMillis(); - assertTrue(waitForCondition(() -> logged(Level.WARN, WAITING) == 1, 30_000)); + exec.runNext(); - TestCertificateAuthority.save(ca.keyStoreWithPrivateKey(now, now + HOUR), keyStore); + long after = System.currentTimeMillis(); + long next = p.nextRenewalTime(); - assertTrue(waitForCondition(() -> automaticRenewals(NODE_TRANSPORTS) == 1, 30_000)); - assertTrue(chainNotAfter(g) > now + 30 * MINUTE); - } - finally { - Files.delete(keyStore); - } + assertTrue("Retry after the reload: " + (next - before) + " ms", next >= before + MINUTE && next <= after + MINUTE * 3 / 2); + assertEquals(List.of("WARN " + WAITING, "WARN " + WAITING), problems()); } - /** Automatic renewal without certificate metadata must stop the node from starting. */ + /** + * An attempt planned for a context that a reload has replaced, here run before the plan of the reload, does not call the + * factory, log or schedule a retry: only the plan of the reload stays pending. + */ @Test - public void testUnknownCertificateRefusedAtStart() { - Issuer issuer = new Issuer(fresh(HOUR)); + public void testStaleAttemptNotRetried() throws Exception { + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - issuer.metadata = false; + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())); - GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, - "The SSL context factory must provide a nonempty certificate chain for every private key"); - } + SslContextProvider p = new SslContextProvider(f); - /** Automatic renewal cannot start when the loaded store contains no private key. */ - @Test - public void testKeyStoreWithoutKeyRefusedAtStart() { - Issuer issuer = new Issuer(SslRenewalTest::noKey); + p.startRenewal(exec, renewalLog); + exec.runNext(); - GridTestUtils.assertThrowsAnyCause(log, () -> startClientConnectorOn(issuer), IgniteException.class, - "The new key material has no private key, so the node would present no certificate"); - } + Task stale = exec.last(); - /** Renewal rejects a store without a private key and retains the context and chain in use. */ - @Test - public void testKeyStoreWithoutKeyNotRenewed() { - SslContextProvider p = new SslContextProvider(new Issuer(fresh(HOUR), SslRenewalTest::noKey)); - - assertRenewalRejected(p, "The new key material has no private key, so the node would present no certificate"); - } - - /** With renewal disabled, missing certificate metadata must not schedule renewal or produce renewal log messages. */ - @Test - public void testUnknownCertificateWithoutRenewal() throws Exception { - Issuer issuer = new Issuer(fresh(HOUR)); - - issuer.metadata = false; - issuer.setRenewalEnabled(false); + p.reload(); - IgniteEx g = startClientConnectorOn(issuer); + Task plan = exec.last(); - SslContextProvider p = provider(g, CLIENT_CONNECTOR); + stale.run(); - assertNull(p.certificateChains()); - assertEquals(0, p.nextRenewalTime()); - assertContains(log, status(g), "certificate metadata is unavailable"); - assertContains(log, reload(g), ": reloaded " + CLIENT_CONNECTOR); - assertTrue(appender.events().isEmpty()); + verify(f, times(2)).createSslBundle(); + assertEquals(List.of(), problems()); + assertEquals(List.of(plan), exec.pending()); } - /** Known aliases with a missing secondary chain cannot start or replace an automatically renewed context. */ + /** + * An attempt runs without the operation context of whoever started renewal, here an operator, even on the executor of a node, + * which passes the context of a submitter to its tasks. The factory sees the operator when the provider is created under it. + */ @Test - public void testMissingSecondaryChainRefused() throws Exception { - String completeAlias = "complete-key"; - String incompleteAlias = "key-without-chain"; - AtomicBoolean missing = new AtomicBoolean(); - KeyStore keys = ca.keyStoreWithPrivateKey(completeAlias); - - addKey(keys, ca.keyStoreWithPrivateKey(incompleteAlias), incompleteAlias); - - Issuer issuer = new Issuer(() -> keys) { - @Override public SslBundle createSslBundle() { - SslBundle loaded = super.createSslBundle(); - - return missing.get() ? new SslBundle(loaded.context(), - Map.of(completeAlias, loaded.certificateChains().get(completeAlias), incompleteAlias, List.of())) : loaded; - } - }; - SslContextProvider p = new SslContextProvider(issuer); - - missing.set(true); - - String reason = "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate chain " + - "[alias=" + incompleteAlias + ']'; + public void testRenewalRunsWithoutOperatorContext() throws Exception { + long now = System.currentTimeMillis(); + SslBundle inWindow = bundle(ca.keyStoreWithPrivateKey("in-window", now - 100 * MINUTE, now + 20 * MINUTE), ca.trustStore()); + List operators = new CopyOnWriteArrayList<>(); + AbstractSslContextFactory f = renewingFactory(QUARTER); + ScheduledExecutorService exec = newSingleThreadScheduledExecutor("ssl-renewal", getTestIgniteInstanceName()); - assertRenewalRejected(p, reason); + when(f.createSslBundle()).thenAnswer(inv -> { + operators.add(OperationContext.get(OPERATOR)); - SslContextProvider incomplete = new SslContextProvider(issuer); - ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(1); + return inWindow; + }); try { - GridTestUtils.assertThrows(log, () -> { - incomplete.startRenewal(exec, log); - - return null; - }, IgniteException.class, reason); + try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { + new SslContextProvider(f).startRenewal(exec, renewalLog); + } - assertEquals(0, incomplete.nextRenewalTime()); + assertTrue(waitForCondition(() -> operators.size() == 2, 10_000)); + assertEquals(Arrays.asList("operator", null), operators); } finally { exec.shutdownNow(); @@ -1277,410 +645,257 @@ public void testMissingSecondaryChainRefused() throws Exception { } } - /** Renewal settings are fixed at registration; changing the factory must not let reload lose the chain used by renewal. */ + /** The log names the certificate an attempt installed, even if a reload replaces it before the attempt logs it. */ @Test - public void testReloadCannotLoseMetadataWithRenewal() { - Issuer issuer = new Issuer(fresh(HOUR)); - SslContextProvider p = new SslContextProvider(issuer); - - issuer.setRenewalEnabled(false); - issuer.metadata = false; - - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); - String reason = "The new SSL context must provide a nonempty certificate chain for every private key to renew automatically"; - - assertNotNull(chains); - - GridTestUtils.assertThrows(log, () -> { - p.reload(); - - return null; - }, IgniteCheckedException.class, reason); - - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - - assertRenewalRejected(p, reason); - } + public void testRenewalLogsAppliedCertificate() throws Exception { + long now = System.currentTimeMillis(); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - /** Copies one private key and its chain into the target store. */ - private static void addKey(KeyStore target, KeyStore source, String alias) throws Exception { - target.setKeyEntry(alias, source.getKey(alias, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - source.getCertificateChain(alias)); - } + when(f.createSslBundle()).thenReturn( + bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("reloaded", now - HOUR, now + 3 * HOUR), ca.trustStore())); - /** @return Key store with no private key. */ - private static KeyStore noKey() throws Exception { - KeyStore noKey = KeyStore.getInstance(KeyStore.getDefaultType()); + SslContextProvider p = new SslContextProvider(f) { + /** {@inheritDoc} */ + @Override SslBundle renew(SSLContext expected) throws IgniteCheckedException { + SslBundle renewed = super.renew(expected); - noKey.load(null, null); + super.reload(); - return noKey; - } + return renewed; + } + }; - /** - * @param factory SSL context factory of the client connector. - * @return Started node whose client connector runs on the factory and whose other transports run without SSL. - */ - private IgniteEx startClientConnectorOn(AbstractSslContextFactory factory) throws Exception { - return startGrid(getTestIgniteInstanceName(0), cfg -> cfg - .setClientConnectorConfiguration(new ClientConnectorConfiguration().setSslEnabled(true).setSslClientAuth(false) - .setUseIgniteSslContextFactory(false).setSslContextFactory(factory))); - } + p.startRenewal(exec, renewalLog); + exec.runNext(); + exec.runNext(); - /** - * @param factory SSL context factory of the node. - * @return Started node. - */ - private IgniteEx startNode(AbstractSslContextFactory factory) throws Exception { - return startNode(0, factory); - } + List reloadedLogs = appender.events().stream().map(e -> e.getMessage().getFormattedMessage()) + .filter(msg -> msg.startsWith("TLS certificates reloaded [")).toList(); - /** - * @param idx Node index. - * @param factory SSL context factory of the node. - * @return Started node. - */ - private IgniteEx startNode(int idx, AbstractSslContextFactory factory) throws Exception { - return startGrid(getTestIgniteInstanceName(idx), cfg -> cfg.setSslContextFactory(factory)); + assertEquals(reloadedLogs.toString(), 1, reloadedLogs.size()); + assertContains(log, reloadedLogs.get(0), ", alias=renewed, subject=CN=renewed, "); } /** - * @param source Key store to use on each request. - * @param trust Authorities the node accepts. - * @param renewal Whether the node renews automatically. - * @return Configured issuer. + * A failure is a warning while more than half of the window is left and an error once less is left, the retry a minute later + * comes after expiry or the certificates have expired. In each case the first failure is logged with its stack trace. */ - private Issuer issuer(Callable source, KeyStore trust, boolean renewal) { - Issuer issuer = new Issuer(source); - - issuer.trust = trust; - issuer.setRenewalEnabled(renewal); + @Test + public void testFailureLogLevelFollowsTimeLeft() throws Exception { + // X.509 keeps whole seconds. + long now = System.currentTimeMillis() / SECOND * SECOND; - return issuer; + assertFailureLogged("More than half of the window left", + ca.keyStoreWithPrivateKey("early", now - 100 * MINUTE, now + 20 * MINUTE), QUARTER, "WARN " + FAILED); + assertFailureLogged("Less than half of the window left", + ca.keyStoreWithPrivateKey("late", now - 110 * MINUTE, now + 10 * MINUTE), QUARTER, "ERROR " + FAILED + " and expire soon"); + // 45 seconds left are more than half of the 72-second window, but less than the minute before the retry. + assertFailureLogged("Retry after expiry", + ca.keyStoreWithPrivateKey("short-window", now + 45 * SECOND - HOUR, now + 45 * SECOND), 0.02, + "ERROR " + FAILED + " and expire soon"); + assertFailureLogged("Expired", + ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), QUARTER, "ERROR " + FAILED + ", though they have expired"); } /** - * Rotates one node's certificate, then restarts each node to check that they load their current certificates and reconnect. - * - * @param peerCa Authority of the peer whose certificate stays unchanged. + * While the factory returns the chains in use, renewal logs that once, as a warning without a stack trace, and again only when + * the lateness grows: the fifth retry pause of 16 minutes takes the next attempt past the expiry 16 minutes away, which is an + * error. The attempts run one after another, so the time left does not change. */ - private void assertNodesReconnectAfterRotationAndRestart(TestCertificateAuthority peerCa) throws Exception { + @Test + public void testWaitLoggedOncePerLateness() throws Exception { long now = System.currentTimeMillis(); - long lifetime = HOUR; - long window = (long)(lifetime * DFLT_RENEW_BEFORE_FRACTION); - long timeLeft = 3 * window / 4; - long notAfter = now + timeLeft; - - AtomicReference keys = new AtomicReference<>(ca.keyStoreWithPrivateKey("node0", notAfter - lifetime, notAfter)); - KeyStore peerKeys = peerCa.keyStoreWithPrivateKey("node1", now - MINUTE, now + HOUR); - Issuer issuer = issuer(keys::get, peerCa.trustStore(), true); - Issuer peerIssuer = issuer(() -> peerKeys, ca.trustStore(), false); - - IgniteEx g0 = startNode(0, issuer); - IgniteEx g1 = startNode(1, peerIssuer); - - X509Certificate peerCert = certificate(provider(g1)); - - assertFalse(certificate(provider(g0)).equals(peerCert)); - assertComputeWorks(g0, g1); + SslBundle inUse = bundle(ca.keyStoreWithPrivateKey("in-use", now - 104 * MINUTE, now + 16 * MINUTE), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - String renewedAlias = "node0-new"; - KeyStore replacement = ca.keyStoreWithPrivateKey(renewedAlias, now, now + 2 * HOUR); - X509Certificate renewed = (X509Certificate)replacement.getCertificate(renewedAlias); + when(f.createSslBundle()).thenReturn(inUse); - keys.set(replacement); + SslContextProvider p = new SslContextProvider(f); - assertTrue(waitForCondition(() -> renewed.equals(certificate(provider(g0))), 30_000)); + p.startRenewal(exec, renewalLog); - assertEquals(renewed, certificate(provider(g0))); - assertEquals(peerCert, certificate(provider(g1))); - assertEquals(0, provider(g1).reloadOutcome().successTime()); - assertEquals(renewed, servedCertificate(peerIssuer.createSslBundle().context(), discoveryPort(g0))); - assertEquals(peerCert, servedCertificate(issuer.createSslBundle().context(), discoveryPort(g1))); + for (int i = 0; i < 8; i++) + exec.runNext(); - assertComputeWorks(g0, g1); - - stopGrid(1); + assertEquals(List.of("WARN " + WAITING, "ERROR " + WAITING + " and expire soon"), problems()); + assertTrue(appender.events().stream().allMatch(e -> e.getThrown() == null)); + } - g1 = startNode(1, issuer(() -> peerKeys, ca.trustStore(), false)); + /** + * A wait logged as expiring soon is logged again once the certificates have expired, and not after that. The certificate expires + * within 3 seconds, which the test waits for. + */ + @Test + public void testWaitLoggedAgainOnExpiry() throws Exception { + // X.509 keeps whole seconds. With 2 to 3 seconds left, the first attempt comes before expiry even on a slow machine. + long notAfter = (System.currentTimeMillis() / SECOND + 3) * SECOND; + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - assertEquals(renewed, certificate(provider(g0))); - assertEquals(peerCert, certificate(provider(g1))); - assertComputeWorks(g0, g1); + when(f.createSslBundle()).thenReturn(bundle(ca.keyStoreWithPrivateKey("in-use", notAfter - 2 * HOUR, notAfter), ca.trustStore())); - stopGrid(0); + SslContextProvider p = new SslContextProvider(f); - IgniteEx restarted = startNode(0, issuer(keys::get, peerCa.trustStore(), true)); + p.startRenewal(exec, renewalLog); + exec.runNext(); + exec.runNext(); - assertEquals(renewed, certificate(provider(restarted))); - assertEquals(renewed, servedCertificate(peerIssuer.createSslBundle().context(), discoveryPort(restarted))); - assertEquals(peerCert, certificate(provider(g1))); - assertComputeWorks(restarted, g1); - } + assertTrue(waitForCondition(() -> System.currentTimeMillis() >= notAfter, 10_000)); - /** Runs a job from each node on its peer. */ - private void assertComputeWorks(IgniteEx g0, IgniteEx g1) { - for (IgniteEx from : List.of(g0, g1)) { - IgniteEx to = from == g0 ? g1 : g0; + exec.runNext(); + exec.runNext(); - assertEquals((Integer)1, from.compute(from.cluster().forNodeId(to.localNode().id())).withTimeout(10_000).call(() -> 1)); - } + assertEquals(List.of("ERROR " + WAITING + " and expire soon", "ERROR " + WAITING + ", though they have expired"), problems()); } /** - * @param notBefore Time the certificate becomes valid. - * @param notAfter Time it expires. - * @return Step that issues a certificate valid for that time. + * @param fraction Share of the lifetime left when renewal starts. + * @return Factory mock with renewal enabled; tests script its bundles. */ - private Callable valid(long notBefore, long notAfter) { - return () -> ca.keyStoreWithPrivateKey(notBefore, notAfter); + private static AbstractSslContextFactory renewingFactory(double fraction) { + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + + when(f.isRenewalEnabled()).thenReturn(true); + when(f.getRenewBeforeFraction()).thenReturn(fraction); + + return f; } /** - * @param lifetime Lifetime of the certificate. - * @return Step that issues a certificate valid from the moment it is issued. + * @param fraction Share of the lifetime left when renewal starts. + * @param keys Key store a provider starts on. + * @return Time of the first attempt that renewal plans for it. */ - private Callable fresh(long lifetime) { - return () -> { - long now = System.currentTimeMillis(); + private long firstAttempt(double fraction, KeyStore keys) throws Exception { + AbstractSslContextFactory f = renewingFactory(fraction); + RecordingExecutor exec = new RecordingExecutor(); - return ca.keyStoreWithPrivateKey(now, now + lifetime); - }; - } + when(f.createSslBundle()).thenReturn(bundle(keys, ca.trustStore())); - /** @return Step that simulates an unreachable issuer. */ - private static Callable failure() { - return () -> { - throw new IgniteException(ISSUER_DOWN); - }; - } + SslContextProvider p = new SslContextProvider(f); - /** @return Context to connect to the nodes with; a new one each time, so that no session is resumed with an old certificate. */ - private SSLContext probe() throws Exception { - return TestCertificateAuthority.context(ca.keyStoreWithPrivateKey(), ca.trustStore()); - } + p.startRenewal(exec, renewalLog); + exec.runNext(); - /** - * @param min Pause after the first unsuccessful renewal attempt, in milliseconds. - * @param max Longest pause, in milliseconds. - */ - private static void pauses(long min, long max) { - GridTestUtils.setFieldValue(SslRenewal.class, "minPause", min); - GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", max); + return p.nextRenewalTime(); } /** + * Checks that renew fails with the reason, keeps the bundle in use, counts one more unsuccessful renewal and records the reason + * as the last reload failure, which status shows. + * + * @param desc Case, named in assertion messages. * @param p Provider. - * @param reason Reason the renewal must be rejected for. + * @param reason Expected part of the failure message. */ - private static void assertRenewalRejected(SslContextProvider p, String reason) { - SSLContext inUse = p.context(); - Map> chains = p.certificateChains(); + private void assertRenewalRejected(String desc, SslContextProvider p, String reason) { + SslBundle inUse = p.bundle(); int failures = p.renewalFailures(); - GridTestUtils.assertThrows(log, () -> p.renew(inUse), IgniteCheckedException.class, reason); - - assertSame(inUse, p.context()); - assertEquals(chains, p.certificateChains()); - assertEquals(failures + 1, p.renewalFailures()); - } + GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteCheckedException.class, reason, desc + ": not refused"); - /** - * @param level Level. - * @param text Text the message contains. - * @return Number of renewal messages containing the text at the given level. - */ - private long logged(Level level, String text) { - return events(level, text).size(); + Assert.assertSame(desc, inUse, p.bundle()); + assertEquals(desc, failures + 1, p.renewalFailures()); + assertTrue(desc, p.reloadOutcome().failureReason().contains(reason)); } /** - * @param level Level. - * @param text Text the message contains. - * @return Renewal messages containing the text at the given level, in log order. + * Starts renewal on the key store, lets the factory fail the first attempt and checks how renewal logs the failure. + * + * @param desc Case, named in assertion messages. + * @param inUse Key store a provider starts on. + * @param fraction Share of the lifetime left when renewal starts. + * @param expected Level and message of the failure, without the details in brackets. */ - private List events(Level level, String text) { - return appender.events().stream().filter(e -> e.getLevel() == level && e.getMessage().getFormattedMessage().contains(text)) - .collect(Collectors.toList()); - } + private void assertFailureLogged(String desc, KeyStore inUse, double fraction, String expected) throws Exception { + AbstractSslContextFactory f = renewingFactory(fraction); + RecordingExecutor exec = new RecordingExecutor(); - /** - * @param transports Transports as the log names them. - * @return How many times the renewals put new certificates in use. - */ - private long automaticRenewals(String transports) { - return logged(Level.INFO, "TLS certificates reloaded [" + transports); - } + when(f.createSslBundle()).thenReturn(bundle(inUse, ca.trustStore())).thenThrow(new IgniteException(ISSUER_DOWN)); - /** - * @param e Event logged by an unsuccessful renewal attempt. - * @return Time from the event to the next attempt it planned, in milliseconds. - */ - private static long plannedPause(LogEvent e) { - String msg = e.getMessage().getFormattedMessage(); - int from = msg.indexOf("nextRenewal=") + "nextRenewal=".length(); + SslContextProvider p = new SslContextProvider(f); + int before = problems().size(); - return Instant.parse(msg.substring(from, msg.indexOf(']', from))).toEpochMilli() - e.getTimeMillis(); - } + p.startRenewal(exec, renewalLog); + exec.runNext(); + exec.runNext(); - /** - * @param g Node. - * @return Start of the renewal window, calculated with the default renewal fraction. - */ - private static long dueTime(IgniteEx g) { - long notBefore = certificate(provider(g)).getNotBefore().getTime(); - long notAfter = chainNotAfter(g); + List problems = problems(); + LogEvent failure = appender.events().get(appender.events().size() - 1); - return notAfter - (long)((notAfter - notBefore) * DFLT_RENEW_BEFORE_FRACTION); + assertEquals(desc, List.of(expected), problems.subList(before, problems.size())); + assertNotNull(desc + ": stack trace", failure.getThrown()); } - /** */ - private static long nextRenewalTime(IgniteEx g) { - return provider(g).nextRenewalTime(); - } - - /** */ - private static long chainNotAfter(IgniteEx g) { - return SslCertificates.chainNotAfter(certificateChain(provider(g))); + /** @return Warnings and errors renewal logged, in log order, each as its level and its message without the details in brackets. */ + private List problems() { + return appender.events().stream().filter(e -> e.getLevel().isMoreSpecificThan(Level.WARN)) + .map(e -> e.getLevel() + " " + e.getMessage().getFormattedMessage().replaceFirst(" \\[.*", "")) + .toList(); } - /** @return Sole chain used by a single-key test factory. */ - private static List certificateChain(SslContextProvider provider) { - Map> chains = provider.certificateChains(); + /** Records scheduled tasks instead of running them after their delays; a test runs them one at a time in its own thread. */ + private static class RecordingExecutor extends ScheduledThreadPoolExecutor { + /** Scheduled tasks, in the order of scheduling. */ + private final List tasks = new ArrayList<>(); - assertNotNull(chains); - assertEquals(1, chains.size()); - - return chains.values().iterator().next(); - } - - /** @return Leaf of the sole chain used by a single-key test factory. */ - private static X509Certificate certificate(SslContextProvider provider) { - return certificateChain(provider).get(0); - } - - /** - * @param g Node. - * @return SSL metrics of the node. - */ - private static ReadOnlyMetricRegistry metrics(IgniteEx g) { - for (ReadOnlyMetricRegistry reg : g.context().metric()) { - if (reg.name().equals("ssl")) - return reg; + /** */ + private RecordingExecutor() { + super(1); } - throw new AssertionError("SSL metrics are not registered"); - } + /** {@inheritDoc} */ + @Override public ScheduledFuture schedule(Runnable cmd, long delay, TimeUnit unit) { + Task task = new Task(cmd, unit.toMillis(delay)); - /** - * @param g Node. - * @return SSL context provider used by communication. - */ - private static SslContextProvider provider(IgniteEx g) { - return provider(g, COMMUNICATION); - } + tasks.add(task); - /** - * @param g Node. - * @param transport Transport. - * @return SSL context provider used by the transport. - */ - private static SslContextProvider provider(IgniteEx g, String transport) { - for (SslContextProvider p : g.context().sslContexts().providers()) { - if (p.transports().contains(transport)) - return p; + return task; } - throw new AssertionError("Nothing serves " + transport); - } - - /** - * @param name Node name. - * @return Whether a renewal thread of the node is alive. - */ - private static boolean renewalThreadAlive(String name) { - for (Thread t : Thread.getAllStackTraces().keySet()) { - if (t.isAlive() && t.getName().startsWith("ssl-renewal") && t.getName().contains(name)) - return true; + /** Runs the pending task with the shortest delay, the first scheduled of equal ones. */ + private void runNext() { + pending().stream().min(Comparator.comparingLong(t -> t.delay)).orElseThrow().run(); } - return false; - } - - /** One request to an issuer. */ - private static class Call { - /** Time the request arrived, in milliseconds since the epoch. */ - private final long time = System.currentTimeMillis(); - - /** Operation context attribute standing for the operator, as the request saw it. */ - private final @Nullable String operator = OperationContext.get(OPERATOR); - } - - /** Factory that answers each request the way the test scripted it, the last step repeated for the rest. */ - private class Issuer extends AbstractSslContextFactory { - /** */ - @Serial - private static final long serialVersionUID = 0L; - - /** Answers to the requests in order, the last one repeated for the rest. */ - private final List> steps; - - /** Requests so far. */ - private final List calls = new CopyOnWriteArrayList<>(); - - /** Trust store for new contexts, or {@code null} to trust only the default test authority. */ - private volatile KeyStore trust; - - /** Whether this factory supplies certificate metadata. */ - private boolean metadata = true; - - /** @param steps Answers to the requests in order, the last one repeated for the rest. */ - @SafeVarargs - private Issuer(Callable... steps) { - this.steps = List.of(steps); - - setRenewalEnabled(true); + /** @return Tasks neither run nor cancelled, in the order of scheduling. */ + private List pending() { + return tasks.stream().filter(t -> !t.isDone()).toList(); } - /** {@inheritDoc} */ - @Override protected void checkParameters() { - // No-op. + /** @return Last scheduled task. */ + private Task last() { + return tasks.get(tasks.size() - 1); } + } - /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { - SslBundle bundle = super.createSslBundle(); + /** Task that runs only when a test runs it. */ + private static class Task extends FutureTask implements ScheduledFuture { + /** Delay requested at scheduling, in milliseconds. */ + private final long delay; + + /** + * @param cmd Command. + * @param delay Delay requested at scheduling, in milliseconds. + */ + private Task(Runnable cmd, long delay) { + super(cmd, null); - return metadata ? bundle : new SslBundle(bundle.context(), null); + this.delay = delay; } /** {@inheritDoc} */ - @Override protected KeyMaterial loadKeyMaterial() { - Callable step = steps.get(Math.min(calls.size(), steps.size() - 1)); - - calls.add(new Call()); - - try { - return new KeyMaterial(step.call(), TestCertificateAuthority.PWD, KeyManagerFactory.getDefaultAlgorithm()); - } - catch (IgniteException e) { - throw e; - } - catch (Exception e) { - throw new IgniteException(e); - } + @Override public long getDelay(TimeUnit unit) { + return unit.convert(delay, TimeUnit.MILLISECONDS); } /** {@inheritDoc} */ - @Override protected TrustManager[] createTrustManagers() throws SSLException { - try { - return TestCertificateAuthority.trustManagers(trust != null ? trust : ca.trustStore()); - } - catch (Exception e) { - throw new SSLException(e); - } + @Override public int compareTo(Delayed other) { + return Long.compare(delay, other.getDelay(TimeUnit.MILLISECONDS)); } } } diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 2fe97abe75b00..aa8b74ad7fcba 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -75,6 +75,7 @@ import org.apache.ignite.internal.processors.security.service.ServiceStaticConfigTest; import org.apache.ignite.internal.processors.security.snapshot.SnapshotPermissionCheckTest; import org.apache.ignite.internal.ssl.SslContextProviderTest; +import org.apache.ignite.internal.ssl.SslRenewalNodeTest; import org.apache.ignite.internal.ssl.SslRenewalTest; import org.apache.ignite.internal.thread.context.ComputeTaskOperationContextPropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributePropagationTest; @@ -152,6 +153,7 @@ SslContextProviderTest.class, SslContextReloadTest.class, SslRenewalTest.class, + SslRenewalNodeTest.class, SslContextReuseTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, From 5f834c81837425550e08995663e92ba4eb491120 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 02:00:20 +0300 Subject: [PATCH 074/242] IGNITE-29211 Say plainly when the renewal window opens Co-Authored-By: Claude Opus 5.5 --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 0778ede76ef53..e39ccfbf94ca6 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -259,7 +259,7 @@ For manual file replacement, use <> and run reload after === Prepare Certificate Delivery Renewal follows the first certificate to expire across all private-key chains, including CA certificates. -For that chain, the renewal window is the last `renewBeforeFraction` share of the time from its leaf certificate's start date to its earliest expiry, so the window ends at that expiry. Ignite starts requesting replacements when this window opens; `control.sh --ssl status` shows the next attempt time. If several chains expire together, the earliest window applies. +For that chain, the renewal window is the last share of its lifetime set by the factory property `renewBeforeFraction`. The lifetime runs from the leaf certificate's start date to the earliest expiry in the chain. Ignite starts trying to load replacement certificates when the renewal window opens. If several chains expire at exactly the same time, renewal starts when the first of their windows opens. The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. From 8a99c9926e2095c452461b35b48564ae862a474e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 02:28:34 +0300 Subject: [PATCH 075/242] IGNITE-29209 State how JKS lists a key without a chain Co-Authored-By: Claude Opus 5.5 --- .../org/apache/ignite/ssl/KeyMaterial.java | 6 ++--- .../TestCertificateAuthority.java | 26 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index 9165bb7a8b23c..2cfc501341b88 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -85,9 +85,9 @@ public KeyManager[] keyManagers() throws SSLException { } /** - * @return Immutable X.509 chains by private-key alias, own certificate first. An empty map means there are no private keys. - * An empty list retains an alias whose chain is absent or contains a non-X.509 certificate; key managers never present - * such a key. + * @return Immutable X.509 chains by private-key alias, own certificate first. An empty map means the store lists no private + * keys. JKS lists a private key stored without a chain as a secret key, so such a key is not included. An empty list + * retains an alias whose chain is absent or contains a non-X.509 certificate; key managers never present such a key. * @throws SSLException If the loaded store cannot be read. */ public Map> certificateChains() throws SSLException { diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index efafae52791ae..fe3c9f829ae69 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -43,7 +43,8 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; /** - * Issues JKS stores protected by {@link #PWD}, each with an EC P-256 private key and its certificate chain. + * Issues JKS stores protected by {@link #PWD}. Key stores hold EC P-256 private keys with their certificate chains, and trust + * stores hold authority certificates. * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, * so the same material works for node-to-node TLS. */ @@ -115,33 +116,28 @@ public KeyStore trustStore() throws Exception { } /** - * @param keyStore Store with at most one private key. - * @return Its certificate chain, or {@code null} if the store has no private key. + * @param keyStore Store with exactly one private key. + * @return Its certificate chain in the form certificate metadata uses. */ - public static X509Certificate[] certificateChain(KeyStore keyStore) throws Exception { - X509Certificate[] chain = null; + public static List chain(KeyStore keyStore) throws Exception { + List chain = null; for (String alias : Collections.list(keyStore.aliases())) { if (!keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) continue; if (chain != null) - throw new IllegalArgumentException("The test key store must contain at most one private key"); + throw new IllegalArgumentException("The test key store must contain exactly one private key"); Certificate[] certs = keyStore.getCertificateChain(alias); - chain = Arrays.copyOf(certs, certs.length, X509Certificate[].class); + chain = List.of(Arrays.copyOf(certs, certs.length, X509Certificate[].class)); } - return chain; - } + if (chain == null) + throw new IllegalArgumentException("The test key store must contain exactly one private key"); - /** - * @param keyStore Store with exactly one private key. - * @return Its certificate chain in the form certificate metadata uses. - */ - public static List chain(KeyStore keyStore) throws Exception { - return List.of(certificateChain(keyStore)); + return chain; } /** From 935b2ba5f8153e519fc3df88542bb2b58ba15ff2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 02:32:51 +0300 Subject: [PATCH 076/242] IGNITE-29211 Order renewal refusals as the code checks them and check the logged reason Co-Authored-By: Claude Opus 5.5 --- .../internal/ssl/SslContextProvider.java | 5 +- .../internal/ssl/SslRenewalNodeTest.java | 2 +- .../ignite/internal/ssl/SslRenewalTest.java | 98 ++++++++++--------- 3 files changed, 54 insertions(+), 51 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 3afd0805c4809..b59ee46a77c67 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -177,12 +177,13 @@ public synchronized SslBundle reload() throws IgniteCheckedException { /** * Installs a new bundle only if the earliest expiry across all its chains is later than the current earliest expiry. - * Returning the current chains counts as an unsuccessful attempt and leaves the bundle unchanged. + * Returning the current chains counts as an unsuccessful attempt and leaves the bundle unchanged. An interrupted attempt + * changes nothing and does not count as unsuccessful. * * @param expected Context the renewal was planned for. * @return Installed bundle, or {@code null} if its chains are unchanged or the planned context had already been replaced. * @throws IgniteCheckedException If any chain is unknown, fails the checks of {@link #reload()} or the earliest expiry - * does not advance. + * does not advance, or if the thread is interrupted by the time the factory returns; the interrupt flag then stays set. */ synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { if (bundle.context() != expected) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index 58afc85d41a28..cf44edae23514 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -59,7 +59,7 @@ * Renewal on a node: a factory without certificate metadata stops the node from starting with renewal; the node exposes the sum of * unsuccessful renewals of its factories as the only SSL metric; stopping the node interrupts an attempt without a failure and * stops the renewal thread; the file-based factory puts a replaced key store in use with no command. Nodes run - * {@link SslContextFactory} on key store files of the test and trust the test authority. A node starting with renewal starts on + * {@link SslContextFactory} on key store files of the test and trust the test authority. Nodes that run renewal start on * a certificate with 10 of its 120 minutes left, inside the default window of 18 minutes, so the first attempt comes at once. */ public class SslRenewalNodeTest extends GridCommonAbstractTest { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 6d5c2f3f14f60..fdb0d4d416a99 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -67,17 +67,18 @@ /** * Renewal without nodes: its settings, {@link SslContextProvider#renew(SSLContext)}, the count of unsuccessful renewals and the - * scheduler. Renewal is off by default. renew installs all new chains, an expired certificate included, only if the earliest - * expiry advances and the checks of reload pass; it refuses a cached context with new metadata, waits on the chains in use, - * ignores a replaced context and changes nothing when interrupted. Reload keeps the metadata renewal needs, renewal needs chains + * scheduler. Renewal is off by default. renew installs all new chains, even in place of an expired certificate. Its refusals + * follow the order of the checks in the code: it applies the checks of reload, waits while the factory returns the chains in + * use, refuses a cached context with new metadata and refuses chains that do not advance the earliest expiry. It ignores a + * replaced context and changes nothing when interrupted. Reload keeps the metadata renewal needs, renewal needs chains * to start, and unsuccessful renewals count until the earliest expiry advances, by renewal or reload. The scheduler tries first * at the window of the earliest chain, then no sooner than a minute after a success, backs off on failures and logs each one, * replans on reload, drops an outdated attempt and runs without the operator context. The log names the installed certificate, * sets the level of a failure by the time left and reports a wait once per lateness, again on expiry. Tests enable renewal on * a factory mock and script its bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. Only two - * tests wait: the operator context test runs on the executor of a node and waits for its attempt, and the last test waits for - * a certificate to expire. An appender on the logger of {@link SslRenewal} captures what renewal logs. + * tests wait: the operator context test runs on the kind of executor a node uses and waits for its attempt, and the last test + * waits for a certificate to expire. An appender on the logger of {@link SslRenewal} captures what renewal logs. */ public class SslRenewalTest extends GridCommonAbstractTest { /** */ @@ -177,33 +178,6 @@ public void testExpiredCertificateRenewed() throws Exception { assertEquals(Map.of("valid", chain(valid)), p.certificateChains()); } - /** - * renew refuses new chains unless the earliest expiry across them, authorities included, is later than the one in use: here - * another key with the same expiry, one of two keys renewed, and a later leaf from an authority that expires no later. - */ - @Test - public void testRenewalMustAdvanceEarliestExpiry() throws Exception { - long now = System.currentTimeMillis(); - String reason = "The new certificate chains expire no later than the ones in use"; - TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - HOUR, now + HOUR); - KeyStore a = ca.keyStoreWithPrivateKey("a", now - HOUR, now + HOUR); - KeyStore b = ca.keyStoreWithPrivateKey("b", now - HOUR, now + HOUR); - KeyStore renewedA = ca.keyStoreWithPrivateKey("a", now - HOUR, now + 2 * HOUR); - AbstractSslContextFactory f = renewingFactory(QUARTER); - - when(f.createSslBundle()).thenReturn( - bundle(merged(a, b), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("other", now - HOUR, now + HOUR), ca.trustStore()), - bundle(merged(renewedA, b), ca.trustStore()), - bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())); - - SslContextProvider p = new SslContextProvider(f); - - assertRenewalRejected("Another key with the same expiry", p, reason); - assertRenewalRejected("One of two keys renewed", p, reason); - assertRenewalRejected("A later leaf from an authority that expires no later", p, reason); - } - /** renew applies the checks of reload: here a candidate without a private key and one that is not valid yet are refused. */ @Test public void testRenewalAppliesReloadChecks() throws Exception { @@ -221,19 +195,6 @@ public void testRenewalAppliesReloadChecks() throws Exception { assertRenewalRejected("Not valid yet", p, "The new certificate chain is not valid now [alias=not-yet-valid, "); } - /** New metadata cannot make the context in use, which a caching factory returns again, serve a renewed certificate. */ - @Test - public void testChangedMetadataCannotRenewCachedContext() throws Exception { - long now = System.currentTimeMillis(); - SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()); - KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR); - AbstractSslContextFactory f = renewingFactory(QUARTER); - - when(f.createSslBundle()).thenReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))); - - assertRenewalRejected("Cached context", new SslContextProvider(f), "The SSL context factory returns the context in use"); - } - /** * The chains in use mean that the factory has nothing newer yet: renew returns {@code null} and counts an unsuccessful renewal, * but records no reload failure. Here a caching factory returns the bundle in use, and another factory returns the expired @@ -262,6 +223,46 @@ public void testUnchangedCertificatesAwaited() throws Exception { assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredProvider.reloadOutcome()); } + /** New metadata cannot make the context in use, which a caching factory returns again, serve a renewed certificate. */ + @Test + public void testChangedMetadataCannotRenewCachedContext() throws Exception { + long now = System.currentTimeMillis(); + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()); + KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); + + when(f.createSslBundle()).thenReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))); + + assertRenewalRejected("Cached context", new SslContextProvider(f), "The SSL context factory returns the context in use"); + } + + /** + * renew refuses new chains unless the earliest expiry across them, authorities included, is later than the one in use: here + * another key with the same expiry, one of two keys renewed, and a later leaf from an authority that expires no later. + */ + @Test + public void testRenewalMustAdvanceEarliestExpiry() throws Exception { + long now = System.currentTimeMillis(); + String reason = "The new certificate chains expire no later than the ones in use"; + TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - HOUR, now + HOUR); + KeyStore a = ca.keyStoreWithPrivateKey("a", now - HOUR, now + HOUR); + KeyStore b = ca.keyStoreWithPrivateKey("b", now - HOUR, now + HOUR); + KeyStore renewedA = ca.keyStoreWithPrivateKey("a", now - HOUR, now + 2 * HOUR); + AbstractSslContextFactory f = renewingFactory(QUARTER); + + when(f.createSslBundle()).thenReturn( + bundle(merged(a, b), ca.trustStore()), + bundle(ca.keyStoreWithPrivateKey("other", now - HOUR, now + HOUR), ca.trustStore()), + bundle(merged(renewedA, b), ca.trustStore()), + bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())); + + SslContextProvider p = new SslContextProvider(f); + + assertRenewalRejected("Another key with the same expiry", p, reason); + assertRenewalRejected("One of two keys renewed", p, reason); + assertRenewalRejected("A later leaf from an authority that expires no later", p, reason); + } + /** renew planned for a context that a reload has replaced does not call the factory and changes nothing. */ @Test public void testRenewForReplacedContextDoesNothing() throws Exception { @@ -613,8 +614,8 @@ public void testStaleAttemptNotRetried() throws Exception { } /** - * An attempt runs without the operation context of whoever started renewal, here an operator, even on the executor of a node, - * which passes the context of a submitter to its tasks. The factory sees the operator when the provider is created under it. + * An attempt runs without the operation context of whoever started renewal, here an operator, even on the kind of executor a + * node uses, which passes the context of a submitter to its tasks. The factory sees the operator when the provider is created under it. */ @Test public void testRenewalRunsWithoutOperatorContext() throws Exception { @@ -681,7 +682,7 @@ public void testRenewalLogsAppliedCertificate() throws Exception { /** * A failure is a warning while more than half of the window is left and an error once less is left, the retry a minute later - * comes after expiry or the certificates have expired. In each case the first failure is logged with its stack trace. + * comes after expiry or the certificates have expired. In each case the first failure is logged with its reason and stack trace. */ @Test public void testFailureLogLevelFollowsTimeLeft() throws Exception { @@ -829,6 +830,7 @@ private void assertFailureLogged(String desc, KeyStore inUse, double fraction, S assertEquals(desc, List.of(expected), problems.subList(before, problems.size())); assertNotNull(desc + ": stack trace", failure.getThrown()); + assertContains(log, failure.getMessage().getFormattedMessage(), ", initiator=automatic renewal, reason=" + ISSUER_DOWN + ", "); } /** @return Warnings and errors renewal logged, in log order, each as its level and its message without the details in brackets. */ From a1bb15fb55fc444908c596438695bdf3503ebbd8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:30:20 +0300 Subject: [PATCH 077/242] IGNITE-29209 Preserve aliases of keys with empty certificate chains --- .../org/apache/ignite/ssl/KeyMaterial.java | 14 +++++++--- .../java/org/apache/ignite/ssl/SslBundle.java | 9 +++--- .../ignite/ssl/SslContextMetadataTest.java | 25 +++++++++++++++-- .../TestCertificateAuthority.java | 28 +++++++++++++++++-- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index 2cfc501341b88..3b079c604d8f5 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -85,8 +85,8 @@ public KeyManager[] keyManagers() throws SSLException { } /** - * @return Immutable X.509 chains by private-key alias, own certificate first. An empty map means the store lists no private - * keys. JKS lists a private key stored without a chain as a secret key, so such a key is not included. An empty list + * @return Immutable X.509 chains by identified private-key alias, own certificate first. An empty map means no private-key + * entries were found. JKS lists a private key stored without a chain as a secret key, so such a key is not included. An empty list * retains an alias whose chain is absent or contains a non-X.509 certificate; key managers never present such a key. * @throws SSLException If the loaded store cannot be read. */ @@ -97,8 +97,14 @@ public Map> certificateChains() throws SSLExceptio for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { String alias = aliases.nextElement(); - if (store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) - chains.put(alias, certificateChain(store.getCertificateChain(alias))); + if (!store.isKeyEntry(alias)) + continue; + + Certificate[] chain = store.getCertificateChain(alias); + + // The JDK's JCEKS entry-type check reads the first certificate even for an empty chain. + if (chain != null || store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + chains.put(alias, certificateChain(chain)); } return Collections.unmodifiableMap(chains); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java index ee5206e5f0d6a..d637a8899c386 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -39,8 +39,8 @@ public final class SslBundle { /** * @param ctx Initialized SSL context. - * @param chains Chains by private-key alias, own certificate first. {@code null} means the aliases are unknown; - * an empty map means there are no private keys. An empty list means the alias has a private key but its chain is absent + * @param chains Chains by identified private-key alias, own certificate first. {@code null} means the aliases are unknown; + * an empty map means no private-key entries were found. An empty list means the alias has a private key but its chain is absent * or contains a non-X.509 certificate, so key managers never present that key. Trusted certificate entries and secret keys * must not be included. */ @@ -66,8 +66,9 @@ public SSLContext context() { } /** - * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private keys; - * an empty list means the alias's chain is absent or contains a non-X.509 certificate, so that key is never presented. + * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private-key + * entries were found. An empty list means the alias's chain is absent or contains a non-X.509 certificate, so that key is + * never presented. */ public @Nullable Map> certificateChains() { return chains; diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 8a1b632a144ff..8fd73e90ec28a 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -48,6 +48,7 @@ import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; import static org.apache.ignite.testframework.TestCertificateAuthority.trustManagers; import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.Mockito.atLeastOnce; @@ -172,6 +173,24 @@ public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { assertEquals(Map.of("private-key", List.of()), material.certificateChains()); } + /** Keys without a chain keep their aliases alongside usable keys in the context metadata. */ + @Test + public void testMissingChainRetainsPrivateKeyAlias() throws Exception { + String missingChainAlias = "key-without-chain"; + String usableAlias = "usable"; + KeyStore usable = ca.keyStoreWithPrivateKey(usableAlias); + + for (String type : List.of("JCEKS", "PKCS12")) { + KeyStore store = keyStoreWithoutCertificateChain(type, missingChainAlias); + + store.setKeyEntry(usableAlias, usable.getKey(usableAlias, PWD), PWD, usable.getCertificateChain(usableAlias)); + + assertNotNull(type, store.getKey(missingChainAlias, PWD)); + assertEquals(type, Map.of(missingChainAlias, List.of(), usableAlias, chain(usable)), + new InMemoryStoreFactory(store).createSslBundle().certificateChains()); + } + } + /** Material keeps its own copy of the key password, so clearing the caller's array does not lock the keys. */ @Test public void testLoadedMaterialCopiesKeyPassword() throws Exception { @@ -286,9 +305,11 @@ public void testKeyPasswordFailureNamesStore() throws Exception { factory.setKeyStoreFilePath("unreadable-key.jks"); - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, - "[type=JKS, keyStorePath=unreadable-key.jks]"); + String storeDetails = "[type=JKS, keyStorePath=unreadable-key.jks]"; + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); + assertTrue(err.getCause() instanceof SSLException); + assertTrue(err.getCause().getMessage().contains(storeDetails)); assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); } diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index fe3c9f829ae69..7dd13e7a8fbe1 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -17,10 +17,13 @@ package org.apache.ignite.testframework; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.math.BigInteger; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; +import java.security.KeyStoreSpi; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.security.spec.ECGenParameterSpec; @@ -43,8 +46,7 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; /** - * Issues JKS stores protected by {@link #PWD}. Key stores hold EC P-256 private keys with their certificate chains, and trust - * stores hold authority certificates. + * Creates test stores protected by {@link #PWD}. Private keys use EC P-256, and trust stores hold authority certificates. * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, * so the same material works for node-to-node TLS. */ @@ -106,6 +108,28 @@ public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { return store; } + /** + * @param type Store type. + * @param alias Private-key alias. + * @return Saved and reloaded store with a private key but no certificate chain, protected by {@link #PWD}. + */ + public static KeyStore keyStoreWithoutCertificateChain(String type, String alias) throws Exception { + KeyStore store = KeyStore.getInstance(type); + + // KeyStore rejects a missing chain on insertion; its provider can still store and load such a key. + KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", type).newInstance(null); + + spi.engineLoad(null, PWD); + spi.engineSetKeyEntry(alias, keyPair().getPrivate(), PWD, null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + spi.engineStore(out, PWD); + store.load(new ByteArrayInputStream(out.toByteArray()), PWD); + + return store; + } + /** @return Trust store with this authority alone. */ public KeyStore trustStore() throws Exception { KeyStore store = emptyStore(); From bbce6906ebbdc465a89e3535e9c9ec89fdc9ed59 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:31:18 +0300 Subject: [PATCH 078/242] IGNITE-29210 Verify CA overlap and reject missing certificate chains --- docs/_docs/security/ssl-tls.adoc | 6 +-- docs/_docs/tools/control-script.adoc | 6 +-- .../management/ssl/SslStatusTask.java | 2 +- .../internal/ssl/SslContextProvider.java | 11 ++--- .../internal/ssl/SslContextRegistry.java | 3 +- .../internal/ssl/SslContextProviderTest.java | 6 +-- .../ignite/ssl/SslContextReloadTest.java | 41 +++++++++++++++++++ 7 files changed, 59 insertions(+), 16 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 1278671b5d9b3..317d69895e100 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -183,7 +183,7 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects a store with no private keys. It also rejects a store with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can still start with such a store. The node then logs a warning, and `--ssl status` shows the problem. To fix the store, see the `no private keys` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates]. +With `SslContextFactory`, reload also rejects stores that report no private-key entries or contain a private key without an X.509 certificate chain. The node cannot present a key whose chain is missing or contains a non-X.509 certificate. A node can still start with such a store, but it logs a warning. Use the `no private-key entries reported` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. @@ -307,8 +307,8 @@ If the source provides a key store, override `loadKeyMaterial()` to return link: The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. -The bundle includes every private-key alias, even when the context can choose among several keys. -An empty map reports that there are no private keys. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. +The bundle includes every private-key entry reported by the store, even when the context can choose among several keys. +An empty map means no private-key entries are reported. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 6f6e1f86caae5..1c152db91266b 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1823,9 +1823,9 @@ Status does not test connections or verify that other nodes and clients trust th |`certificate metadata is unavailable` |The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. |`alias=...: certificate chain is absent or is not X.509` -|This private-key entry has no usable X.509 certificate chain. Add its X.509 chain or remove the unusable private-key entry, then run reload. Reload rejects a replacement that still contains such an entry. -|`no private keys` -|The loaded source contains no private key. Install the node's certificate and private key, then run reload. +|This private key has no certificate chain, or its chain contains a non-X.509 certificate. The node never presents such a key. Add its X.509 chain or remove the key, then run reload. +|`no private-key entries reported` +|Install the node's private key and its X.509 certificate chain in the configured key store, then run reload. |`no SSL context factory is configured` |This node has no certificates managed by these commands. This message does not make either command fail. |=== diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 4a35ed3261409..be329c9d790a5 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -80,7 +80,7 @@ private SslStatusJob(NoArg arg, boolean debug) { if (chains == null) lines.add(DOUBLE_INDENT + "certificate metadata is unavailable"); else if (chains.isEmpty()) - lines.add(DOUBLE_INDENT + "no private keys"); + lines.add(DOUBLE_INDENT + "no private-key entries reported"); else { for (Map.Entry> entry : chains.entrySet()) { List chain = entry.getValue(); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 9ddb1813c8c54..c5e29a7eaa416 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -95,8 +95,8 @@ private SslBundle createBundle() { } /** - * Builds and installs a new context for new connections, recording success or failure. Rejects key material known to have no - * private key or to contain a private key without an X.509 certificate chain. Every supplied certificate must be within its + * Builds and installs a new context for new connections, recording success or failure. Rejects key material that reports no + * private-key entries or contains a private key without an X.509 certificate chain. Every supplied certificate must be within its * validity period. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * @@ -129,7 +129,7 @@ public synchronized SslBundle reload() throws IgniteCheckedException { /** * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material has no private key or a key has no X.509 certificate chain. + * @throws IgniteCheckedException If the loaded material reports no private-key entries or a key has no X.509 certificate chain. */ private SslBundle build() throws IgniteCheckedException { SslBundle rebuilt = createBundle(); @@ -143,14 +143,15 @@ private SslBundle build() throws IgniteCheckedException { /** * @param chains Chains by private-key alias, {@code null} if the factory supplies no metadata. - * @throws IgniteCheckedException If there is no private key or a key has no X.509 certificate chain. + * @throws IgniteCheckedException If no private-key entries are reported or a key has no X.509 certificate chain. */ private static void checkKeyMaterial(@Nullable Map> chains) throws IgniteCheckedException { if (chains == null) return; if (chains.isEmpty()) - throw new IgniteCheckedException("The new key material has no private key, so the node would present no certificate"); + throw new IgniteCheckedException("The new key material reports no private-key entries, " + + "so the node would present no certificate"); for (Map.Entry> entry : chains.entrySet()) { if (entry.getValue().isEmpty()) { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index dc027838fb974..33af0082e6d09 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -85,7 +85,8 @@ private void warnIfUnusable(SslContextProvider provider) { return; if (chains.isEmpty()) - U.warn(log, "The key material has no private key, so the node presents no certificate; reload rejects such key material"); + U.warn(log, "The key material reports no private-key entries, so the node presents no certificate; " + + "reload rejects such key material"); for (Map.Entry> entry : chains.entrySet()) { if (entry.getValue().isEmpty()) { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index a7ef2fc8ca7f1..3e9430d3c360a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -156,7 +156,7 @@ public void testKeyMaterialWithoutUsableKeyNotApplied() throws Exception { SslContextProvider p = new SslContextProvider(f); - assertReloadRejected(p, "The new key material has no private key, so the node would present no certificate"); + assertReloadRejected(p, "The new key material reports no private-key entries, so the node would present no certificate"); assertReloadRejected(p, "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate " + "chain [alias=z-no-chain]"); } @@ -248,8 +248,8 @@ public void testRegistryWarnsAboutKeysReloadRefuses() throws Exception { when(noChain.createSslBundle()).thenReturn(new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); ListeningTestLogger registryLog = new ListeningTestLogger(log); - LogListener noKeyWarned = LogListener.matches("The key material has no private key, so the node presents no certificate; " + - "reload rejects such key material").times(1).build(); + LogListener noKeyWarned = LogListener.matches("The key material reports no private-key entries, " + + "so the node presents no certificate; reload rejects such key material").times(1).build(); LogListener noChainWarned = LogListener.matches("The private key has no X.509 certificate chain, so the node never presents " + "it; reload rejects this key store until the key is removed or its X.509 certificate chain is provided " + "[alias=z-no-chain]").times(1).build(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 9dacc222a9743..b8d0a505812f0 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -77,6 +77,7 @@ import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; +import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; import static org.apache.ignite.testframework.TestCertificateAuthority.save; import static org.mockito.Mockito.mock; @@ -335,6 +336,33 @@ public void testRejectedReloadReported() throws Exception { "\\S+: " + Pattern.quote(reason)).matcher(status).find()); } + /** A real PKCS12 replacement with a private key but no certificate chain is refused, and new connections keep the old certificate. */ + @Test + public void testKeyWithoutCertificateChainNotApplied() throws Exception { + String name = getTestIgniteInstanceName(0); + String alias = "key-without-chain"; + String storeType = "PKCS12"; + KeyStore startupKeys = KeyStore.getInstance(storeType); + + startupKeys.load(null, null); + startupKeys.setKeyEntry(OLD_KEY, oldKeys.getKey(OLD_KEY, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + oldKeys.getCertificateChain(OLD_KEY)); + + save(startupKeys, keyFile(name)); + + IgniteEx g = startGrid(0, cfg -> ((SslContextFactory)cfg.getSslContextFactory()).setKeyStoreType(storeType)); + KeyStore replacement = keyStoreWithoutCertificateChain(storeType, alias); + + assertTrue(replacement.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)); + assertNull(replacement.getCertificateChain(alias)); + + save(replacement, keyFile(name)); + + assertContains(log, reloadFailure(g), "The private key has no X.509 certificate chain; remove the key or provide its " + + "X.509 certificate chain [alias=" + alias + ']'); + assertEquals(oldCert, servedCertificate(context(oldKeys, trustBoth), discoveryPort(g))); + } + /** Both commands report a node that is unavailable when jobs are mapped and still run on the other nodes. */ @Test public void testUnavailableNodeReported() throws Exception { @@ -443,6 +471,7 @@ public void testWithoutSsl() throws Exception { /** * The authority is replaced with three reloads: trust both authorities, present a key of the new one, trust only the new one. + * After the first reload, clients of both authorities connect while the node still presents its old certificate. * Then the discovery, communication, client connector and binary REST ports accept a client of the new authority and refuse * one of the old authority and one without a certificate, a reopened communication connection works, and a node of the new * authority joins. @@ -460,6 +489,18 @@ public void testCertificateAuthorityReplaced() throws Exception { placeTrust(trustBoth); reload(g0); + + for (KeyStore keys : List.of(oldKeys, newKeys)) { + SSLContext clientCtx = context(keys, trustBoth); + + try (IgniteClient cli = Ignition.startClient(new ClientConfiguration() + .setAddresses("127.0.0.1:" + g0.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED).setSslContextFactory(() -> clientCtx) + .setPartitionAwarenessEnabled(false).setClusterDiscoveryEnabled(false))) { + cli.cacheNames(); + } + } + placeKeys(newKeys); reload(g0); placeTrust(newCa.trustStore()); From fa20c772aa2e96f32a1f1167dbd12d5636bedafe Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:35:10 +0300 Subject: [PATCH 079/242] IGNITE-29210 Clarify that bundle metadata contains aliases and chains --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 317d69895e100..ab15a1120f382 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -307,7 +307,7 @@ If the source provides a key store, override `loadKeyMaterial()` to return link: The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. -The bundle includes every private-key entry reported by the store, even when the context can choose among several keys. +The bundle includes the alias and certificate chain of every private-key entry reported by the store, even when the context can choose among several keys. An empty map means no private-key entries are reported. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. From 878ae6a97d0e08fd7a4c202b5d59b3be2e108d49 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:35:11 +0300 Subject: [PATCH 080/242] IGNITE-29211 Control expiry time in renewal logging test --- docs/_docs/security/ssl-tls.adoc | 6 ++-- .../ignite/internal/ssl/SslRenewal.java | 29 +++++++++++++++++-- .../ignite/internal/ssl/SslRenewalTest.java | 23 ++++++++------- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 176026cf9c5e5..0965bfdde5388 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -184,7 +184,7 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects a store with no private keys. It also rejects a store with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can still start with such a store. The node then logs a warning, and `--ssl status` shows the problem. To fix the store, see the `no private keys` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates]. +With `SslContextFactory`, reload also rejects a store with no private keys. It also rejects a store with a private key whose certificate chain is missing or contains a non-X.509 certificate. The node never presents such a key. A node can still start with such a store if automatic renewal is disabled. The node then logs a warning, and `--ssl status` shows the problem. To fix the store, see the `no private keys` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates]. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. @@ -264,8 +264,8 @@ For that chain, the renewal window is the last share of its lifetime set by the The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. -With `SslContextFactory`, include the X.509 chain of every private key in the key store. A custom factory must supply all these chains with each context. -Without complete chains, a node with automatic renewal enabled cannot start because it cannot determine when renewal is due. +With `SslContextFactory`, the key store must contain at least one private-key entry and the complete X.509 chain of every private key. A custom factory must supply at least one chain and include the chain of every private key with each context. +Without a private-key chain, or if any chain is incomplete, a node with automatic renewal enabled cannot start because it cannot determine when renewal is due. Ignite replaces the entire context at once. All replacement chains must be valid, but their aliases need not match the old ones. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java index c5681f309fb4d..48a487de8124a 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -25,6 +25,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.thread.context.OperationContext; @@ -65,6 +66,9 @@ class SslRenewal { /** */ private final IgniteLogger log; + /** Current time in milliseconds. */ + private final LongSupplier currentTimeMillis; + /** Last planned attempt, {@code null} before the first one. */ private ScheduledFuture next; @@ -93,10 +97,28 @@ class SslRenewal { * @param log Logger. */ SslRenewal(SslContextProvider provider, double renewBeforeFraction, ScheduledExecutorService exec, IgniteLogger log) { + this(provider, renewBeforeFraction, exec, log, System::currentTimeMillis); + } + + /** + * @param provider Provider whose context the factory builds. + * @param renewBeforeFraction Share of the certificate lifetime left when the renewal window opens. + * @param exec Renewal thread. + * @param log Logger. + * @param currentTimeMillis Current time in milliseconds. + */ + SslRenewal( + SslContextProvider provider, + double renewBeforeFraction, + ScheduledExecutorService exec, + IgniteLogger log, + LongSupplier currentTimeMillis + ) { this.provider = provider; this.renewBeforeFraction = renewBeforeFraction; this.exec = exec; this.log = log; + this.currentTimeMillis = currentTimeMillis; } /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ @@ -122,7 +144,8 @@ private void plan() { window = (long)(lifetime * renewBeforeFraction); - long at = Math.max(chainNotAfter - window, Math.max(provider.reloadOutcome().successTime() + minPause, System.currentTimeMillis())); + long at = Math.max(chainNotAfter - window, + Math.max(provider.reloadOutcome().successTime() + minPause, currentTimeMillis.getAsLong())); schedule(at, planned.context()); @@ -163,7 +186,7 @@ private void retry(SSLContext planned, @Nullable Throwable e) { if (exec.isShutdown()) return; - long now = System.currentTimeMillis(); + long now = currentTimeMillis.getAsLong(); pause = pause == 0 ? minPause : Math.min(pause * 2, maxPause); @@ -205,7 +228,7 @@ private void schedule(long at, SSLContext planned) { nextRenewalTime = at; - next = submit(() -> attempt(planned), Math.max(0, at - System.currentTimeMillis())); + next = submit(() -> attempt(planned), Math.max(0, at - currentTimeMillis.getAsLong())); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index fdb0d4d416a99..17a983062f949 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -31,6 +31,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; @@ -76,9 +77,9 @@ * replans on reload, drops an outdated attempt and runs without the operator context. The log names the installed certificate, * sets the level of a failure by the time left and reports a wait once per lateness, again on expiry. Tests enable renewal on * a factory mock and script its bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in - * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. Only two - * tests wait: the operator context test runs on the kind of executor a node uses and waits for its attempt, and the last test - * waits for a certificate to expire. An appender on the logger of {@link SslRenewal} captures what renewal logs. + * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. Only one + * test waits: the operator context test runs on the kind of executor a node uses and waits for its attempt. The expiry test + * advances the scheduler's clock explicitly. An appender on the logger of {@link SslRenewal} captures what renewal logs. */ public class SslRenewalTest extends GridCommonAbstractTest { /** */ @@ -727,25 +728,27 @@ public void testWaitLoggedOncePerLateness() throws Exception { } /** - * A wait logged as expiring soon is logged again once the certificates have expired, and not after that. The certificate expires - * within 3 seconds, which the test waits for. + * A wait logged as expiring soon is logged again once the certificates have expired, and not after that. */ @Test public void testWaitLoggedAgainOnExpiry() throws Exception { - // X.509 keeps whole seconds. With 2 to 3 seconds left, the first attempt comes before expiry even on a slow machine. - long notAfter = (System.currentTimeMillis() / SECOND + 3) * SECOND; + KeyStore inUse = ca.keyStoreWithPrivateKey(); + long notAfter = chain(inUse).get(0).getNotAfter().getTime(); + + // Hold the scheduler's clock before expiry until the test advances it. + AtomicLong now = new AtomicLong(notAfter - SECOND); AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(bundle(ca.keyStoreWithPrivateKey("in-use", notAfter - 2 * HOUR, notAfter), ca.trustStore())); + when(f.createSslBundle()).thenReturn(bundle(inUse, ca.trustStore())); SslContextProvider p = new SslContextProvider(f); - p.startRenewal(exec, renewalLog); + new SslRenewal(p, QUARTER, exec, renewalLog, now::get).replan(); exec.runNext(); exec.runNext(); - assertTrue(waitForCondition(() -> System.currentTimeMillis() >= notAfter, 10_000)); + now.set(notAfter); exec.runNext(); exec.runNext(); From 4d7ccc7953ca909044c9c797323c8a18ff332ec8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:39:03 +0300 Subject: [PATCH 081/242] IGNITE-29210 Disambiguate test node configuration callback --- .../test/java/org/apache/ignite/ssl/SslContextReloadTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index b8d0a505812f0..97b051eac04d5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -350,7 +350,9 @@ public void testKeyWithoutCertificateChainNotApplied() throws Exception { save(startupKeys, keyFile(name)); - IgniteEx g = startGrid(0, cfg -> ((SslContextFactory)cfg.getSslContextFactory()).setKeyStoreType(storeType)); + IgniteEx g = startGrid(0, cfg -> { + ((SslContextFactory)cfg.getSslContextFactory()).setKeyStoreType(storeType); + }); KeyStore replacement = keyStoreWithoutCertificateChain(storeType, alias); assertTrue(replacement.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)); From b34c6db981fd90fb2a4586a4a0fa8fc074dda11f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:51:54 +0300 Subject: [PATCH 082/242] IGNITE-29210 Describe validation of reported key entries --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index ab15a1120f382..cb4d049d39754 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -183,7 +183,7 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects stores that report no private-key entries or contain a private key without an X.509 certificate chain. The node cannot present a key whose chain is missing or contains a non-X.509 certificate. A node can still start with such a store, but it logs a warning. Use the `no private-key entries reported` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. +With `SslContextFactory`, reload also rejects stores that report no private-key entries or report a private-key entry without an X.509 certificate chain. The node cannot present a key whose chain is missing or contains a non-X.509 certificate. A node can still start with such a store, but it logs a warning. Use the `no private-key entries reported` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. From 077f64a5c6e9b6b46b235f12244c477d3a294d8a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:52:27 +0300 Subject: [PATCH 083/242] IGNITE-29211 Clarify certificate requirements at startup --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b1b1d27228f54..af829e21057d6 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -265,7 +265,7 @@ The delivery system must make a valid replacement available before the window op The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. With `SslContextFactory`, the key store must contain at least one private-key entry and the complete X.509 chain of every private key. A custom factory must supply at least one chain and include the chain of every private key with each context. -Without a private-key chain, or if any chain is incomplete, a node with automatic renewal enabled cannot start because it cannot determine when renewal is due. +A node with automatic renewal enabled cannot start unless the factory reports at least one private-key entry and supplies a nonempty X.509 chain for every reported entry. Ignite replaces the entire context at once. All replacement chains must be valid, but their aliases need not match the old ones. From 1c1d9c9b5699b67d12c7993a743d3b8901a85888 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:31:50 +0300 Subject: [PATCH 084/242] IGNITE-29211 Renew reported TLS certificate chains --- docs/_docs/security/ssl-tls.adoc | 7 +-- .../ignite/ssl/AbstractSslContextFactory.java | 6 +-- .../internal/ssl/SslContextProvider.java | 30 ++++------- .../internal/ssl/SslRenewalNodeTest.java | 2 +- .../ignite/internal/ssl/SslRenewalTest.java | 51 +++++++++++-------- 5 files changed, 48 insertions(+), 48 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index af829e21057d6..d5674b5de0828 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -258,16 +258,17 @@ For manual file replacement, use <> and run reload after === Prepare Certificate Delivery -Renewal follows the first certificate to expire across all private-key chains, including CA certificates. +Renewal follows the first certificate to expire across the TLS certificate chains loaded by the factory, including CA certificates. For that chain, the renewal window is the last share of its lifetime set by the factory property `renewBeforeFraction`. The lifetime runs from the leaf certificate's start date to the earliest expiry in the chain. Ignite starts trying to load replacement certificates when the renewal window opens. If several chains expire at exactly the same time, renewal starts when the first of their windows opens. The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. -With `SslContextFactory`, the key store must contain at least one private-key entry and the complete X.509 chain of every private key. A custom factory must supply at least one chain and include the chain of every private key with each context. -A node with automatic renewal enabled cannot start unless the factory reports at least one private-key entry and supplies a nonempty X.509 chain for every reported entry. +Provide at least one private key with its X.509 certificate chain. `SslContextFactory` ignores entries whose chains are missing, empty or contain a non-X.509 certificate. A custom factory must supply the TLS certificate chains used by its context. +A node with automatic renewal enabled cannot start if the factory supplies no chains or cannot report them. Ignite replaces the entire context at once. All replacement chains must be valid, but their aliases need not match the old ones. +If an entry loses its X.509 chain, it is treated as removed. The remaining chains must still advance the earliest expiry for renewal to apply the replacement. Nodes and applications must trust the replacement certificates before they are loaded. For a new CA, follow the stages in <>. When renewal installs a replacement certificate, `SslContextFactory` also reloads its trust store. Keep the old CA in the trust store until every affected node and application has switched certificates. Otherwise, the renewed node cannot connect to nodes that still use certificates from the old CA. To apply a trust store change without replacing the certificate, run `control.sh --ssl reload`. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index a93fe8fded3ab..fe61430786a99 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -121,8 +121,8 @@ public boolean isRenewalEnabled() { /** * Sets whether a node calls {@link #createSslBundle()} before the certificates expire to pick up replacements. * A file-based factory rereads its files. It does not replace them. - * When enabled, the factory must provide at least one private key and a nonempty X.509 chain for every private key, or the node - * does not start. Disabled by default. + * When enabled, the factory must report at least one X.509 certificate chain, or the node does not start. + * Renewal considers all reported chains. Disabled by default. * Set this before starting the node; changing it on a running node takes effect after a restart. * * @param renewalEnabled Whether renewal is enabled. @@ -138,7 +138,7 @@ public double getRenewBeforeFraction() { /** * Sets the share of the certificate lifetime left when a node starts to renew the certificate. The lifetime runs from the time - * the leaf certificate becomes valid to the earliest expiry in its chain. For several private keys, the chain with the earliest + * the leaf certificate becomes valid to the earliest expiry in its chain. Among the reported chains, the one with the earliest * expiry determines renewal; ties use the earliest leaf validity start. The value must be greater than {@code 0} and less than * {@code 1}, otherwise this method throws {@link IllegalArgumentException}. * If not specified, {@link #DFLT_RENEW_BEFORE_FRACTION} is used. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index da8391251338f..72f0031ff16ce 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -125,15 +125,8 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { if (renewBeforeFraction == 0) return; - try { - checkKeyMaterial(bundle.certificateChains()); - } - catch (IgniteCheckedException e) { - throw new IgniteException(e.getMessage(), e); - } - if (renewalChain(bundle) == null) { - throw new IgniteException("The SSL context factory must provide a nonempty certificate chain for every private key " + + throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + "to renew automatically [transports=" + transports() + ']'); } @@ -182,8 +175,9 @@ public synchronized SslBundle reload() throws IgniteCheckedException { * * @param expected Context the renewal was planned for. * @return Installed bundle, or {@code null} if its chains are unchanged or the planned context had already been replaced. - * @throws IgniteCheckedException If any chain is unknown, fails the checks of {@link #reload()} or the earliest expiry - * does not advance, or if the thread is interrupted by the time the factory returns; the interrupt flag then stays set. + * @throws IgniteCheckedException If certificate metadata is unknown, the bundle fails the checks of {@link #reload()}, + * or the earliest expiry does not advance, or if the thread is interrupted by the time the factory returns; + * the interrupt flag then stays set. */ synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { if (bundle.context() != expected) @@ -251,7 +245,7 @@ private void putInUse(SslBundle rebuilt) { * Chooses the first chain to expire, using the earliest leaf validity start to break ties. * * @param bundle Context and its chains. - * @return Chain that determines renewal, or {@code null} unless every private key has a nonempty X.509 chain. + * @return Chain that determines renewal, or {@code null} if certificate metadata is unknown or contains no chains. */ static @Nullable List renewalChain(SslBundle bundle) { Map> chains = bundle.certificateChains(); @@ -263,9 +257,6 @@ private void putInUse(SslBundle rebuilt) { long earliestExpiry = Long.MAX_VALUE; for (List chain : chains.values()) { - if (chain.isEmpty()) - return null; - long expiry = chainNotAfter(chain); if (earliest == null || expiry < earliestExpiry || @@ -314,16 +305,15 @@ private static void checkKeyMaterial(@Nullable Map /** * @param next Bundle loaded by the factory. - * @param requireChains Whether renewal requires known chains for every private key. - * @throws IgniteCheckedException If a required chain is missing or a certificate is outside its validity period. + * @param requireChains Whether at least one reported chain is required for renewal. + * @throws IgniteCheckedException If required certificate metadata is unknown or empty, or a certificate is outside its validity period. */ private void check(SslBundle next, boolean requireChains) throws IgniteCheckedException { - if (requireChains && renewalChain(next) == null) - throw new IgniteCheckedException("The new SSL context must provide a nonempty certificate chain for every private key " + - "to renew automatically"); - Map> chains = next.certificateChains(); + if (requireChains && (chains == null || chains.isEmpty())) + throw new IgniteCheckedException("The new SSL context must provide at least one X.509 certificate chain to renew automatically"); + if (chains == null) return; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index cf44edae23514..dd593b901c852 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -122,7 +122,7 @@ public void testUnknownCertificateRefusedAtStart() throws Exception { GridTestUtils.assertThrowsAnyCause(log, () -> startGrid(0, cfg -> { cfg.setSslContextFactory(factory); - }), IgniteException.class, "The SSL context factory must provide a nonempty certificate chain for every private key"); + }), IgniteException.class, "The SSL context factory must provide at least one X.509 certificate chain"); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index d3a39544f1322..2ddeb03d8c9f0 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -161,6 +161,23 @@ public void testRenewInstallsAllNewChains() throws Exception { assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), p.certificateChains()); } + /** Removing the first-expiring chain advances the earliest expiry when the remaining chain expires later. */ + @Test + public void testRenewalCanRemoveEarliestChain() throws Exception { + long now = System.currentTimeMillis(); + KeyStore removed = ca.keyStoreWithPrivateKey("removed", now - HOUR, now + HOUR); + KeyStore remaining = ca.keyStoreWithPrivateKey("remaining", now - HOUR, now + 2 * HOUR); + SslBundle replacement = bundle(remaining, ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + + when(f.createSslBundle()).thenReturn(bundle(merged(removed, remaining), ca.trustStore()), replacement); + + SslContextProvider p = new SslContextProvider(f); + + assertSame(replacement, p.renew(p.context())); + assertEquals(Map.of("remaining", chain(remaining)), p.certificateChains()); + } + /** renew replaces an expired certificate in use with a valid one. */ @Test public void testExpiredCertificateRenewed() throws Exception { @@ -179,7 +196,7 @@ public void testExpiredCertificateRenewed() throws Exception { assertEquals(Map.of("valid", chain(valid)), p.certificateChains()); } - /** renew applies the checks of reload: here a candidate without a private key and one that is not valid yet are refused. */ + /** renew applies the checks of reload: here a candidate without certificate chains and one that is not valid yet are refused. */ @Test public void testRenewalAppliesReloadChecks() throws Exception { long now = System.currentTimeMillis(); @@ -192,8 +209,8 @@ public void testRenewalAppliesReloadChecks() throws Exception { SslContextProvider p = new SslContextProvider(f); - assertRenewalRejected("No private key", p, - "The new key material reports no private-key entries, so the node would present no certificate"); + assertRenewalRejected("No certificate chains", p, + "The new key material has no X.509 certificate chains, so the node would present no certificate"); assertRenewalRejected("Not valid yet", p, "The new certificate chain is not valid now [alias=not-yet-valid, "); } @@ -344,7 +361,7 @@ public void testInterruptedRenewalChangesNothing() throws Exception { */ @Test public void testReloadCannotLoseMetadataWithRenewal() throws Exception { - String reason = "The new SSL context must provide a nonempty certificate chain for every private key to renew automatically"; + String reason = "The new SSL context must provide at least one X.509 certificate chain to renew automatically"; SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()); SslBundle unknown = new SslBundle(context(ca.keyStoreWithPrivateKey("unknown"), ca.trustStore()), null); AbstractSslContextFactory f = renewingFactory(QUARTER); @@ -361,31 +378,23 @@ public void testReloadCannotLoseMetadataWithRenewal() throws Exception { assertRenewalRejected("Renewal", p, reason); } - /** Renewal does not start without a nonempty chain for every private key: no metadata, no private key, a key without a chain. */ + /** Renewal does not start when certificate metadata is unknown or contains no chains. */ @Test public void testRenewalRefusedToStartWithoutChains() throws Exception { - KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); - SSLContext ctx = context(valid, ca.trustStore()); + SSLContext ctx = context(ca.keyStoreWithPrivateKey(), ca.trustStore()); AbstractSslContextFactory unknown = renewingFactory(QUARTER); - AbstractSslContextFactory noKey = renewingFactory(QUARTER); - AbstractSslContextFactory noChain = renewingFactory(QUARTER); + AbstractSslContextFactory noChains = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); + String reason = "The SSL context factory must provide at least one X.509 certificate chain to renew automatically"; when(unknown.createSslBundle()).thenReturn(new SslBundle(ctx, null)); - when(noKey.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); - // Aliases are sorted, so "z-" puts the key without a chain after a valid one: the check must go past the first key. - when(noChain.createSslBundle()).thenReturn(new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); + when(noChains.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); SslContextProvider unknownProvider = new SslContextProvider(unknown); - SslContextProvider noKeyProvider = new SslContextProvider(noKey); - SslContextProvider noChainProvider = new SslContextProvider(noChain); - - GridTestUtils.assertThrows(log, () -> unknownProvider.startRenewal(exec, renewalLog), IgniteException.class, - "The SSL context factory must provide a nonempty certificate chain for every private key to renew automatically"); - GridTestUtils.assertThrows(log, () -> noKeyProvider.startRenewal(exec, renewalLog), IgniteException.class, - "The new key material reports no private-key entries, so the node would present no certificate"); - GridTestUtils.assertThrows(log, () -> noChainProvider.startRenewal(exec, renewalLog), IgniteException.class, - "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate chain [alias=z-no-chain]"); + SslContextProvider noChainsProvider = new SslContextProvider(noChains); + + GridTestUtils.assertThrows(log, () -> unknownProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); + GridTestUtils.assertThrows(log, () -> noChainsProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); } /** Both the chains in use and a factory failure count as unsuccessful renewals, and a renewal clears the count. */ From 5f0c03fe35ffb20d29f26203b56938a87fa6238b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:37:36 +0300 Subject: [PATCH 085/242] IGNITE-29209 Report only nonempty X.509 key chains --- .../org/apache/ignite/ssl/KeyMaterial.java | 13 ++-- .../java/org/apache/ignite/ssl/SslBundle.java | 17 +++-- .../ignite/ssl/SslContextMetadataTest.java | 76 +++++++------------ 3 files changed, 45 insertions(+), 61 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index 3b079c604d8f5..565997946062b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -85,9 +85,9 @@ public KeyManager[] keyManagers() throws SSLException { } /** - * @return Immutable X.509 chains by identified private-key alias, own certificate first. An empty map means no private-key - * entries were found. JKS lists a private key stored without a chain as a secret key, so such a key is not included. An empty list - * retains an alias whose chain is absent or contains a non-X.509 certificate; key managers never present such a key. + * @return Immutable, nonempty X.509 chains of key entries, ordered by alias and with each key's own certificate first. + * Entries without a chain or with a non-X.509 certificate are omitted. An empty map means no such chains were found. + * Reading the chains does not recover the keys. * @throws SSLException If the loaded store cannot be read. */ public Map> certificateChains() throws SSLException { @@ -100,11 +100,10 @@ public Map> certificateChains() throws SSLExceptio if (!store.isKeyEntry(alias)) continue; - Certificate[] chain = store.getCertificateChain(alias); + List chain = certificateChain(store.getCertificateChain(alias)); - // The JDK's JCEKS entry-type check reads the first certificate even for an empty chain. - if (chain != null || store.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) - chains.put(alias, certificateChain(chain)); + if (!chain.isEmpty()) + chains.put(alias, chain); } return Collections.unmodifiableMap(chains); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java index d637a8899c386..f15f4ccfb61ed 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -39,10 +39,10 @@ public final class SslBundle { /** * @param ctx Initialized SSL context. - * @param chains Chains by identified private-key alias, own certificate first. {@code null} means the aliases are unknown; - * an empty map means no private-key entries were found. An empty list means the alias has a private key but its chain is absent - * or contains a non-X.509 certificate, so key managers never present that key. Trusted certificate entries and secret keys + * @param chains Nonempty X.509 chains of key entries, by alias, own certificate first. {@code null} means certificate + * metadata is unavailable; an empty map means there are no chains. Trusted certificate entries and secret keys * must not be included. + * @throws IllegalArgumentException If a chain is empty. */ public SslBundle(SSLContext ctx, @Nullable Map> chains) { A.notNull(ctx, "ctx"); @@ -54,7 +54,11 @@ public SslBundle(SSLContext ctx, @Nullable Map> ch else { Map> copy = new TreeMap<>(); - chains.forEach((alias, chain) -> copy.put(alias, List.copyOf(chain))); + chains.forEach((alias, chain) -> { + A.ensure(!chain.isEmpty(), "Certificate chain must not be empty [alias=" + alias + ']'); + + copy.put(alias, List.copyOf(chain)); + }); this.chains = Collections.unmodifiableMap(copy); } @@ -66,9 +70,8 @@ public SSLContext context() { } /** - * @return Immutable chains ordered by alias, or {@code null} if the aliases are unknown. An empty map means no private-key - * entries were found. An empty list means the alias's chain is absent or contains a non-X.509 certificate, so that key is - * never presented. + * @return Immutable, nonempty chains ordered by alias, or {@code null} if certificate metadata is unavailable. + * An empty map means there are no chains. */ public @Nullable Map> certificateChains() { return chains; diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 8fd73e90ec28a..2938e453c9f76 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -57,10 +57,10 @@ /** * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, - * and checks parameters before reading. {@link KeyMaterial} reports private keys only, each with its own chain (an empty store - * gives an empty map, a key whose chain contains a non-X.509 certificate keeps its alias with an empty chain), and keeps its - * own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}. Custom key managers take - * part in handshakes and cannot be {@code null}. {@link SslContextFactory} names the store whose key it cannot open. + * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering + * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, + * and rejects empty per-alias chains. Custom key managers take part in handshakes and cannot be {@code null}. + * {@link SslContextFactory} names the store whose key it cannot open. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -127,25 +127,29 @@ public void testInvalidParametersDoNotLoadStore() throws Exception { assertEquals(0, factory.loads); } - /** Metadata lists every private key with its own chain and skips trusted certificates and secret keys. */ + /** Metadata includes only nonempty X.509 chains of key entries, without recovering the keys. */ @Test - public void testOnlyPrivateKeysWithTheirChains() throws Exception { - KeyStore first = ca.keyStoreWithPrivateKey("first-key"); - KeyStore second = ca.keyStoreWithPrivateKey("second-key"); + public void testOnlyKeyEntriesWithX509Chains() throws Exception { + String firstAlias = "first-key"; + String secondAlias = "second-key"; + KeyStore first = ca.keyStoreWithPrivateKey(firstAlias); + KeyStore second = ca.keyStoreWithPrivateKey(secondAlias); KeyStore.PasswordProtection prot = new KeyStore.PasswordProtection(PWD); // JCEKS can hold both private keys and secret keys. - KeyStore store = KeyStore.getInstance("JCEKS"); + KeyStore store = keyStoreWithoutCertificateChain("JCEKS", "key-without-chain"); - store.load(null, PWD); - store.setEntry("first-key", first.getEntry("first-key", prot), prot); - store.setEntry("second-key", second.getEntry("second-key", prot), prot); - store.setCertificateEntry("trusted", first.getCertificate("first-key")); - store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), prot); + store.setEntry(firstAlias, first.getEntry(firstAlias, prot), prot); + store.setEntry(secondAlias, second.getEntry(secondAlias, prot), prot); + store.setCertificateEntry("trusted", first.getCertificate(firstAlias)); + store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), + new KeyStore.PasswordProtection("unrelated-password".toCharArray())); + store.setKeyEntry("non-x509-chain", first.getKey(firstAlias, PWD), PWD, + new Certificate[] {first.getCertificate(firstAlias), mock(Certificate.class)}); KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); - assertEquals(Map.of("first-key", chain(first), "second-key", chain(second)), material.certificateChains()); + assertEquals(Map.of(firstAlias, chain(first), secondAlias, chain(second)), material.certificateChains()); } /** An empty key store yields a context without private keys and an empty map. */ @@ -160,37 +164,6 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { assertEquals(Map.of(), bundle.certificateChains()); } - /** A private key whose chain contains a non-X.509 certificate keeps its alias with an empty chain. */ - @Test - public void testNonX509ChainRetainsPrivateKeyAlias() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("private-key"); - - store.setKeyEntry("private-key", store.getKey("private-key", PWD), PWD, - new Certificate[] {store.getCertificate("private-key"), mock(Certificate.class)}); - - KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); - - assertEquals(Map.of("private-key", List.of()), material.certificateChains()); - } - - /** Keys without a chain keep their aliases alongside usable keys in the context metadata. */ - @Test - public void testMissingChainRetainsPrivateKeyAlias() throws Exception { - String missingChainAlias = "key-without-chain"; - String usableAlias = "usable"; - KeyStore usable = ca.keyStoreWithPrivateKey(usableAlias); - - for (String type : List.of("JCEKS", "PKCS12")) { - KeyStore store = keyStoreWithoutCertificateChain(type, missingChainAlias); - - store.setKeyEntry(usableAlias, usable.getKey(usableAlias, PWD), PWD, usable.getCertificateChain(usableAlias)); - - assertNotNull(type, store.getKey(missingChainAlias, PWD)); - assertEquals(type, Map.of(missingChainAlias, List.of(), usableAlias, chain(usable)), - new InMemoryStoreFactory(store).createSslBundle().certificateChains()); - } - } - /** Material keeps its own copy of the key password, so clearing the caller's array does not lock the keys. */ @Test public void testLoadedMaterialCopiesKeyPassword() throws Exception { @@ -228,7 +201,16 @@ public void testBundleKeepsSortedImmutableCopy() throws Exception { GridTestUtils.assertThrows(log, () -> chains.get("a").set(0, bCert), UnsupportedOperationException.class, null); } - /** Unknown metadata stays {@code null}, so it is not mistaken for a store without private keys. */ + /** Custom factories must omit entries without a chain instead of reporting an empty chain. */ + @Test + public void testBundleRejectsEmptyChain() throws Exception { + String alias = "missing-chain"; + + GridTestUtils.assertThrows(log, () -> new SslBundle(SSLContext.getDefault(), Map.of(alias, List.of())), + IllegalArgumentException.class, "Certificate chain must not be empty [alias=" + alias + ']'); + } + + /** Unknown metadata stays {@code null}, so it is not mistaken for a store without certificate chains. */ @Test public void testBundleKeepsUnknownMetadata() throws Exception { assertNull(new SslBundle(SSLContext.getDefault(), null).certificateChains()); From 25bb76d0bd825bf95ee16fe6edd76241abaee818 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:38:29 +0300 Subject: [PATCH 086/242] IGNITE-29210 Validate available TLS certificate chains --- docs/_docs/security/ssl-tls.adoc | 10 ++-- docs/_docs/tools/control-script.adoc | 8 ++- .../management/ssl/SslStatusTask.java | 8 +-- .../ignite/internal/ssl/SslCertificates.java | 5 +- .../internal/ssl/SslContextProvider.java | 34 +++---------- .../internal/ssl/SslContextRegistry.java | 9 +--- .../internal/ssl/SslContextProviderTest.java | 50 ++++++------------- .../ignite/ssl/SslContextReloadTest.java | 23 +++++++-- 8 files changed, 53 insertions(+), 94 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index cb4d049d39754..924a8af7f32ff 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -183,7 +183,9 @@ Reload checks the dates of the certificate chains supplied by the factory. If an A custom factory can return a context without certificate information. Status then reports `certificate metadata is unavailable`, and reload cannot check certificate dates. -With `SslContextFactory`, reload also rejects stores that report no private-key entries or report a private-key entry without an X.509 certificate chain. The node cannot present a key whose chain is missing or contains a non-X.509 certificate. A node can still start with such a store, but it logs a warning. Use the `no private-key entries reported` and `alias=...` rows in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. +With `SslContextFactory`, status lists only keys with a nonempty X.509 certificate chain. Keys without a chain, or whose chain contains a non-X.509 certificate, are omitted. After reload, check that every alias you expect appears in status. + +Reload also rejects a replacement with no X.509 certificate chains. A node can still start with such a store, but it logs a warning. Follow the `no X.509 certificate chains` row in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. @@ -299,7 +301,7 @@ Reload leaves existing connections open. After preventing new connections with t This section is for developers integrating a certificate source, such as a PKI service. To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. -Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by private-key alias. +Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by key alias. The final `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. Nodes call `createSslBundle()` directly; callers using `Factory` obtain its context through `create()`. @@ -307,8 +309,8 @@ If the source provides a key store, override `loadKeyMaterial()` to return link: The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. -The bundle includes the alias and certificate chain of every private-key entry reported by the store, even when the context can choose among several keys. -An empty map means no private-key entries are reported. An empty list for an alias means its certificate chain is missing or contains a non-X.509 certificate. Node reload rejects both cases. +The bundle lists nonempty X.509 certificate chains associated with key entries, indexed by alias. Entries without such a chain are omitted. Key availability and selection still depend on the configured key managers. +An empty map means no such chains were found, and node reload rejects it. Use `null` when certificate metadata is unknown. Every list in a supplied map must be nonempty. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. === Migrating an Existing Factory diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 1c152db91266b..209f0d67f5eec 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1801,7 +1801,7 @@ Use this command after a replacement or when investigating a reload failure. It control.sh|bat --ssl status ---- -For each factory, the output lists its transports and last reload result. Each private-key entry (alias) has its own `loaded` line: +For each factory, the output lists its transports and last reload result. Each reported certificate chain has a `loaded` line identified by its key alias: [source,text] ---- @@ -1810,7 +1810,7 @@ e1e18f84-0fb5-484d-a623-15ffe3800000 [consistentId=node01]: client connector, co last reload succeeded at 2026-10-05T09:00:02.347Z ---- -Check each certificate's identity and expiry date in its `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. +Check that all expected aliases are listed, then check each certificate's identity and expiry date in its `loaded` line. If a CA certificate in its chain expires sooner, `chainNotAfter` gives that earlier date. Status does not test connections or verify that other nodes and clients trust this certificate. [cols="2,3",opts="header"] @@ -1822,9 +1822,7 @@ Status does not test connections or verify that other nodes and clients trust th |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` |The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. -|`alias=...: certificate chain is absent or is not X.509` -|This private key has no certificate chain, or its chain contains a non-X.509 certificate. The node never presents such a key. Add its X.509 chain or remove the key, then run reload. -|`no private-key entries reported` +|`no X.509 certificate chains` |Install the node's private key and its X.509 certificate chain in the configured key store, then run reload. |`no SSL context factory is configured` |This node has no certificates managed by these commands. This message does not make either command fail. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index be329c9d790a5..43e19a5b90434 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -80,18 +80,12 @@ private SslStatusJob(NoArg arg, boolean debug) { if (chains == null) lines.add(DOUBLE_INDENT + "certificate metadata is unavailable"); else if (chains.isEmpty()) - lines.add(DOUBLE_INDENT + "no private-key entries reported"); + lines.add(DOUBLE_INDENT + "no X.509 certificate chains"); else { for (Map.Entry> entry : chains.entrySet()) { List chain = entry.getValue(); String alias = "alias=" + entry.getKey(); - if (chain.isEmpty()) { - lines.add(DOUBLE_INDENT + alias + ": certificate chain is absent or is not X.509"); - - continue; - } - X509Certificate leaf = chain.get(0); long chainNotAfter = chainNotAfter(chain); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 2e56e6008c007..fe07a91eade4f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -69,7 +69,7 @@ public static String describe(@Nullable X509Certificate cert) { } /** - * @param chains Chains by private-key alias, or {@code null} when metadata is unavailable. + * @param chains Nonempty chains by key alias, or {@code null} when metadata is unavailable. * @return Description of every alias and its leaf certificate, or an empty string if the aliases are unknown. */ public static String describeChains(@Nullable Map> chains) { @@ -78,8 +78,7 @@ public static String describeChains(@Nullable Map> List descriptions = new ArrayList<>(); - chains.forEach((alias, chain) -> descriptions.add("alias=" + alias + ", " + - (chain.isEmpty() ? "certificate chain is absent or is not X.509" : describe(chain.get(0))))); + chains.forEach((alias, chain) -> descriptions.add("alias=" + alias + ", " + describe(chain.get(0)))); return String.join("; ", descriptions); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index c5e29a7eaa416..7e454e0b7316a 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -84,7 +84,7 @@ public String transports() { return String.join(", ", transports); } - /** @return Chains by private-key alias, or {@code null} when the factory supplies no metadata. */ + /** @return Chains by key alias, or {@code null} when the factory supplies no metadata. */ public @Nullable Map> certificateChains() { return bundle.certificateChains(); } @@ -95,9 +95,8 @@ private SslBundle createBundle() { } /** - * Builds and installs a new context for new connections, recording success or failure. Rejects key material that reports no - * private-key entries or contains a private key without an X.509 certificate chain. Every supplied certificate must be within its - * validity period. + * Builds and installs a new context for new connections, recording success or failure. Rejects material known to have no + * X.509 certificate chains. Every supplied certificate must be within its validity period. * The administrator must ensure that peers trust the new certificates and can connect with the new context. * * @return Context and certificate metadata installed by this reload. @@ -129,38 +128,21 @@ public synchronized SslBundle reload() throws IgniteCheckedException { /** * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material reports no private-key entries or a key has no X.509 certificate chain. + * @throws IgniteCheckedException If the loaded material has no X.509 certificate chains. */ private SslBundle build() throws IgniteCheckedException { SslBundle rebuilt = createBundle(); + Map> chains = rebuilt.certificateChains(); - checkKeyMaterial(rebuilt.certificateChains()); + if (chains != null && chains.isEmpty()) + throw new IgniteCheckedException("The new key material has no X.509 certificate chains, " + + "so the node would present no certificate"); rebuilt.context().createSSLEngine(); return rebuilt; } - /** - * @param chains Chains by private-key alias, {@code null} if the factory supplies no metadata. - * @throws IgniteCheckedException If no private-key entries are reported or a key has no X.509 certificate chain. - */ - private static void checkKeyMaterial(@Nullable Map> chains) throws IgniteCheckedException { - if (chains == null) - return; - - if (chains.isEmpty()) - throw new IgniteCheckedException("The new key material reports no private-key entries, " + - "so the node would present no certificate"); - - for (Map.Entry> entry : chains.entrySet()) { - if (entry.getValue().isEmpty()) { - throw new IgniteCheckedException("The private key has no X.509 certificate chain; remove the key or provide its " + - "X.509 certificate chain [alias=" + entry.getKey() + ']'); - } - } - } - /** * @param next Chains loaded with the new context, {@code null} if they are unknown. * @throws IgniteCheckedException If a certificate is outside its validity period. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 33af0082e6d09..0899b0c857fff 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -85,17 +85,10 @@ private void warnIfUnusable(SslContextProvider provider) { return; if (chains.isEmpty()) - U.warn(log, "The key material reports no private-key entries, so the node presents no certificate; " + + U.warn(log, "The key material has no X.509 certificate chains, so the node cannot present a certificate; " + "reload rejects such key material"); for (Map.Entry> entry : chains.entrySet()) { - if (entry.getValue().isEmpty()) { - U.warn(log, "The private key has no X.509 certificate chain, so the node never presents it; reload rejects " + - "this key store until the key is removed or its X.509 certificate chain is provided [alias=" + entry.getKey() + ']'); - - continue; - } - X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), System.currentTimeMillis()); if (invalid != null) { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 3e9430d3c360a..3f3062b17b07b 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -138,27 +138,17 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { assertEquals(new ReloadOutcome(recovered.successTime(), 0, null, 0), recovered); } - /** - * Key material without a private key, such as a trust store put in place of the key store, or with a private key that has no - * X.509 chain is not applied. - */ + /** Key material with no certificate chains, such as a trust store put in place of the key store, is not applied. */ @Test - public void testKeyMaterialWithoutUsableKeyNotApplied() throws Exception { - // Aliases are sorted: the "z-" prefix puts the broken key after the valid one, so the check does not stop at the first key. - KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); - SSLContext ctx = context(valid, ca.trustStore()); + public void testMaterialWithoutCertificateChainsNotApplied() throws Exception { AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); when(f.createSslBundle()).thenReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.trustStore(), ca.trustStore()), - new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); - - SslContextProvider p = new SslContextProvider(f); + bundle(ca.trustStore(), ca.trustStore())); - assertReloadRejected(p, "The new key material reports no private-key entries, so the node would present no certificate"); - assertReloadRejected(p, "The private key has no X.509 certificate chain; remove the key or provide its X.509 certificate " + - "chain [alias=z-no-chain]"); + assertReloadRejected(new SslContextProvider(f), + "The new key material has no X.509 certificate chains, so the node would present no certificate"); } /** A context that cannot create an engine, here with a protocol the JVM does not support, is not applied. */ @@ -233,36 +223,24 @@ record Fixed(SSLContext ctx) implements Factory { assertEquals(2, registry.providers().size()); } - /** - * Registration warns about key material that reload refuses: no private key, here a trust store put in place of the key store, - * or a private key without an X.509 chain. - */ + /** Registration warns when the key material has no certificate chains, here a trust store put in place of the key store. */ @Test - public void testRegistryWarnsAboutKeysReloadRefuses() throws Exception { - KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); - SSLContext ctx = context(valid, ca.trustStore()); - AbstractSslContextFactory noKey = mock(AbstractSslContextFactory.class); - AbstractSslContextFactory noChain = mock(AbstractSslContextFactory.class); + public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); - when(noKey.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); - when(noChain.createSslBundle()).thenReturn(new SslBundle(ctx, Map.of("a-valid", chain(valid), "z-no-chain", List.of()))); + when(f.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); ListeningTestLogger registryLog = new ListeningTestLogger(log); - LogListener noKeyWarned = LogListener.matches("The key material reports no private-key entries, " + - "so the node presents no certificate; reload rejects such key material").times(1).build(); - LogListener noChainWarned = LogListener.matches("The private key has no X.509 certificate chain, so the node never presents " + - "it; reload rejects this key store until the key is removed or its X.509 certificate chain is provided " + - "[alias=z-no-chain]").times(1).build(); + LogListener warned = LogListener.matches("The key material has no X.509 certificate chains, " + + "so the node cannot present a certificate; reload rejects such key material").times(1).build(); - registryLog.registerAllListeners(noKeyWarned, noChainWarned); + registryLog.registerListener(warned); SslContextRegistry registry = new SslContextRegistry(new StandaloneGridKernalContext(registryLog, null)); - registry.register(noKey, CLIENT_CONNECTOR); - registry.register(noChain, BINARY_REST); + registry.register(f, CLIENT_CONNECTOR); - assertTrue("No private key", noKeyWarned.check()); - assertTrue("Key without a chain", noChainWarned.check()); + assertTrue(warned.check()); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 97b051eac04d5..0082ae9058161 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -75,6 +75,7 @@ import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; @@ -336,9 +337,9 @@ public void testRejectedReloadReported() throws Exception { "\\S+: " + Pattern.quote(reason)).matcher(status).find()); } - /** A real PKCS12 replacement with a private key but no certificate chain is refused, and new connections keep the old certificate. */ + /** Reload refuses a PKCS12 store with no certificate chains, but ignores an entry without a chain when another has one. */ @Test - public void testKeyWithoutCertificateChainNotApplied() throws Exception { + public void testReloadRequiresAtLeastOneCertificateChain() throws Exception { String name = getTestIgniteInstanceName(0); String alias = "key-without-chain"; String storeType = "PKCS12"; @@ -355,14 +356,26 @@ public void testKeyWithoutCertificateChainNotApplied() throws Exception { }); KeyStore replacement = keyStoreWithoutCertificateChain(storeType, alias); - assertTrue(replacement.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)); + assertTrue(replacement.isKeyEntry(alias)); assertNull(replacement.getCertificateChain(alias)); save(replacement, keyFile(name)); - assertContains(log, reloadFailure(g), "The private key has no X.509 certificate chain; remove the key or provide its " + - "X.509 certificate chain [alias=" + alias + ']'); + assertContains(log, reloadFailure(g), + "The new key material has no X.509 certificate chains, so the node would present no certificate"); assertEquals(oldCert, servedCertificate(context(oldKeys, trustBoth), discoveryPort(g))); + + replacement.setKeyEntry(NEW_KEY, newKeys.getKey(NEW_KEY, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, + newKeys.getCertificateChain(NEW_KEY)); + + save(replacement, keyFile(name)); + reload(g); + + String report = status(g); + + assertContains(log, report, "loaded alias=" + NEW_KEY + ", subject=CN=" + NEW_KEY); + assertNotContains(log, report, "alias=" + alias); + assertEquals(newCert, servedCertificate(context(oldKeys, trustBoth), discoveryPort(g))); } /** Both commands report a node that is unavailable when jobs are mapped and still run on the other nodes. */ From 384d6765a3e0b299a8931df483836d0a5d2b7ad1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:47:25 +0300 Subject: [PATCH 087/242] IGNITE-29210 Name reported chains in reload test guarantees --- .../java/org/apache/ignite/ssl/SslContextReloadTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 0082ae9058161..51ece516974b1 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -88,7 +88,7 @@ * {@code --ssl reload} and {@code --ssl status} on running nodes: new connections of every transport get the reloaded certificate, * every node logs who reloaded it, a factory is loaded once per reload whatever transports share it, separate factories get * providers of their own, and a failing factory does not stop the others. The report, the node log and status show refused - * reloads; the report shows unavailable nodes and lists failures first; both commands name every key; a node on an invalid + * reloads; the report shows unavailable nodes and lists failures first; both commands name each reported chain; a node on an invalid * certificate warns and fails status; without SSL both commands say that no factory is configured. The last test replaces the * authority of a cluster. A node starts on old-key from old-ca and trusts both authorities unless a test says otherwise; its * client connector shares the node factory and asks for no client certificate. Each node reads its own key and trust store @@ -425,11 +425,11 @@ public void testReportListsFailuresFirst() { } /** - * Reload and status name every private key of the store; for a chain whose authority expires before its leaf, status shows the + * Reload and status name every reported chain of the store; for a chain whose authority expires before its leaf, status shows the * expiry of the authority. */ @Test - public void testReportsNameEveryKey() throws Exception { + public void testReportsNameEveryChain() throws Exception { // X.509 keeps whole seconds. long now = System.currentTimeMillis() / 1000 * 1000; long caExpiry = now + HOUR; From c240cab389f91c17e684ec1769d7601b48ede919 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:47:26 +0300 Subject: [PATCH 088/242] IGNITE-29211 Align renewal documentation with reported chains --- docs/_docs/security/ssl-tls.adoc | 2 +- .../org/apache/ignite/internal/ssl/SslContextProvider.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 448cac538af53..e66a6f0ef0f47 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -62,7 +62,7 @@ tab:C#/.NET[] tab:C++[unsupported] -- -The keystore must contain the node's certificate and private key. For automatic renewal, include the X.509 chain of every private key. +The keystore must contain the node's certificate and private key. The trust store defines which peer certificates the node accepts. Configure trust on all nodes and clients before replacing certificates. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index fe45c82dde953..2e45cf5e6db33 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -288,7 +288,8 @@ private SslBundle build() throws IgniteCheckedException { /** * @param next Bundle loaded by the factory. * @param requireChains Whether at least one reported chain is required for renewal. - * @throws IgniteCheckedException If required certificate metadata is unknown or empty, or a certificate is outside its validity period. + * @throws IgniteCheckedException If required certificate metadata is unknown or empty, + * or a certificate is outside its validity period. */ private void check(SslBundle next, boolean requireChains) throws IgniteCheckedException { Map> chains = next.certificateChains(); From da73b61aafaecd3a03a60199ab3af748788d07e0 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:47:49 +0300 Subject: [PATCH 089/242] IGNITE-29211 Clarify scope of omitted certificate entries --- docs/_docs/security/ssl-tls.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index e66a6f0ef0f47..b32ed238e92d1 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -266,7 +266,7 @@ For that chain, the renewal window is the last share of its lifetime set by the The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. -Provide at least one private key with its X.509 certificate chain. `SslContextFactory` ignores entries whose chains are missing, empty or contain a non-X.509 certificate. A custom factory must supply the TLS certificate chains used by its context. +Provide at least one private key with its X.509 certificate chain. Entries whose chains are missing, empty or contain a non-X.509 certificate are omitted from certificate status and renewal scheduling. A custom factory must supply the TLS certificate chains used by its context. A node with automatic renewal enabled cannot start if the factory supplies no chains or cannot report them. Ignite replaces the entire context at once. All replacement chains must be valid, but their aliases need not match the old ones. From a883cc3b33d58f07ed0beb9c1b9b218ca7808f68 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:48:52 +0300 Subject: [PATCH 090/242] IGNITE-29211 Keep renewal error within line limit --- .../org/apache/ignite/internal/ssl/SslContextProvider.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 2e45cf5e6db33..03cb555e4ea96 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -295,7 +295,8 @@ private void check(SslBundle next, boolean requireChains) throws IgniteCheckedEx Map> chains = next.certificateChains(); if (requireChains && (chains == null || chains.isEmpty())) - throw new IgniteCheckedException("The new SSL context must provide at least one X.509 certificate chain to renew automatically"); + throw new IgniteCheckedException("The new SSL context must provide at least one X.509 certificate chain " + + "to renew automatically"); if (chains == null) return; From f7e62f67406fe7d95014ab2e7ee99f13996da3fc Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 19:43:43 +0300 Subject: [PATCH 091/242] IGNITE-29209 Remove optional key manager hook --- .../ignite/ssl/AbstractSslContextFactory.java | 17 +--- .../apache/ignite/ssl/SslContextFactory.java | 11 --- .../ignite/ssl/SslContextMetadataTest.java | 81 ++----------------- .../org/apache/ignite/ssl/SslTestUtils.java | 51 ------------ 4 files changed, 8 insertions(+), 152 deletions(-) delete mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index d63cd81492b83..1aa3e806735cd 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -117,10 +117,10 @@ public SslBundle createSslBundle() { KeyMaterial material = loadKeyMaterial(); - return new SslBundle(createSslContext(createKeyManagers(material)), material.certificateChains()); + return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); } catch (SSLException e) { - throw new IgniteException(e); + throw new IgniteException(new SSLException(e.getMessage() + " [factory=" + this + ']', e)); } } @@ -130,8 +130,6 @@ public SslBundle createSslBundle() { * @throws SSLException If trust managers or the context cannot be created. */ private SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { - checkNullParameter(keyMgrs, "keyManagers"); - TrustManager[] trustMgrs = createTrustManagers(); try { @@ -187,17 +185,6 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ protected abstract KeyMaterial loadKeyMaterial() throws SSLException; - /** - * Creates key managers from the loaded material. Override to customize key selection without reloading the store. - * - * @param material Loaded key material whose certificate chains will be included in the bundle. - * @return Key managers using that material, never {@code null}. - * @throws SSLException If the key managers cannot be created. - */ - protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { - return material.keyManagers(); - } - /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 181160a5c64b3..40def4b6f2aa1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -27,7 +27,6 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -264,16 +263,6 @@ public static TrustManager getDisabledTrustManager() { return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { - try { - return super.createKeyManagers(material); - } - catch (SSLException e) { - throw new SSLException(e.getMessage() + " [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); - } - } - /** {@inheritDoc} */ @Override protected final TrustManager[] createTrustManagers() throws SSLException { if (trustMgrs != null) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 2938e453c9f76..a3cbe7bd577ea 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -17,7 +17,6 @@ package org.apache.ignite.ssl; -import java.net.InetAddress; import java.security.KeyStore; import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; @@ -27,40 +26,28 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLServerSocket; -import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; -import static org.apache.ignite.testframework.TestCertificateAuthority.trustManagers; -import static org.mockito.AdditionalAnswers.delegatesTo; -import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; /** * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, - * and rejects empty per-alias chains. Custom key managers take part in handshakes and cannot be {@code null}. - * {@link SslContextFactory} names the store whose key it cannot open. + * and rejects empty per-alias chains. {@link SslContextFactory} names the store whose key it cannot open. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -216,65 +203,6 @@ public void testBundleKeepsUnknownMetadata() throws Exception { assertNull(new SslBundle(SSLContext.getDefault(), null).certificateChains()); } - /** Key managers returned by an overridden {@code createKeyManagers} present the certificate in a handshake. */ - @Test - public void testCustomKeyManagerUsedByContext() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("server-key"); - AtomicReference customMgr = new AtomicReference<>(); - InMemoryStoreFactory factory = new InMemoryStoreFactory(store) { - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { - X509ExtendedKeyManager mgr = mock(X509ExtendedKeyManager.class, delegatesTo(super.createKeyManagers(material)[0])); - - customMgr.set(mgr); - - return new KeyManager[] {mgr}; - } - }; - SSLContext probe = SSLContext.getInstance("TLS"); - - probe.init(new KeyManager[0], trustManagers(ca.trustStore()), null); - - int timeout = 10_000; - - try (SSLServerSocket listener = (SSLServerSocket)factory.create().getServerSocketFactory() - .createServerSocket(0, 0, InetAddress.getLoopbackAddress())) { - listener.setSoTimeout(timeout); - - IgniteInternalFuture accepted = GridTestUtils.runAsync(() -> { - try (SSLSocket server = (SSLSocket)listener.accept()) { - server.setSoTimeout(timeout); - server.startHandshake(); - } - }); - - assertEquals(store.getCertificate("server-key"), servedCertificate(probe, listener.getLocalPort())); - - accepted.get(timeout); - } - - verify(customMgr.get(), atLeastOnce()).getPrivateKey("server-key"); - } - - /** - * A {@code null} result of {@code createKeyManagers} is rejected: {@code SSLContext.init} would accept it and install an - * empty key manager, so the context would present no certificate. - */ - @Test - public void testNullKeyManagersRejected() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()) { - /** {@inheritDoc} */ - @Override protected KeyManager[] createKeyManagers(KeyMaterial material) { - return null; - } - }; - - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, - "parameter cannot be null): keyManagers"); - - assertTrue(err.getCause() instanceof SSLException); - } - /** A key the store password cannot open is reported with the store type and path and keeps the original cause. */ @Test public void testKeyPasswordFailureNamesStore() throws Exception { @@ -285,13 +213,16 @@ public void testKeyPasswordFailureNamesStore() throws Exception { InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - factory.setKeyStoreFilePath("unreadable-key.jks"); + String storePath = "unreadable-key.jks"; + + factory.setKeyStoreFilePath(storePath); - String storeDetails = "[type=JKS, keyStorePath=unreadable-key.jks]"; + String storeDetails = "keyStoreFile=" + storePath; Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); assertTrue(err.getCause() instanceof SSLException); assertTrue(err.getCause().getMessage().contains(storeDetails)); + assertTrue(err.getCause().getMessage().contains("keyStoreType=" + store.getType())); assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java deleted file mode 100644 index 7869ecd68c16b..0000000000000 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslTestUtils.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.security.cert.X509Certificate; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; - -/** Probes the certificates that TLS servers present. */ -public class SslTestUtils { - /** Timeout for a probe connection or TLS handshake. */ - private static final int SOCKET_TIMEOUT = 30_000; - - /** */ - private SslTestUtils() { - // No-op. - } - - /** - * @param probe Context to connect with. - * @param port Loopback port to connect to. - * @return Certificate the server presents on a new TLS connection to that port. - */ - public static X509Certificate servedCertificate(SSLContext probe, int port) throws Exception { - try (SSLSocket sock = (SSLSocket)probe.getSocketFactory().createSocket()) { - sock.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), SOCKET_TIMEOUT); - sock.setSoTimeout(SOCKET_TIMEOUT); - - sock.startHandshake(); - - return (X509Certificate)sock.getSession().getPeerCertificates()[0]; - } - } -} From 021fd83a8d094d72f1c62df736e8ccb15dd94047 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 19:44:42 +0300 Subject: [PATCH 092/242] IGNITE-29210 Document custom SSL bundle construction --- docs/_docs/security/ssl-tls.adoc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 924a8af7f32ff..01d7b67119d64 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -307,7 +307,9 @@ Nodes call `createSslBundle()` directly; callers using `Factory` obt If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. -To customize key-manager behavior, override `createKeyManagers(KeyMaterial)` and wrap the managers returned by `super.createKeyManagers(material)`. The managers and reported certificate chains must use the same loaded material. + +To use custom key managers, override `createSslBundle()` to initialize a fresh `SSLContext` with those managers and return it with matching certificate metadata in an `SslBundle`. +The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates. The bundle lists nonempty X.509 certificate chains associated with key entries, indexed by alias. Entries without such a chain are omitted. Key availability and selection still depend on the configured key managers. An empty map means no such chains were found, and node reload rejects it. Use `null` when certificate metadata is unknown. Every list in a supplied map must be nonempty. @@ -317,8 +319,8 @@ See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ign The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed, and `create()` is now final. Update affected subclasses and recompile before upgrading: -* Move store loading from `createKeyManagers()` into `loadKeyMaterial()`, returning the loaded store and its settings. Move key-manager customization into `createKeyManagers(KeyMaterial)`. -* Remove `create()` overrides. Move store loading into `loadKeyMaterial()`. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. +* Move custom store loading into `loadKeyMaterial()`, returning the loaded store and its settings. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. +* Remove `create()` overrides. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. * Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. From 89d0e7ee3d9c6918b31736e94146188bbc90eb33 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 29 Oct 2026 13:44:58 +0300 Subject: [PATCH 093/242] IGNITE-29209 Preserve SSL diagnostics without an extra exception --- .../ignite/jdbc/thin/JdbcThinConnectionSSLTest.java | 8 ++++---- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 2 +- .../apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java | 3 +-- .../org/apache/ignite/ssl/SslContextMetadataTest.java | 6 ++---- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index c97fbe559117a..4707668125f83 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -655,9 +655,7 @@ public void testUnsupportedSslProtocol() throws Exception { } } - /** - * @throws Exception If failed. - */ + /** An unsupported key-manager algorithm is reported together with the affected key store. */ @Test public void testInvalidKeyAlgorithm() throws Exception { setSslCtxFactoryToCli = true; @@ -666,7 +664,7 @@ public void testInvalidKeyAlgorithm() throws Exception { startGrids(1); try { - GridTestUtils.assertThrows(log, new Callable() { + Throwable err = GridTestUtils.assertThrows(log, new Callable() { @Override public Object call() throws Exception { Connection c = DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + "&sslKeyAlgorithm=INVALID" + @@ -678,6 +676,8 @@ public void testInvalidKeyAlgorithm() throws Exception { return null; } }, SQLException.class, "Unsupported key algorithm: INVALID"); + + assertTrue(err.getMessage().contains("keyStoreFile=" + CLI_KEY_STORE_PATH)); } finally { stopAllGrids(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 1aa3e806735cd..8f3de9298ad21 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -120,7 +120,7 @@ public SslBundle createSslBundle() { return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); } catch (SSLException e) { - throw new IgniteException(new SSLException(e.getMessage() + " [factory=" + this + ']', e)); + throw new IgniteException(e.getMessage() + " [factory=" + this + ']', e); } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java b/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java index 8c7ca94ab53b2..1024d30a81a67 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java @@ -188,9 +188,8 @@ private static SSLSocketFactory getSSLSocketFactory(ConnectionProperties connPro catch (IgniteException e) { final Throwable cause = e.getCause(); - // Unwrap. if (cause instanceof SSLException) - throw new SQLException(cause.getMessage(), SqlStateCode.CLIENT_CONNECTION_FAILED, e); + throw new SQLException(e.getMessage(), SqlStateCode.CLIENT_CONNECTION_FAILED, e); else throw new SQLException("Unknown error.", SqlStateCode.CLIENT_CONNECTION_FAILED, e); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index a3cbe7bd577ea..f637b7fe1481a 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -32,7 +32,6 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; @@ -221,9 +220,8 @@ public void testKeyPasswordFailureNamesStore() throws Exception { Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); assertTrue(err.getCause() instanceof SSLException); - assertTrue(err.getCause().getMessage().contains(storeDetails)); - assertTrue(err.getCause().getMessage().contains("keyStoreType=" + store.getType())); - assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); + assertTrue(err.getMessage().contains("keyStoreType=" + store.getType())); + assertTrue(err.getCause().getCause() instanceof UnrecoverableKeyException); } /** From 6459a46c8db0d9afbec35dc1a144933fb7c40f39 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 29 Oct 2026 13:59:20 +0300 Subject: [PATCH 094/242] IGNITE-29209 Allow factory details in .NET SSL error assertions --- .../Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs b/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs index 8b7a253a09b96..c82230ec08062 100644 --- a/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs +++ b/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs @@ -112,14 +112,14 @@ public void TestConfigurationExceptions() TrustStoreFilePath, Password); ex = Assert.Throws(() => Ignition.Start(cfg)); - Assert.AreEqual(@"Failed to initialize key store (I/O error occurred): Config/KeyStore/server.jks", + StringAssert.StartsWith(@"Failed to initialize key store (I/O error occurred): " + KeyStoreFilePath, ex.Message); cfg.SslContextFactory = new SslContextFactory(KeyStoreFilePath, Password, TrustStoreFilePath, "654321"); ex = Assert.Throws(() => Ignition.Start(cfg)); - Assert.AreEqual(@"Failed to initialize key store (I/O error occurred): Config/KeyStore/trust.jks", + StringAssert.StartsWith(@"Failed to initialize key store (I/O error occurred): " + TrustStoreFilePath, ex.Message); } From 71459c780432c77aa8f15b8435bd99cbd131211c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 29 Oct 2026 23:51:55 +0300 Subject: [PATCH 095/242] IGNITE-29210 Clarify SSL reload certificate checks --- .../org/apache/ignite/internal/ssl/SslContextProviderTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 3f3062b17b07b..eb7fa6453df60 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -57,7 +57,8 @@ /** * What {@link SslContextProvider#reload()} puts in use and records, what it refuses; the registry keeps one provider per factory * object and warns at registration about key material that reload refuses. Reload refusals follow the order of the checks in the - * code: usable keys, an engine from the new context, a context not in use, certificates valid now. + * code: nonempty X.509 chains when metadata is available, an engine from the new context, a context not in use, + * certificates valid now. */ public class SslContextProviderTest extends GridCommonAbstractTest { /** */ From e9a90b4bc455e374b5480c98b4759f1d6f413fd7 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sun, 1 Nov 2026 20:01:20 +0300 Subject: [PATCH 096/242] IGNITE-29210 Wait for consistency cancellation status cleanup in tests --- .../ignite/util/KillCommandsControlShTest.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java index e195c5ba00144..14ce472d93c41 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java @@ -281,7 +281,7 @@ public void testCancelConsistencyMissedTask() { * */ @Test - public void testCancelConsistencyTaskParallel() throws InterruptedException { + public void testCancelConsistencyTaskParallel() throws Exception { testCancelConsistencyTask(true); } @@ -289,14 +289,14 @@ public void testCancelConsistencyTaskParallel() throws InterruptedException { * */ @Test - public void testCancelConsistencyTaskSequential() throws InterruptedException { + public void testCancelConsistencyTaskSequential() throws Exception { testCancelConsistencyTask(false); } /** * */ - private void testCancelConsistencyTask(boolean parallel) throws InterruptedException { + private void testCancelConsistencyTask(boolean parallel) throws Exception { Assume.assumeTrue(cliCommandHandler()); String consistencyCacheName = "consistencyCache"; @@ -427,11 +427,13 @@ else if (foundLocally) { testOut.reset(); - int res = execute("--consistency", "status"); + boolean cleared = GridTestUtils.waitForCondition(() -> { + assertEquals(EXIT_CODE_OK, execute("--consistency", "status")); - assertEquals(EXIT_CODE_OK, res); + return testOut.toString().contains(ConsistencyStatusTask.NOTHING_FOUND); + }, 5_000); - assertContains(log, testOut.toString(), ConsistencyStatusTask.NOTHING_FOUND); + assertTrue("Consistency status did not clear:\n" + testOut, cleared); assertNotContains(log, testOut.toString(), "Status"); } } From ba1dbcf1d4f0392ed852eb6d9a755ae2f48da683 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 15:20:54 +0300 Subject: [PATCH 097/242] IGNITE-29209 Let SSL factories own initialized bundles --- .../ignite/ssl/AbstractSslContextFactory.java | 68 +++++++++- .../apache/ignite/ssl/CurrentSslContext.java | 97 +++++++++++++++ .../apache/ignite/ssl/SslContextFactory.java | 4 +- .../ignite/ssl/SslContextMetadataTest.java | 116 +++++++++++++++--- .../ignite/ssl/SslContextReuseTest.java | 110 ++++++++++++++++- 5 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 8f3de9298ad21..6c9d1b558980c 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -29,8 +29,10 @@ import org.apache.ignite.internal.util.typedef.internal.A; /** - * Builds a fresh SSL context and its certificate metadata on each call. The component using the context is responsible for - * retaining it between connections. + * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; + * {@link #create()} returns a stable context. Its newly created engines and newly obtained socket factories use the + * accepted material; previously obtained engines, socket factories and sockets retain their original material. + * Configure the factory before its first use. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -39,6 +41,12 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default SSL protocol. */ public static final String DFLT_SSL_PROTOCOL = "TLS"; + /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ + private transient volatile SslBundle bundle; + + /** Stable context returned to consumers after the first successful initialization. */ + private transient volatile SSLContext sharedCtx; + /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -108,6 +116,9 @@ public void setProtocols(String... protocols) { * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from * that material. The factory does not retain the result. * + * Override this method to load a candidate from another source. It must not change the material already in use. + * Use {@link #sslBundle()} to inspect the accepted material. + * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. */ @@ -192,12 +203,57 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Builds a fresh context through {@link #createSslBundle()}. Override that method to customize both entry points. + * Loads keys and trust settings and atomically replaces the accepted context and its metadata. A failed initialization + * leaves the previous material in use. New engines and socket factories obtained through {@link #create()} use the + * replacement; already opened connections keep their original material. * - * @return Fresh initialized context. - * @throws IgniteException If the context could not be created. + * @return Context and metadata accepted by this initialization. + * @throws IgniteException If the material cannot be loaded or the context cannot be created. + */ + public final synchronized SslBundle init() { + SslBundle next = createSslBundle(); + + next.context().createSSLEngine(); + + SSLContext shared = sharedCtx; + + if (shared == null) + shared = new CurrentSslContext(next.context(), () -> bundle.context()); + + bundle = next; + sharedCtx = shared; + + return next; + } + + /** + * Returns the accepted context and its metadata, initializing the factory on first use. + * + * @return One snapshot of the material in use. + * @throws IgniteException If the first initialization fails. + */ + public final SslBundle sslBundle() { + create(); + + return bundle; + } + + /** + * Returns the same forwarding context on every call, initializing it on first use. Obtain new engines and socket + * factories from this context to use the material accepted by the last successful {@link #init()}. Repeated calls + * do not read the key or trust stores. + * + * @return Stable initialized context. + * @throws IgniteException If the first initialization fails. */ @Override public final SSLContext create() { - return createSslBundle().context(); + if (sharedCtx == null) { + synchronized (this) { + if (sharedCtx == null) + init(); + } + } + + return sharedCtx; } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java new file mode 100644 index 0000000000000..5d0903870e41f --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java @@ -0,0 +1,97 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.security.SecureRandom; +import java.util.function.Supplier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLContextSpi; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +/** Delegates to the current SSL context so that new connections use reloaded certificates. */ +class CurrentSslContext extends SSLContext { + /** + * @param initial First accepted context, used for its provider and protocol. + * @param ctx Supplier of the context currently in use. + */ + CurrentSslContext(SSLContext initial, Supplier ctx) { + super(new Spi(ctx), initial.getProvider(), initial.getProtocol()); + } + + /** */ + private static class Spi extends SSLContextSpi { + /** */ + private final Supplier ctx; + + /** @param ctx Supplier of the context currently in use. */ + private Spi(Supplier ctx) { + this.ctx = ctx; + } + + /** {@inheritDoc} */ + @Override protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom rnd) { + throw new UnsupportedOperationException("The SSL context is built by its factory"); + } + + /** {@inheritDoc} */ + @Override protected SSLSocketFactory engineGetSocketFactory() { + return ctx.get().getSocketFactory(); + } + + /** {@inheritDoc} */ + @Override protected SSLServerSocketFactory engineGetServerSocketFactory() { + return ctx.get().getServerSocketFactory(); + } + + /** {@inheritDoc} */ + @Override protected SSLEngine engineCreateSSLEngine() { + return ctx.get().createSSLEngine(); + } + + /** {@inheritDoc} */ + @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { + return ctx.get().createSSLEngine(host, port); + } + + /** {@inheritDoc} */ + @Override protected SSLSessionContext engineGetServerSessionContext() { + return ctx.get().getServerSessionContext(); + } + + /** {@inheritDoc} */ + @Override protected SSLSessionContext engineGetClientSessionContext() { + return ctx.get().getClientSessionContext(); + } + + /** {@inheritDoc} */ + @Override protected SSLParameters engineGetDefaultSSLParameters() { + return ctx.get().getDefaultSSLParameters(); + } + + /** {@inheritDoc} */ + @Override protected SSLParameters engineGetSupportedSSLParameters() { + return ctx.get().getSupportedSSLParameters(); + } + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 40def4b6f2aa1..35e511dcde177 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -37,8 +37,8 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * Each call loads the stores again and builds a fresh context. The component using it should retain the context between - * connections. + * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. Call {@link #init()} + * to read the stores again and publish the replacement material. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index f637b7fe1481a..9b8c3f7a92d15 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -26,6 +26,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -43,10 +45,9 @@ import static org.mockito.Mockito.mock; /** - * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, - * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering - * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, - * and rejects empty per-alias chains. {@link SslContextFactory} names the store whose key it cannot open. + * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous + * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} reports nonempty X.509 chains + * without recovering keys and keeps its own key password. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -57,44 +58,131 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each bundle reads the key store once, builds a new context and takes its metadata from that read. */ + /** Each initialization publishes a fresh context with metadata from the same, single key store read. */ @Test - public void testEachBundleReadsKeyStoreOnce() throws Exception { + public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { KeyStore first = ca.keyStoreWithPrivateKey("first"); InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - SslBundle firstBundle = factory.createSslBundle(); + SslBundle firstBundle = factory.init(); assertEquals(1, factory.loads); + assertSame(firstBundle, factory.sslBundle()); assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); + SSLContext shared = factory.create(); KeyStore second = ca.keyStoreWithPrivateKey("second"); factory.store = second; - SslBundle secondBundle = factory.createSslBundle(); + SslBundle secondBundle = factory.init(); assertEquals(2, factory.loads); + assertSame(secondBundle, factory.sslBundle()); assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); + assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); assertNotSame(firstBundle.context(), secondBundle.context()); + assertSame(shared, factory.create()); } - /** A failed read is thrown instead of reusing an earlier context, and the next call reads the store again. */ + /** Concurrent first calls initialize once; obtaining the context and accepted snapshot never reloads the store. */ @Test - public void testFailedLoadCanBeRetried() throws Exception { + public void testCreateAndSnapshotDoNotReloadStore() throws Exception { InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + int threadCnt = 4; + CyclicBarrier start = new CyclicBarrier(threadCnt); + AtomicReference sharedRef = new AtomicReference<>(); + + GridTestUtils.runMultiThreaded(() -> { + start.await(); + + SSLContext ctx = factory.create(); + + sharedRef.compareAndSet(null, ctx); + assertSame(sharedRef.get(), ctx); + + return null; + }, threadCnt, "ssl-context-create"); + + SslBundle accepted = factory.sslBundle(); + SSLContext shared = sharedRef.get(); + + factory.loadFailure = "Store must not be read again"; + + assertSame(shared, factory.create()); + assertSame(accepted, factory.sslBundle()); + assertEquals(1, factory.loads); + } - factory.create(); + /** A failed reload leaves both the accepted snapshot and the forwarding context usable, and can be retried. */ + @Test + public void testFailedInitRetainsAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); factory.loadFailure = "Key store is unavailable"; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "Key store is unavailable"); + Throwable err = GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); assertTrue(err.getCause() instanceof SSLException); + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(2, factory.loads); + + factory.loadFailure = null; + + SslBundle replacement = factory.init(); + + assertNotSame(accepted, replacement); + assertSame(replacement, factory.sslBundle()); + assertSame(shared, factory.create()); + assertEquals(3, factory.loads); + } + + /** A failed first initialization leaves the factory able to initialize on the next context request. */ + @Test + public void testFirstInitializationFailureCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + + factory.loadFailure = "Key store is unavailable"; + + GridTestUtils.assertThrows(log, factory::create, IgniteException.class, factory.loadFailure); factory.loadFailure = null; - factory.create(); + SSLContext shared = factory.create(); + + assertSame(shared, factory.create()); + assertNotNull(factory.sslBundle().context().createSSLEngine()); + assertEquals(2, factory.loads); + } + + /** Unsupported TLS parameters are rejected before a replacement becomes visible. */ + @Test + public void testInvalidTlsParametersDoNotReplaceAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); + + factory.setProtocols("unsupported-protocol"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + + factory.setProtocols((String[])null); + factory.setCipherSuites("unsupported-cipher-suite"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(3, factory.loads); } /** Missing parameters are reported before the key store is read. */ @@ -145,7 +233,7 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { empty.load(null, null); - SslBundle bundle = new InMemoryStoreFactory(empty).createSslBundle(); + SslBundle bundle = new InMemoryStoreFactory(empty).init(); assertEquals(Map.of(), bundle.certificateChains()); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 19792613b6650..242487c346b91 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -17,18 +17,33 @@ package org.apache.ignite.ssl; +import java.io.File; +import java.io.FileOutputStream; +import java.security.KeyStore; import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; -/** Communication keeps one SSL context: new connections do not call the SSL context factory on either node. */ +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.junit.Assert.assertArrayEquals; + +/** Consumers retain one SSL context while initialization changes the material used by new connections. */ public class SslContextReuseTest extends GridCommonAbstractTest { + /** Stores generated independently for each test. */ + @Rule public final TemporaryFolder tmp = new TemporaryFolder(); + /** {@inheritDoc} */ @Override protected void afterTest() throws Exception { stopAllGrids(); @@ -36,6 +51,78 @@ public class SslContextReuseTest extends GridCommonAbstractTest { super.afterTest(); } + /** New engines and socket factories use initialized TLS settings; previously obtained ones retain their settings. */ + @Test + public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Exception { + SslContextFactory factory = fileFactory(); + String[] firstProtocols = {"TLSv1.2"}; + String[] firstCiphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; + + factory.setProtocols(firstProtocols); + factory.setCipherSuites(firstCiphers); + + SSLContext shared = factory.create(); + SSLEngine existing = shared.createSSLEngine(); + SSLSocketFactory capturedFactory = shared.getSocketFactory(); + String[] replacementProtocols = {"TLSv1.3"}; + String[] replacementCiphers = {"TLS_AES_128_GCM_SHA256"}; + + factory.setProtocols(replacementProtocols); + factory.setCipherSuites(replacementCiphers); + + SslBundle replacement = factory.init(); + String host = "peer.example"; + int port = 10800; + SSLEngine addressed = shared.createSSLEngine(host, port); + SSLEngine unaddressed = shared.createSSLEngine(); + + assertSame(shared, factory.create()); + assertSame(replacement.context().getClientSessionContext(), shared.getClientSessionContext()); + assertSame(replacement.context().getServerSessionContext(), shared.getServerSessionContext()); + assertEquals(host, addressed.getPeerHost()); + assertEquals(port, addressed.getPeerPort()); + assertArrayEquals(replacementProtocols, addressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, addressed.getEnabledCipherSuites()); + assertNull(unaddressed.getPeerHost()); + assertEquals(-1, unaddressed.getPeerPort()); + assertArrayEquals(replacementProtocols, unaddressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, unaddressed.getEnabledCipherSuites()); + assertArrayEquals(firstProtocols, existing.getEnabledProtocols()); + assertArrayEquals(firstCiphers, existing.getEnabledCipherSuites()); + + try (SSLSocket captured = (SSLSocket)capturedFactory.createSocket(); + SSLSocket current = (SSLSocket)shared.getSocketFactory().createSocket()) { + assertArrayEquals(firstProtocols, captured.getEnabledProtocols()); + assertArrayEquals(firstCiphers, captured.getEnabledCipherSuites()); + assertArrayEquals(replacementProtocols, current.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, current.getEnabledCipherSuites()); + } + } + + /** An initialized factory serializes its configuration and initializes fresh runtime state after deserialization. */ + @Test + public void testInitializedFactorySerializationKeepsConfiguration() throws Exception { + SslContextFactory factory = fileFactory(); + String[] protocols = {"TLSv1.2"}; + + factory.setProtocols(protocols); + + SslBundle original = factory.init(); + SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), + getClass().getClassLoader()); + + assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); + assertEquals(factory.getTrustStoreFilePath(), restored.getTrustStoreFilePath()); + assertArrayEquals(protocols, restored.getProtocols()); + + SSLContext shared = restored.create(); + + assertNotSame(factory.create(), shared); + assertSame(shared, restored.create()); + assertArrayEquals(protocols, shared.createSSLEngine().getEnabledProtocols()); + assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); + } + /** Reopening a communication connection does not call the SSL context factory on either node. */ @Test public void testCommunicationReusesContextOnReconnect() throws Exception { @@ -62,4 +149,25 @@ public void testCommunicationReusesContextOnReconnect() throws Exception { assertEquals(createdAtStart, created.get()); } + + /** Creates a file-backed factory with a generated private key and currently valid certificate chain. */ + private SslContextFactory fileFactory() throws Exception { + KeyStore store = new TestCertificateAuthority("context-reuse-ca").keyStoreWithPrivateKey(); + File storeFile = tmp.newFile("context.jks"); + + try (FileOutputStream out = new FileOutputStream(storeFile)) { + store.store(out, PWD); + } + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreType(store.getType()); + factory.setKeyStoreFilePath(storeFile.getAbsolutePath()); + factory.setKeyStorePassword(PWD); + factory.setTrustStoreType(store.getType()); + factory.setTrustStoreFilePath(storeFile.getAbsolutePath()); + factory.setTrustStorePassword(PWD); + + return factory; + } } From 22e3ec581ac7d91f56018d6f8045270e2d47ea0c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 15:25:25 +0300 Subject: [PATCH 098/242] IGNITE-29209 Clarify initialized factory lifetime --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 6c9d1b558980c..64e7e210309b6 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -32,7 +32,8 @@ * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; * {@link #create()} returns a stable context. Its newly created engines and newly obtained socket factories use the * accepted material; previously obtained engines, socket factories and sockets retain their original material. - * Configure the factory before its first use. + * Configure the factory before its first use. Reusing an initialized factory, including after a node restart, retains + * its material until {@code init()} is called. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -117,7 +118,7 @@ public void setProtocols(String... protocols) { * that material. The factory does not retain the result. * * Override this method to load a candidate from another source. It must not change the material already in use. - * Use {@link #sslBundle()} to inspect the accepted material. + * Outside loading hooks, use {@link #sslBundle()} to inspect the accepted material. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. From f3b80a04ee59005e1eb0ac7039452f88940d6d20 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 15:25:38 +0300 Subject: [PATCH 099/242] IGNITE-29210 Reload the state owned by SSL factories --- docs/_docs/security/ssl-tls.adoc | 13 +- .../ignite/internal/ssl/SslCertificates.java | 0 .../ignite/ssl/AbstractSslContextFactory.java | 159 ++++++++++++++++-- .../apache/ignite}/ssl/CurrentSslContext.java | 12 +- .../apache/ignite/ssl/SslContextFactory.java | 4 +- .../management/ssl/SslStatusTask.java | 3 +- .../internal/ssl/SslContextProvider.java | 148 +++------------- .../internal/ssl/SslContextProviderTest.java | 71 +++++--- .../ignite/ssl/SslContextMetadataTest.java | 116 +++++++++++-- .../ignite/ssl/SslContextReloadTest.java | 2 +- .../ignite/ssl/SslContextReuseTest.java | 110 +++++++++++- 11 files changed, 456 insertions(+), 182 deletions(-) rename modules/{core => commons}/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java (100%) rename modules/{core/src/main/java/org/apache/ignite/internal => commons/src/main/java/org/apache/ignite}/ssl/CurrentSslContext.java (90%) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 01d7b67119d64..27a49b67f641f 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -302,18 +302,21 @@ This section is for developers integrating a certificate source, such as a PKI s To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by key alias. -The final `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. -Nodes call `createSslBundle()` directly; callers using `Factory` obtain its context through `create()`. +The factory owns the accepted bundle. Its final `create()` method initializes the factory on first use and returns the same forwarding context on subsequent calls, without loading the stores again. +Call `init()` to load and validate a replacement; the node's reload command calls this method too. A successful call replaces the context and its metadata together. If loading or validation fails, the factory keeps the previous bundle. +Outside loading hooks, use `sslBundle()` to inspect the accepted material. New engines and socket factories obtained from the forwarding context use that material. Previously obtained engines, socket factories and sockets keep their original material. + +State belongs to the factory instance. Reusing an initialized factory, including across embedded node restarts, retains its material until `init()` is called. Create a separate factory instance for each node. If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. To use custom key managers, override `createSslBundle()` to initialize a fresh `SSLContext` with those managers and return it with matching certificate metadata in an `SslBundle`. -The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates. +The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates, and leave the accepted material unchanged: `init()` publishes the candidate after validation. The bundle lists nonempty X.509 certificate chains associated with key entries, indexed by alias. Entries without such a chain are omitted. Key availability and selection still depend on the configured key managers. An empty map means no such chains were found, and node reload rejects it. Use `null` when certificate metadata is unknown. Every list in a supplied map must be nonempty. -See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. +See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#init()[initialization contract] for the validation applied at startup and on subsequent calls. === Migrating an Existing Factory @@ -321,7 +324,7 @@ The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have * Move custom store loading into `loadKeyMaterial()`, returning the loaded store and its settings. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. * Remove `create()` overrides. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. -* Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. +* Remove custom context caching. The base factory retains the accepted bundle and provides a stable context through `create()`. Use `init()` to load replacement material. A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java similarity index 100% rename from modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java rename to modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 2ad80ad8f8a5e..8f3d1b13f53ec 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -19,6 +19,10 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.List; +import java.util.Map; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; @@ -26,11 +30,16 @@ import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; /** - * Builds a fresh SSL context and its certificate metadata on each call. The component using the context is responsible for - * retaining it between connections. + * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; + * {@link #create()} returns a stable context. Its newly created engines and newly obtained socket factories use the + * accepted material; previously obtained engines, socket factories and sockets retain their original material. + * Configure the factory before its first use. Reusing an initialized factory, including after a node restart, retains + * its material until {@code init()} is called. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -39,6 +48,15 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default SSL protocol. */ public static final String DFLT_SSL_PROTOCOL = "TLS"; + /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ + private transient volatile SslBundle bundle; + + /** Stable context returned to consumers after the first successful initialization. */ + private transient volatile SSLContext sharedCtx; + + /** Snapshot of the last successful reload and subsequent failures. */ + private transient volatile ReloadOutcome outcome; + /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -107,10 +125,9 @@ public void setProtocols(String... protocols) { /** * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from * that material. The factory does not retain the result. - *

- * The node uses this method at startup and for {@code control.sh --ssl reload}. A node never calls this method concurrently - * for one factory object. A factory shared by several nodes in one JVM, or also used by application code, must be thread-safe. - * A factory that fetches certificates remotely must bound each request and respond to thread interruption. + * + * Override this method to load a candidate from another source. It must not change the material already in use. + * Outside loading hooks, use {@link #sslBundle()} to inspect the accepted material. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. @@ -196,12 +213,134 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Builds a fresh context through {@link #createSslBundle()}. Override that method to customize both entry points. + * Loads keys and trust settings and atomically replaces the accepted context and its metadata. A failed initialization + * leaves the previous material in use. New engines and socket factories obtained through {@link #create()} use the + * replacement; already opened connections keep their original material. * - * @return Fresh initialized context. - * @throws IgniteException If the context could not be created. + * The first initialization permits material without certificate chains and certificates outside their validity period, + * matching node startup. Later calls reject a context already in use, known empty certificate metadata and chains with + * any expired or not-yet-valid certificate. Peers must trust the replacement; this method does not test connections. + * + * @return Context and metadata accepted by this initialization. + * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. + */ + public final synchronized SslBundle init() { + try { + SslBundle next = createSslBundle(); + + if (bundle == null) + next.context().createSSLEngine(); + else + checkReplacement(next); + + SSLContext shared = sharedCtx; + + if (shared == null) + shared = new CurrentSslContext(next.context(), () -> bundle.context()); + + boolean reload = bundle != null; + + bundle = next; + sharedCtx = shared; + + if (reload) + outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); + + return next; + } + catch (Throwable e) { + if (bundle != null) { + ReloadOutcome previous = reloadOutcome(); + + outcome = new ReloadOutcome(previous.successTime(), System.currentTimeMillis(), + SslCertificates.reason(e), previous.failures() + 1); + } + + throw e; + } + } + + /** + * @param next Replacement to check before it is published. + * @throws IgniteException If the context is already in use, known to have no certificate chains, or a certificate is invalid. + */ + private void checkReplacement(SslBundle next) { + Map> chains = next.certificateChains(); + + if (chains != null && chains.isEmpty()) { + throw new IgniteException("The new key material has no X.509 certificate chains, " + + "so the node would present no certificate"); + } + + next.context().createSSLEngine(); + + if (next.context() == bundle.context()) { + throw new IgniteException("The SSL context factory returns the context in use, so the node gets no new " + + "certificates from it"); + } + + if (chains == null) + return; + + long now = System.currentTimeMillis(); + + for (Map.Entry> entry : chains.entrySet()) { + X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), now); + + if (invalid != null) { + throw new IgniteException("The new certificate chain is not valid now [alias=" + entry.getKey() + ", " + + SslCertificates.describe(invalid) + ", now=" + Instant.ofEpochMilli(now) + ']'); + } + } + } + + /** @return Snapshot of the last successful reload and subsequent failures; zero times before any reload. */ + public final ReloadOutcome reloadOutcome() { + ReloadOutcome res = outcome; + + return res == null ? new ReloadOutcome(0, 0, null, 0) : res; + } + + /** + * Snapshot of the last successful reload and subsequent failures. + * + * @param successTime Time of the last successful reload, {@code 0} if there was none. + * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. + * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. + * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. + */ + public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { + // No-op. + } + + /** + * Returns the accepted context and its metadata, initializing the factory on first use. + * + * @return One snapshot of the material in use. + * @throws IgniteException If the first initialization fails. + */ + public final SslBundle sslBundle() { + create(); + + return bundle; + } + + /** + * Returns the same forwarding context on every call, initializing it on first use. Obtain new engines and socket + * factories from this context to use the material accepted by the last successful {@link #init()}. Repeated calls + * do not read the key or trust stores. + * + * @return Stable initialized context. + * @throws IgniteException If the first initialization fails. */ @Override public final SSLContext create() { - return createSslBundle().context(); + if (sharedCtx == null) { + synchronized (this) { + if (sharedCtx == null) + init(); + } + } + + return sharedCtx; } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java similarity index 90% rename from modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java rename to modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java index d80377dd4bcc8..5d0903870e41f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java @@ -14,7 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.ignite.internal.ssl; + +package org.apache.ignite.ssl; import java.security.SecureRandom; import java.util.function.Supplier; @@ -30,9 +31,12 @@ /** Delegates to the current SSL context so that new connections use reloaded certificates. */ class CurrentSslContext extends SSLContext { - /** @param ctx Supplier of the context currently in use. */ - CurrentSslContext(Supplier ctx) { - super(new Spi(ctx), ctx.get().getProvider(), ctx.get().getProtocol()); + /** + * @param initial First accepted context, used for its provider and protocol. + * @param ctx Supplier of the context currently in use. + */ + CurrentSslContext(SSLContext initial, Supplier ctx) { + super(new Spi(ctx), initial.getProvider(), initial.getProtocol()); } /** */ diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 40def4b6f2aa1..35e511dcde177 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -37,8 +37,8 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * Each call loads the stores again and builds a fresh context. The component using it should retain the context between - * connections. + * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. Call {@link #init()} + * to read the stores again and publish the replacement material. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 43e19a5b90434..114c06018ce0c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -30,6 +30,7 @@ import org.apache.ignite.internal.ssl.SslContextProvider; import org.apache.ignite.internal.visor.VisorJob; import org.apache.ignite.lang.IgniteBiTuple; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; @@ -100,7 +101,7 @@ else if (chains.isEmpty()) } } - SslContextProvider.ReloadOutcome outcome = provider.reloadOutcome(); + ReloadOutcome outcome = provider.reloadOutcome(); if (outcome.failures() > 0) { lines.add(DOUBLE_INDENT + "last reload failed " + outcome.failures() + " time(s) in a row, the last at " + diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 7e454e0b7316a..fcd6d9e8d3514 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -18,60 +18,61 @@ package org.apache.ignite.internal.ssl; import java.security.cert.X509Certificate; -import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; +import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; -import static org.apache.ignite.internal.ssl.SslCertificates.describe; - -/** Owns one factory's context and reloads on this node; transports using that factory share its context. */ +/** Registers the transports using a factory; the factory owns their current context and reload results. */ public class SslContextProvider { - /** */ + /** Factory configured by the user, retained for registration by identity. */ private final Factory factory; + /** Factory that owns the context, adapting a plain factory when necessary. */ + private final AbstractSslContextFactory sslFactory; + /** */ private final Set transports = new ConcurrentSkipListSet<>(); - /** Context and certificate metadata in use. */ - private volatile SslBundle bundle; - - /** Stable context handed to every transport using this factory. */ - private final SSLContext sharedCtx; - - /** Snapshot of the last successful reload and subsequent failures. */ - private volatile ReloadOutcome outcome = new ReloadOutcome(0, 0, null, 0); - - /** @param factory Factory to build the context with. */ + /** @param factory Factory the transports are configured with. */ SslContextProvider(Factory factory) { this.factory = factory; - bundle = createBundle(); - bundle.context().createSSLEngine(); + sslFactory = factory instanceof AbstractSslContextFactory f ? f : new SslContextFactory() { + /** {@inheritDoc} */ + @Override public SslBundle createSslBundle() { + return new SslBundle(factory.create(), null); + } + }; - sharedCtx = new CurrentSslContext(this::context); + sslFactory.create(); } - /** @return Factory the context is built with. */ + /** @return Factory the transport is configured with. */ Factory factory() { return factory; } /** @return Context to open the next connection with. */ SSLContext context() { - return bundle.context(); + return bundle().context(); + } + + /** @return Context and certificate metadata currently accepted by the factory. */ + SslBundle bundle() { + return sslFactory.sslBundle(); } /** @return Stable context shared by every transport configured with this factory. */ SSLContext sharedContext() { - return sharedCtx; + return sslFactory.create(); } /** @param transport Transport the context is handed to. */ @@ -86,109 +87,16 @@ public String transports() { /** @return Chains by key alias, or {@code null} when the factory supplies no metadata. */ public @Nullable Map> certificateChains() { - return bundle.certificateChains(); + return bundle().certificateChains(); } - /** @return A new bundle, with unknown certificate metadata for factories that only return a context. */ - private SslBundle createBundle() { - return factory instanceof AbstractSslContextFactory f ? f.createSslBundle() : new SslBundle(factory.create(), null); - } - - /** - * Builds and installs a new context for new connections, recording success or failure. Rejects material known to have no - * X.509 certificate chains. Every supplied certificate must be within its validity period. - * The administrator must ensure that peers trust the new certificates and can connect with the new context. - * - * @return Context and certificate metadata installed by this reload. - * @throws IgniteCheckedException If the new context fails a check or the factory returns the context in use. On any failure, - * including a factory exception, the current context is retained. - */ - public synchronized SslBundle reload() throws IgniteCheckedException { - try { - SslBundle rebuilt = build(); - - if (rebuilt.context() == bundle.context()) - throw new IgniteCheckedException("The SSL context factory returns the context in use, so the node gets no new " + - "certificates from it"); - - check(rebuilt.certificateChains()); - - bundle = rebuilt; - } - catch (Throwable e) { - onFailure(e); - - throw e; - } - - onSuccess(); - - return bundle; - } - - /** - * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material has no X.509 certificate chains. - */ - private SslBundle build() throws IgniteCheckedException { - SslBundle rebuilt = createBundle(); - Map> chains = rebuilt.certificateChains(); - - if (chains != null && chains.isEmpty()) - throw new IgniteCheckedException("The new key material has no X.509 certificate chains, " + - "so the node would present no certificate"); - - rebuilt.context().createSSLEngine(); - - return rebuilt; - } - - /** - * @param next Chains loaded with the new context, {@code null} if they are unknown. - * @throws IgniteCheckedException If a certificate is outside its validity period. - */ - private void check(@Nullable Map> next) throws IgniteCheckedException { - if (next == null) - return; - - long now = System.currentTimeMillis(); - - for (Map.Entry> entry : next.entrySet()) { - X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), now); - - if (invalid != null) { - throw new IgniteCheckedException("The new certificate chain is not valid now [alias=" + entry.getKey() + ", " + - describe(invalid) + ", now=" + Instant.ofEpochMilli(now) + ']'); - } - } - } - - /** Records a successful reload. */ - private void onSuccess() { - outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); - } - - /** @param e Why the reload failed. */ - private void onFailure(Throwable e) { - ReloadOutcome o = outcome; - - outcome = new ReloadOutcome(o.successTime(), System.currentTimeMillis(), SslCertificates.reason(e), o.failures() + 1); + /** @return Context and certificate metadata accepted by the factory's initialization. */ + public SslBundle reload() { + return sslFactory.init(); } /** @return Snapshot of the last successful reload and subsequent failures. */ public ReloadOutcome reloadOutcome() { - return outcome; - } - - /** - * Snapshot of the last successful reload and subsequent failures. - * - * @param successTime Time of the last successful reload, {@code 0} if there was none. - * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. - * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. - * @param failures Failed reloads in a row since the last successful one. - */ - public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { - // No-op. + return sslFactory.reloadOutcome(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index eb7fa6453df60..12d265a77fabb 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -28,8 +28,8 @@ import javax.net.ssl.SSLParameters; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; -import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.testframework.GridTestUtils; @@ -49,16 +49,15 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** - * What {@link SslContextProvider#reload()} puts in use and records, what it refuses; the registry keeps one provider per factory - * object and warns at registration about key material that reload refuses. Reload refusals follow the order of the checks in the - * code: nonempty X.509 chains when metadata is available, an engine from the new context, a context not in use, - * certificates valid now. + * The provider exposes the material and reload results owned by its factory. Reload validates the complete candidate before + * replacing the material in use. The registry shares a provider between transports using the same factory object. */ public class SslContextProviderTest extends GridCommonAbstractTest { /** */ @@ -82,9 +81,9 @@ public void testReloadInstallsNewBundle() throws Exception { KeyStore a = otherCa.keyStoreWithPrivateKey("a"); KeyStore b = otherCa.keyStoreWithPrivateKey("b"); SslBundle next = bundle(merged(a, b), ca.trustStore()); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next); + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -96,6 +95,29 @@ public void testReloadInstallsNewBundle() throws Exception { assertTrue(p.reloadOutcome().successTime() > 0); } + /** Calling the factory directly updates the provider's material and outcome without replacing its shared context. */ + @Test + public void testDirectFactoryInitUpdatesProvider() throws Exception { + SslBundle next = bundle(ca.keyStoreWithPrivateKey("replacement"), ca.trustStore()); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); + + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next).when(f).createSslBundle(); + + SslContextProvider p = new SslContextProvider(f); + SSLContext shared = p.sharedContext(); + + assertEquals(new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); + + f.init(); + + assertSame(next, p.bundle()); + assertSame(next.context(), p.context()); + assertEquals(next.certificateChains(), p.certificateChains()); + assertSame(shared, p.sharedContext()); + assertSame(f.reloadOutcome(), p.reloadOutcome()); + assertTrue(p.reloadOutcome().successTime() > 0); + } + /** * A failed reload keeps the context, metadata and success time in use, counts failures in a row and records the time and the * reason without the messages of wrappers; a successful reload clears the failures. @@ -105,12 +127,12 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { SslBundle reloaded = bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore()); KeyStore recoveredKeys = ca.keyStoreWithPrivateKey("recovered"); IgniteException failure = new IgniteException("Failed to load", new SSLException(new IOException("bad file"))); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()) - .thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), reloaded) - .thenThrow(failure, failure) - .thenReturn(bundle(recoveredKeys, ca.trustStore())); + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), reloaded) + .doThrow(failure, failure) + .doReturn(bundle(recoveredKeys, ca.trustStore())) + .when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -142,11 +164,11 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { /** Key material with no certificate chains, such as a trust store put in place of the key store, is not applied. */ @Test public void testMaterialWithoutCertificateChainsNotApplied() throws Exception { - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.trustStore(), ca.trustStore())); + bundle(ca.trustStore(), ca.trustStore())).when(f).createSslBundle(); assertReloadRejected(new SslContextProvider(f), "The new key material has no X.509 certificate chains, so the node would present no certificate"); @@ -160,11 +182,12 @@ public void testUnsupportedProtocolNotApplied() throws Exception { params.setProtocols(new String[] {"SSLvDoesNotExist"}); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())); + new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())) + .when(f).createSslBundle(); assertReloadRejected(new SslContextProvider(f), "SSLvDoesNotExist"); } @@ -184,12 +207,12 @@ public void testEveryCertificateMustBeValidNow() throws Exception { KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); KeyStore notYetValid = ca.keyStoreWithPrivateKey("z-not-yet-valid", now + HOUR, now + 2 * HOUR); KeyStore expiredCaLeaf = new TestCertificateAuthority("expired-ca", now - 2 * HOUR, now - HOUR).keyStoreWithPrivateKey("leaf"); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), bundle(merged(valid, notYetValid), ca.trustStore()), - bundle(expiredCaLeaf, ca.trustStore())); + bundle(expiredCaLeaf, ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -227,9 +250,9 @@ record Fixed(SSLContext ctx) implements Factory { /** Registration warns when the key material has no certificate chains, here a trust store put in place of the key store. */ @Test public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); + doReturn(bundle(ca.trustStore(), ca.trustStore())).when(f).createSslBundle(); ListeningTestLogger registryLog = new ListeningTestLogger(log); LogListener warned = LogListener.matches("The key material has no X.509 certificate chains, " + diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index f637b7fe1481a..9b8c3f7a92d15 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -26,6 +26,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -43,10 +45,9 @@ import static org.mockito.Mockito.mock; /** - * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, - * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering - * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, - * and rejects empty per-alias chains. {@link SslContextFactory} names the store whose key it cannot open. + * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous + * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} reports nonempty X.509 chains + * without recovering keys and keeps its own key password. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -57,44 +58,131 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each bundle reads the key store once, builds a new context and takes its metadata from that read. */ + /** Each initialization publishes a fresh context with metadata from the same, single key store read. */ @Test - public void testEachBundleReadsKeyStoreOnce() throws Exception { + public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { KeyStore first = ca.keyStoreWithPrivateKey("first"); InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - SslBundle firstBundle = factory.createSslBundle(); + SslBundle firstBundle = factory.init(); assertEquals(1, factory.loads); + assertSame(firstBundle, factory.sslBundle()); assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); + SSLContext shared = factory.create(); KeyStore second = ca.keyStoreWithPrivateKey("second"); factory.store = second; - SslBundle secondBundle = factory.createSslBundle(); + SslBundle secondBundle = factory.init(); assertEquals(2, factory.loads); + assertSame(secondBundle, factory.sslBundle()); assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); + assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); assertNotSame(firstBundle.context(), secondBundle.context()); + assertSame(shared, factory.create()); } - /** A failed read is thrown instead of reusing an earlier context, and the next call reads the store again. */ + /** Concurrent first calls initialize once; obtaining the context and accepted snapshot never reloads the store. */ @Test - public void testFailedLoadCanBeRetried() throws Exception { + public void testCreateAndSnapshotDoNotReloadStore() throws Exception { InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + int threadCnt = 4; + CyclicBarrier start = new CyclicBarrier(threadCnt); + AtomicReference sharedRef = new AtomicReference<>(); + + GridTestUtils.runMultiThreaded(() -> { + start.await(); + + SSLContext ctx = factory.create(); + + sharedRef.compareAndSet(null, ctx); + assertSame(sharedRef.get(), ctx); + + return null; + }, threadCnt, "ssl-context-create"); + + SslBundle accepted = factory.sslBundle(); + SSLContext shared = sharedRef.get(); + + factory.loadFailure = "Store must not be read again"; + + assertSame(shared, factory.create()); + assertSame(accepted, factory.sslBundle()); + assertEquals(1, factory.loads); + } - factory.create(); + /** A failed reload leaves both the accepted snapshot and the forwarding context usable, and can be retried. */ + @Test + public void testFailedInitRetainsAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); factory.loadFailure = "Key store is unavailable"; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "Key store is unavailable"); + Throwable err = GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); assertTrue(err.getCause() instanceof SSLException); + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(2, factory.loads); + + factory.loadFailure = null; + + SslBundle replacement = factory.init(); + + assertNotSame(accepted, replacement); + assertSame(replacement, factory.sslBundle()); + assertSame(shared, factory.create()); + assertEquals(3, factory.loads); + } + + /** A failed first initialization leaves the factory able to initialize on the next context request. */ + @Test + public void testFirstInitializationFailureCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + + factory.loadFailure = "Key store is unavailable"; + + GridTestUtils.assertThrows(log, factory::create, IgniteException.class, factory.loadFailure); factory.loadFailure = null; - factory.create(); + SSLContext shared = factory.create(); + + assertSame(shared, factory.create()); + assertNotNull(factory.sslBundle().context().createSSLEngine()); + assertEquals(2, factory.loads); + } + + /** Unsupported TLS parameters are rejected before a replacement becomes visible. */ + @Test + public void testInvalidTlsParametersDoNotReplaceAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); + + factory.setProtocols("unsupported-protocol"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + + factory.setProtocols((String[])null); + factory.setCipherSuites("unsupported-cipher-suite"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(3, factory.loads); } /** Missing parameters are reported before the key store is read. */ @@ -145,7 +233,7 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { empty.load(null, null); - SslBundle bundle = new InMemoryStoreFactory(empty).createSslBundle(); + SslBundle bundle = new InMemoryStoreFactory(empty).init(); assertEquals(Map.of(), bundle.certificateChains()); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 51ece516974b1..68d244df90bb5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -602,7 +602,7 @@ private CountingFactory(Path keys, Path trust) { @Override public SSLContext create() { loads.incrementAndGet(); - return delegate.create(); + return delegate.createSslBundle().context(); } } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 19792613b6650..242487c346b91 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -17,18 +17,33 @@ package org.apache.ignite.ssl; +import java.io.File; +import java.io.FileOutputStream; +import java.security.KeyStore; import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; -/** Communication keeps one SSL context: new connections do not call the SSL context factory on either node. */ +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.junit.Assert.assertArrayEquals; + +/** Consumers retain one SSL context while initialization changes the material used by new connections. */ public class SslContextReuseTest extends GridCommonAbstractTest { + /** Stores generated independently for each test. */ + @Rule public final TemporaryFolder tmp = new TemporaryFolder(); + /** {@inheritDoc} */ @Override protected void afterTest() throws Exception { stopAllGrids(); @@ -36,6 +51,78 @@ public class SslContextReuseTest extends GridCommonAbstractTest { super.afterTest(); } + /** New engines and socket factories use initialized TLS settings; previously obtained ones retain their settings. */ + @Test + public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Exception { + SslContextFactory factory = fileFactory(); + String[] firstProtocols = {"TLSv1.2"}; + String[] firstCiphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; + + factory.setProtocols(firstProtocols); + factory.setCipherSuites(firstCiphers); + + SSLContext shared = factory.create(); + SSLEngine existing = shared.createSSLEngine(); + SSLSocketFactory capturedFactory = shared.getSocketFactory(); + String[] replacementProtocols = {"TLSv1.3"}; + String[] replacementCiphers = {"TLS_AES_128_GCM_SHA256"}; + + factory.setProtocols(replacementProtocols); + factory.setCipherSuites(replacementCiphers); + + SslBundle replacement = factory.init(); + String host = "peer.example"; + int port = 10800; + SSLEngine addressed = shared.createSSLEngine(host, port); + SSLEngine unaddressed = shared.createSSLEngine(); + + assertSame(shared, factory.create()); + assertSame(replacement.context().getClientSessionContext(), shared.getClientSessionContext()); + assertSame(replacement.context().getServerSessionContext(), shared.getServerSessionContext()); + assertEquals(host, addressed.getPeerHost()); + assertEquals(port, addressed.getPeerPort()); + assertArrayEquals(replacementProtocols, addressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, addressed.getEnabledCipherSuites()); + assertNull(unaddressed.getPeerHost()); + assertEquals(-1, unaddressed.getPeerPort()); + assertArrayEquals(replacementProtocols, unaddressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, unaddressed.getEnabledCipherSuites()); + assertArrayEquals(firstProtocols, existing.getEnabledProtocols()); + assertArrayEquals(firstCiphers, existing.getEnabledCipherSuites()); + + try (SSLSocket captured = (SSLSocket)capturedFactory.createSocket(); + SSLSocket current = (SSLSocket)shared.getSocketFactory().createSocket()) { + assertArrayEquals(firstProtocols, captured.getEnabledProtocols()); + assertArrayEquals(firstCiphers, captured.getEnabledCipherSuites()); + assertArrayEquals(replacementProtocols, current.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, current.getEnabledCipherSuites()); + } + } + + /** An initialized factory serializes its configuration and initializes fresh runtime state after deserialization. */ + @Test + public void testInitializedFactorySerializationKeepsConfiguration() throws Exception { + SslContextFactory factory = fileFactory(); + String[] protocols = {"TLSv1.2"}; + + factory.setProtocols(protocols); + + SslBundle original = factory.init(); + SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), + getClass().getClassLoader()); + + assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); + assertEquals(factory.getTrustStoreFilePath(), restored.getTrustStoreFilePath()); + assertArrayEquals(protocols, restored.getProtocols()); + + SSLContext shared = restored.create(); + + assertNotSame(factory.create(), shared); + assertSame(shared, restored.create()); + assertArrayEquals(protocols, shared.createSSLEngine().getEnabledProtocols()); + assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); + } + /** Reopening a communication connection does not call the SSL context factory on either node. */ @Test public void testCommunicationReusesContextOnReconnect() throws Exception { @@ -62,4 +149,25 @@ public void testCommunicationReusesContextOnReconnect() throws Exception { assertEquals(createdAtStart, created.get()); } + + /** Creates a file-backed factory with a generated private key and currently valid certificate chain. */ + private SslContextFactory fileFactory() throws Exception { + KeyStore store = new TestCertificateAuthority("context-reuse-ca").keyStoreWithPrivateKey(); + File storeFile = tmp.newFile("context.jks"); + + try (FileOutputStream out = new FileOutputStream(storeFile)) { + store.store(out, PWD); + } + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreType(store.getType()); + factory.setKeyStoreFilePath(storeFile.getAbsolutePath()); + factory.setKeyStorePassword(PWD); + factory.setTrustStoreType(store.getType()); + factory.setTrustStoreFilePath(storeFile.getAbsolutePath()); + factory.setTrustStorePassword(PWD); + + return factory; + } } From 4c35f3221b63d4cc598d62a718c159016faa329e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 15:26:38 +0300 Subject: [PATCH 100/242] IGNITE-29211 Renew the material owned by the SSL factory --- docs/_docs/security/ssl-tls.adoc | 14 +- .../ignite/internal/ssl/SslCertificates.java | 31 +- .../ignite/internal/ssl/SslRenewal.java | 61 ++-- .../ignite/ssl/AbstractSslContextFactory.java | 330 +++++++++++++++++- .../apache/ignite}/ssl/CurrentSslContext.java | 12 +- .../apache/ignite/ssl/SslContextFactory.java | 4 +- .../management/ssl/SslStatusTask.java | 3 +- .../internal/ssl/SslContextProvider.java | 301 ++-------------- .../internal/ssl/SslContextProviderTest.java | 71 ++-- .../internal/ssl/SslRenewalNodeTest.java | 36 +- .../ignite/internal/ssl/SslRenewalTest.java | 303 +++++++++++----- .../ignite/ssl/SslContextMetadataTest.java | 116 +++++- .../ignite/ssl/SslContextReloadTest.java | 2 +- .../ignite/ssl/SslContextReuseTest.java | 110 +++++- 14 files changed, 964 insertions(+), 430 deletions(-) rename modules/{core => commons}/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java (77%) rename modules/{core => commons}/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java (81%) rename modules/{core/src/main/java/org/apache/ignite/internal => commons/src/main/java/org/apache/ignite}/ssl/CurrentSslContext.java (90%) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index ecffbce52ebaa..8801a7d824206 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -353,20 +353,24 @@ This section is for developers integrating a certificate source, such as a PKI s To support reload and automatic renewal with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. Its `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by key alias. -The final `create()` method returns `createSslBundle().context()`: each call creates a new context, with no cache in the factory. -Nodes call `createSslBundle()` directly; callers using `Factory` obtain its context through `create()`. +The factory owns the accepted bundle. Its final `create()` method initializes the factory on first use and returns the same forwarding context on subsequent calls, without loading the stores again. +Call `init()` to load and validate a replacement; the node's reload command calls this method too. A successful call replaces the context and its metadata together. If loading or validation fails, the factory keeps the previous bundle. +Use `sslBundle()` to inspect the accepted material. Newly created engines and newly obtained socket factories use that material; existing engines, socket factories and sockets retain their original material. If the source provides a key store, override `loadKeyMaterial()` to return link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] containing that loaded store, its key password and key manager algorithm. The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. To use custom key managers, override `createSslBundle()` to initialize a fresh `SSLContext` with those managers and return it with matching certificate metadata in an `SslBundle`. -The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates. +The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates, and leave the accepted material unchanged: `init()` publishes the candidate after validation. The bundle lists nonempty X.509 certificate chains associated with key entries, indexed by alias. Entries without such a chain are omitted. Key availability and selection still depend on the configured key managers. An empty map means no such chains were found, and node reload rejects it. Use `null` when certificate metadata is unknown. Every list in a supplied map must be nonempty. With automatic renewal enabled, the bundle must contain at least one X.509 chain at startup and on each replacement. Without renewal, metadata is optional. -See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#createSslBundle()[method contract] for call ordering, concurrent calls and remote request handling. +The factory serializes initialization and renewal and owns their outcome and failure counters. A direct call to `init()` also replans automatic renewal. Bound remote requests and respond to thread interruption so that stopping the node can stop a renewal attempt. + +One factory instance shares its accepted material across all its consumers. Configure a separate factory instance for each node that enables automatic renewal: its renewal runtime belongs to that node's executor. After that node stops, the same factory can attach to the restarted node. +See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#init()[initialization contract] for the validation applied at startup and on subsequent calls. === Migrating an Existing Factory @@ -374,7 +378,7 @@ The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have * Move custom store loading into `loadKeyMaterial()`, returning the loaded store and its settings. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. * Remove `create()` overrides. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. -* Move any context caching needed by an external caller into that caller. The node keeps the current bundle and replaces it on reload. +* Remove custom context caching. The base factory retains the accepted bundle and provides a stable context through `create()`. Use `init()` to load replacement material. A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java similarity index 77% rename from modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java rename to modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index c18ee7f9706f8..88fe6068bdbe8 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -22,9 +22,10 @@ import java.util.List; import java.util.Map; import org.apache.ignite.internal.util.typedef.X; +import org.apache.ignite.ssl.SslBundle; import org.jetbrains.annotations.Nullable; -/** Certificate dates and descriptions used by SSL reload, renewal and status. */ +/** Certificate dates and descriptions used by SSL reload and status. */ public class SslCertificates { /** */ private SslCertificates() { @@ -44,6 +45,34 @@ public static long chainNotAfter(Iterable chain) { return res; } + /** + * Chooses the first chain to expire, using the earliest leaf validity start to break ties. + * + * @param bundle Context and its chains. + * @return Chain that determines renewal, or {@code null} if certificate metadata is unknown or contains no chains. + */ + public static @Nullable List renewalChain(SslBundle bundle) { + Map> chains = bundle.certificateChains(); + + if (chains == null || chains.isEmpty()) + return null; + + List earliest = null; + long earliestExpiry = Long.MAX_VALUE; + + for (List chain : chains.values()) { + long expiry = chainNotAfter(chain); + + if (earliest == null || expiry < earliestExpiry || + expiry == earliestExpiry && chain.get(0).getNotBefore().before(earliest.get(0).getNotBefore())) { + earliest = chain; + earliestExpiry = expiry; + } + } + + return earliest; + } + /** * @param chain Chain. * @param time Time to check, in milliseconds since the epoch. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java similarity index 81% rename from modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java rename to modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java index 48a487de8124a..c5f0b587bf809 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -26,11 +26,12 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.function.LongSupplier; +import java.util.function.Supplier; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; -import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.internal.util.CommonUtils; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslBundle; import org.jetbrains.annotations.Nullable; @@ -39,7 +40,7 @@ * Renews one factory's certificates before expiry when {@link AbstractSslContextFactory#setRenewalEnabled(boolean) renewal is enabled}. * Renewal planning and attempts run in the node's renewal thread. */ -class SslRenewal { +public class SslRenewal { /** How the node log names an automatic renewal in {@code initiator=}. */ private static final String INITIATOR = "automatic renewal"; @@ -55,7 +56,10 @@ class SslRenewal { private static long maxPause = 3_600_000L; /** */ - private final SslContextProvider provider; + private final AbstractSslContextFactory factory; + + /** Transports using this factory, including those registered after renewal starts. */ + private final Supplier transports; /** Share of the certificate lifetime left when the renewal window opens. */ private final double renewBeforeFraction; @@ -91,30 +95,40 @@ class SslRenewal { private int waitLoggedLateness; /** - * @param provider Provider whose context the factory builds. + * @param factory Factory that owns the accepted material. * @param renewBeforeFraction Share of the certificate lifetime left when the renewal window opens. * @param exec Renewal thread. * @param log Logger. + * @param transports Names of the transports currently using this factory. */ - SslRenewal(SslContextProvider provider, double renewBeforeFraction, ScheduledExecutorService exec, IgniteLogger log) { - this(provider, renewBeforeFraction, exec, log, System::currentTimeMillis); + public SslRenewal( + AbstractSslContextFactory factory, + double renewBeforeFraction, + ScheduledExecutorService exec, + IgniteLogger log, + Supplier transports + ) { + this(factory, renewBeforeFraction, exec, log, transports, System::currentTimeMillis); } /** - * @param provider Provider whose context the factory builds. + * @param factory Factory that owns the accepted material. * @param renewBeforeFraction Share of the certificate lifetime left when the renewal window opens. * @param exec Renewal thread. * @param log Logger. + * @param transports Names of the transports currently using this factory. * @param currentTimeMillis Current time in milliseconds. */ SslRenewal( - SslContextProvider provider, + AbstractSslContextFactory factory, double renewBeforeFraction, ScheduledExecutorService exec, IgniteLogger log, + Supplier transports, LongSupplier currentTimeMillis ) { - this.provider = provider; + this.factory = factory; + this.transports = transports; this.renewBeforeFraction = renewBeforeFraction; this.exec = exec; this.log = log; @@ -122,12 +136,12 @@ class SslRenewal { } /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ - long nextRenewalTime() { - return nextRenewalTime; + public long nextRenewalTime() { + return exec.isShutdown() ? 0 : nextRenewalTime; } /** Plans the next renewal in the renewal thread. */ - void replan() { + public void replan() { submit(this::plan, 0); } @@ -135,8 +149,8 @@ void replan() { private void plan() { pause = waitLogTime = 0; - SslBundle planned = provider.bundle(); - List chain = SslContextProvider.renewalChain(planned); + SslBundle planned = factory.sslBundle(); + List chain = SslCertificates.renewalChain(planned); chainNotAfter = SslCertificates.chainNotAfter(chain); @@ -145,11 +159,11 @@ private void plan() { window = (long)(lifetime * renewBeforeFraction); long at = Math.max(chainNotAfter - window, - Math.max(provider.reloadOutcome().successTime() + minPause, currentTimeMillis.getAsLong())); + Math.max(factory.reloadOutcome().successTime() + minPause, currentTimeMillis.getAsLong())); schedule(at, planned.context()); - U.log(log, "TLS certificates will be renewed automatically [transports=" + provider.transports() + + CommonUtils.log(log, "TLS certificates will be renewed automatically [transports=" + transports.get() + ", nextRenewal=" + Instant.ofEpochMilli(at) + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) + ']'); } @@ -160,13 +174,13 @@ private void plan() { */ private void attempt(SSLContext planned) { try { - SslBundle renewed = provider.renew(planned); + SslBundle renewed = factory.renew(planned); if (renewed != null) { - U.log(log, "TLS certificates reloaded [transports=" + provider.transports() + ", " + + CommonUtils.log(log, "TLS certificates reloaded [transports=" + transports.get() + ", " + SslCertificates.describeChains(renewed.certificateChains()) + ", initiator=" + INITIATOR + ']'); } - else if (provider.context() == planned) + else if (factory.sslBundle().context() == planned) retry(planned, null); } catch (Throwable e) { @@ -208,14 +222,14 @@ private void retry(SSLContext planned, @Nullable Throwable e) { String msg = (e == null ? "The SSL context factory has no newer TLS certificates yet, the ones in use stay" : "Failed to reload TLS certificates, the ones in use stay") + (lateness == 2 ? ", though they have expired" : lateness == 1 ? " and expire soon" : "") + - " [transports=" + provider.transports() + + " [transports=" + transports.get() + (e == null ? "" : ", initiator=" + INITIATOR + ", reason=" + SslCertificates.reason(e)) + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) + ", nextRenewal=" + Instant.ofEpochMilli(at) + ']'; if (soon) - U.error(log, msg, e); + CommonUtils.error(log, msg, e); else - U.warn(log, msg, e); + CommonUtils.warn(log, msg, e); } /** @@ -229,6 +243,9 @@ private void schedule(long at, SSLContext planned) { nextRenewalTime = at; next = submit(() -> attempt(planned), Math.max(0, at - currentTimeMillis.getAsLong())); + + if (next == null) + nextRenewalTime = 0; } /** diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index afbbb9f5641e0..6e40de59ba640 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -19,18 +19,35 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Supplier; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; +import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteInterruptedException; +import org.apache.ignite.IgniteLogger; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.internal.ssl.SslCertificates; +import org.apache.ignite.internal.ssl.SslRenewal; +import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; /** - * Builds a fresh SSL context and its certificate metadata on each call. The component using the context is responsible for - * retaining it between connections. + * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; + * {@link #create()} returns a stable context. Its newly created engines and newly obtained socket factories use the + * accepted material; previously obtained engines, socket factories and sockets retain their original material. + * Configure the factory before its first use. Reusing an initialized factory, including after a node restart, retains + * its material until {@code init()} is called. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -42,6 +59,24 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default share of the certificate lifetime left when a node starts to renew it. */ public static final double DFLT_RENEW_BEFORE_FRACTION = 0.15; + /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ + private transient volatile SslBundle bundle; + + /** Stable context returned to consumers after the first successful initialization. */ + private transient volatile SSLContext sharedCtx; + + /** Snapshot of the last successful reload and subsequent failures. */ + private transient volatile ReloadOutcome outcome; + + /** Automatic renewal attached by the node; the node owns its executor. */ + private transient volatile SslRenewal renewal; + + /** Executor whose attachment fixes the renewal settings until that node stops. */ + private transient ScheduledExecutorService renewalExec; + + /** Failed automatic attempts since the earliest expiry across all chains was advanced. */ + private transient volatile int renewalFailures; + /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -51,10 +86,10 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; - /** Whether a node builds its SSL context again before the certificate expires, to pick up a new certificate. */ + /** Whether automatic renewal is requested when a node attaches its renewal executor. */ private boolean renewalEnabled; - /** Share of the certificate lifetime left when a node starts to renew it. */ + /** Share of the certificate lifetime left when renewal starts. */ private double renewBeforeFraction = DFLT_RENEW_BEFORE_FRACTION; /** @@ -155,10 +190,9 @@ public void setRenewBeforeFraction(double renewBeforeFraction) { /** * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from * that material. The factory does not retain the result. - *

- * The node uses this method at startup, for {@code control.sh --ssl reload} and for automatic renewal. A node never calls this - * method concurrently for one factory object. A factory shared by several nodes in one JVM, or also used by application code, - * must be thread-safe. A factory that fetches certificates remotely must bound each request and respond to thread interruption. + * + * Override this method to load a candidate from another source. It must not change the material already in use. + * Outside loading hooks, use {@link #sslBundle()} to inspect the accepted material. * * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. @@ -244,12 +278,284 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Builds a fresh context through {@link #createSslBundle()}. Override that method to customize both entry points. + * Loads keys and trust settings and atomically replaces the accepted context and its metadata. A failed initialization + * leaves the previous material in use. New engines and socket factories obtained through {@link #create()} use the + * replacement; already opened connections keep their original material. * - * @return Fresh initialized context. - * @throws IgniteException If the context could not be created. + * The first initialization permits material without certificate chains and certificates outside their validity period, + * matching node startup. Later calls reject a context already in use, known empty certificate metadata and chains with + * any expired or not-yet-valid certificate. Peers must trust the replacement; this method does not test connections. + * While automatic renewal is attached, replacement metadata must contain at least one chain. A successful replacement + * replans renewal and clears its unsuccessful-attempt count only when the earliest chain expiry advances. + * + * @return Context and metadata accepted by this initialization. + * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. + */ + public final synchronized SslBundle init() { + try { + SslBundle next; + boolean reload = bundle != null; + + if (reload) { + next = buildReplacement(); + + checkReplacement(next, renewal != null); + } + else { + next = createSslBundle(); + next.context().createSSLEngine(); + } + + putInUse(next); + + if (reload) + onSuccess(); + + return next; + } + catch (Throwable e) { + if (bundle != null) + onFailure(e); + + throw e; + } + } + + /** + * Attaches the node's automatic renewal executor. The current renewal settings apply until that executor stops. + * Repeated attachment to the same running node changes nothing. A factory with renewal enabled cannot serve two running + * nodes with separate renewal executors; configure a separate factory instance on each node. After a node stops, its + * factory can attach to a new executor, using the settings configured for that restart. + * + * @param exec Renewal executor owned and stopped by the node. + * @param log Renewal logger. + * @param transports Names of the transports currently using this factory, evaluated when logging. + * @throws IgniteException If renewal is already attached to another running node or certificate metadata is unavailable. + */ + public final synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { + if (renewalExec != null && !renewalExec.isShutdown()) { + if (renewalExec != exec && (renewal != null || renewalEnabled)) + throw new IgniteException("The SSL context factory already renews certificates for another running node; " + + "configure a separate factory instance on each node"); + + return; + } + + renewal = null; + renewalExec = null; + + if (renewalEnabled && SslCertificates.renewalChain(sslBundle()) == null) { + throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + + "to renew automatically [transports=" + transports.get() + ']'); + } + + renewalExec = exec; + + if (renewalEnabled) { + renewal = new SslRenewal(this, renewBeforeFraction, exec, log, transports); + renewal.replan(); + } + } + + /** + * Installs replacement material only if its earliest chain expiry is later than the current earliest expiry. + * Unchanged chains count as an unsuccessful renewal without recording a reload failure. An interrupted attempt retains + * the current material and does not count as unsuccessful. The node's renewal scheduler calls this method. + * + * @param expected Accepted context from {@link #sslBundle()} when the renewal was planned. + * @return Installed bundle, or {@code null} if its chains are unchanged or the expected context has already been replaced. + * @throws IgniteException If the replacement fails initialization checks, has no certificate metadata or does not advance + * the earliest expiry across all chains. + * @throws IgniteCheckedException If renewal is interrupted; the interrupt flag stays set when the factory sets it. + */ + public final synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { + if (bundle == null || bundle.context() != expected) + return null; + + try { + SslBundle next = buildReplacement(); + + if (Thread.currentThread().isInterrupted()) + throw new IgniteInterruptedCheckedException("TLS certificate renewal was interrupted"); + + Map> chains = next.certificateChains(); + + if (chains != null && chains.equals(bundle.certificateChains())) { + renewalFailures++; + + return null; + } + + checkReplacement(next, true); + + List cur = SslCertificates.renewalChain(bundle); + long nextExpiry = SslCertificates.chainNotAfter(SslCertificates.renewalChain(next)); + + if (cur != null && nextExpiry <= SslCertificates.chainNotAfter(cur)) { + throw new IgniteException("The new certificate chains expire no later than the ones in use; if a certificate " + + "authority expires first, renew it [chainNotAfter=" + Instant.ofEpochMilli(nextExpiry) + + ", inUseChainNotAfter=" + Instant.ofEpochMilli(SslCertificates.chainNotAfter(cur)) + ']'); + } + + putInUse(next); + } + catch (Throwable e) { + if (!Thread.currentThread().isInterrupted() && + !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) { + renewalFailures++; + + onFailure(e); + } + + throw e; + } + + onSuccess(); + + return bundle; + } + + /** @return Failed automatic attempts since the earliest expiry across all chains was advanced. */ + public final int renewalFailures() { + return renewalFailures; + } + + /** @return Time of the next automatic renewal, {@code 0} if none is planned or the node has stopped. */ + public final long nextRenewalTime() { + SslRenewal cur = renewal; + + return cur == null ? 0 : cur.nextRenewalTime(); + } + + /** @return Newly built replacement with a usable context and no known absence of certificate chains. */ + private SslBundle buildReplacement() { + SslBundle next = createSslBundle(); + Map> chains = next.certificateChains(); + + if (chains != null && chains.isEmpty()) { + throw new IgniteException("The new key material has no X.509 certificate chains, " + + "so the node would present no certificate"); + } + + next.context().createSSLEngine(); + + return next; + } + + /** + * @param next Replacement to check before it is published. + * @param requireChains Whether certificate metadata must remain available for automatic renewal. + * @throws IgniteException If the context is already in use, required metadata is missing or a certificate is invalid. + */ + private void checkReplacement(SslBundle next, boolean requireChains) { + if (next.context() == bundle.context()) { + throw new IgniteException("The SSL context factory returns the context in use, so the node gets no new " + + "certificates from it"); + } + + Map> chains = next.certificateChains(); + + if (requireChains && chains == null) { + throw new IgniteException("The new SSL context must provide at least one X.509 certificate chain " + + "to renew automatically"); + } + + if (chains == null) + return; + + long now = System.currentTimeMillis(); + + for (Map.Entry> entry : chains.entrySet()) { + X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), now); + + if (invalid != null) { + throw new IgniteException("The new certificate chain is not valid now [alias=" + entry.getKey() + ", " + + SslCertificates.describe(invalid) + ", now=" + Instant.ofEpochMilli(now) + ']'); + } + } + } + + /** @param next Material that has passed the applicable initialization or renewal checks. */ + private void putInUse(SslBundle next) { + List cur = bundle == null ? null : SslCertificates.renewalChain(bundle); + List replacement = SslCertificates.renewalChain(next); + + if (replacement != null && (cur == null || + SslCertificates.chainNotAfter(replacement) > SslCertificates.chainNotAfter(cur))) + renewalFailures = 0; + + SSLContext shared = sharedCtx; + + if (shared == null) + shared = new CurrentSslContext(next.context(), () -> bundle.context()); + + bundle = next; + sharedCtx = shared; + } + + /** Records a successful replacement and plans renewal from the new material. */ + private void onSuccess() { + outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); + + if (renewal != null) + renewal.replan(); + } + + /** @param e Why replacement failed. */ + private void onFailure(Throwable e) { + ReloadOutcome previous = reloadOutcome(); + + outcome = new ReloadOutcome(previous.successTime(), System.currentTimeMillis(), + SslCertificates.reason(e), previous.failures() + 1); + } + + /** @return Snapshot of the last successful reload and subsequent failures; zero times before any reload. */ + public final ReloadOutcome reloadOutcome() { + ReloadOutcome res = outcome; + + return res == null ? new ReloadOutcome(0, 0, null, 0) : res; + } + + /** + * Snapshot of the last successful reload and subsequent failures. + * + * @param successTime Time of the last successful reload, {@code 0} if there was none. + * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. + * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. + * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. + */ + public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { + // No-op. + } + + /** + * Returns the accepted context and its metadata, initializing the factory on first use. + * + * @return One snapshot of the material in use. + * @throws IgniteException If the first initialization fails. + */ + public final SslBundle sslBundle() { + create(); + + return bundle; + } + + /** + * Returns the same forwarding context on every call, initializing it on first use. Obtain new engines and socket + * factories from this context to use the material accepted by the last successful {@link #init()}. Repeated calls + * do not read the key or trust stores. + * + * @return Stable initialized context. + * @throws IgniteException If the first initialization fails. */ @Override public final SSLContext create() { - return createSslBundle().context(); + if (sharedCtx == null) { + synchronized (this) { + if (sharedCtx == null) + init(); + } + } + + return sharedCtx; } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java similarity index 90% rename from modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java rename to modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java index d80377dd4bcc8..5d0903870e41f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/CurrentSslContext.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java @@ -14,7 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.ignite.internal.ssl; + +package org.apache.ignite.ssl; import java.security.SecureRandom; import java.util.function.Supplier; @@ -30,9 +31,12 @@ /** Delegates to the current SSL context so that new connections use reloaded certificates. */ class CurrentSslContext extends SSLContext { - /** @param ctx Supplier of the context currently in use. */ - CurrentSslContext(Supplier ctx) { - super(new Spi(ctx), ctx.get().getProvider(), ctx.get().getProtocol()); + /** + * @param initial First accepted context, used for its provider and protocol. + * @param ctx Supplier of the context currently in use. + */ + CurrentSslContext(SSLContext initial, Supplier ctx) { + super(new Spi(ctx), initial.getProvider(), initial.getProtocol()); } /** */ diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 40def4b6f2aa1..35e511dcde177 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -37,8 +37,8 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * Each call loads the stores again and builds a fresh context. The component using it should retain the context between - * connections. + * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. Call {@link #init()} + * to read the stores again and publish the replacement material. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 6cd1a7ba30da8..ec5981943d96d 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -30,6 +30,7 @@ import org.apache.ignite.internal.ssl.SslContextProvider; import org.apache.ignite.internal.visor.VisorJob; import org.apache.ignite.lang.IgniteBiTuple; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; @@ -100,7 +101,7 @@ else if (chains.isEmpty()) } } - SslContextProvider.ReloadOutcome outcome = provider.reloadOutcome(); + ReloadOutcome outcome = provider.reloadOutcome(); if (outcome.failures() > 0) { lines.add(DOUBLE_INDENT + "last reload failed " + outcome.failures() + " time(s) in a row, the last at " + diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 03cb555e4ea96..f8988ad7d6a69 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -18,7 +18,6 @@ package org.apache.ignite.internal.ssl; import java.security.cert.X509Certificate; -import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -27,74 +26,56 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; -import org.apache.ignite.IgniteException; -import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.IgniteLogger; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; -import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; +import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; -import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; -import static org.apache.ignite.internal.ssl.SslCertificates.describe; - -/** Owns one factory's context, reloads and automatic renewal on this node; transports using that factory share its context. */ +/** Registers the transports using a factory; the factory owns their current context and reload results. */ public class SslContextProvider { - /** */ + /** Factory configured by the user, retained for registration by identity. */ private final Factory factory; + /** Factory that owns the context, adapting a plain factory when necessary. */ + private final AbstractSslContextFactory sslFactory; + /** */ private final Set transports = new ConcurrentSkipListSet<>(); - /** Context and certificate metadata in use. */ - private volatile SslBundle bundle; - - /** Stable context handed to every transport using this factory. */ - private final SSLContext sharedCtx; - - /** Renewal window configured at registration, or {@code 0} when automatic renewal is disabled. */ - private final double renewBeforeFraction; - - /** Automatic renewal, attached before this provider is registered. */ - private @Nullable SslRenewal renewal; - - /** Snapshot of the last successful reload and subsequent failures. */ - private volatile ReloadOutcome outcome = new ReloadOutcome(0, 0, null, 0); - - /** Failed automatic attempts since the earliest expiry across all chains was advanced. */ - private volatile int renewalFailures; - - /** @param factory Factory to build the context with. */ + /** @param factory Factory the transports are configured with. */ SslContextProvider(Factory factory) { this.factory = factory; - bundle = createBundle(); - bundle.context().createSSLEngine(); - - renewBeforeFraction = factory instanceof AbstractSslContextFactory f && f.isRenewalEnabled() ? f.getRenewBeforeFraction() : 0; + sslFactory = factory instanceof AbstractSslContextFactory f ? f : new SslContextFactory() { + /** {@inheritDoc} */ + @Override public SslBundle createSslBundle() { + return new SslBundle(factory.create(), null); + } + }; - sharedCtx = new CurrentSslContext(this::context); + sslFactory.create(); } - /** @return Factory the context is built with. */ + /** @return Factory the transport is configured with. */ Factory factory() { return factory; } /** @return Context to open the next connection with. */ SSLContext context() { - return bundle.context(); + return bundle().context(); } - /** @return Context and certificate metadata from the same load. */ + /** @return Context and certificate metadata currently accepted by the factory. */ SslBundle bundle() { - return bundle; + return sslFactory.sslBundle(); } /** @return Stable context shared by every transport configured with this factory. */ SSLContext sharedContext() { - return sharedCtx; + return sslFactory.create(); } /** @param transport Transport the context is handed to. */ @@ -109,249 +90,43 @@ public String transports() { /** @return Chains by key alias, or {@code null} when the factory supplies no metadata. */ public @Nullable Map> certificateChains() { - return bundle.certificateChains(); - } - - /** @return A new bundle, with unknown certificate metadata for factories that only return a context. */ - private SslBundle createBundle() { - return factory instanceof AbstractSslContextFactory f ? f.createSslBundle() : new SslBundle(factory.create(), null); + return bundle().certificateChains(); } /** - * @param exec Renewal thread of the node. + * @param exec Renewal executor owned by the node. * @param log Renewal logger. */ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { - if (renewBeforeFraction == 0) - return; - - if (renewalChain(bundle) == null) { - throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + - "to renew automatically [transports=" + transports() + ']'); - } - - renewal = new SslRenewal(this, renewBeforeFraction, exec, log); - renewal.replan(); + sslFactory.startRenewal(exec, log, this::transports); } - /** - * Builds and installs a new context for new connections, recording success or failure. Rejects material known to have no - * X.509 certificate chains. Every supplied certificate must be within its validity period. - * Certificate metadata is required when automatic renewal is enabled. - * The administrator must ensure that peers trust the new certificates and can connect with the new context. - * - * @return Context and certificate metadata installed by this reload. - * @throws IgniteCheckedException If the new context fails a check or the factory returns the context in use. On any failure, - * including a factory exception, the current context is retained. - */ - public synchronized SslBundle reload() throws IgniteCheckedException { - try { - SslBundle rebuilt = build(); - - if (rebuilt.context() == bundle.context()) - throw new IgniteCheckedException("The SSL context factory returns the context in use, so the node gets no new " + - "certificates from it"); - - check(rebuilt, renewBeforeFraction > 0); - - putInUse(rebuilt); - } - catch (Throwable e) { - onFailure(e); - - throw e; - } - - onSuccess(); - - return bundle; - } - - /** - * Installs a new bundle only if the earliest expiry across all its chains is later than the current earliest expiry. - * Returning the current chains counts as an unsuccessful attempt and leaves the bundle unchanged. An interrupted attempt - * changes nothing and does not count as unsuccessful. - * - * @param expected Context the renewal was planned for. - * @return Installed bundle, or {@code null} if its chains are unchanged or the planned context had already been replaced. - * @throws IgniteCheckedException If certificate metadata is unknown, the bundle fails the checks of {@link #reload()}, - * or the earliest expiry does not advance, or if the thread is interrupted by the time the factory returns; - * the interrupt flag then stays set. - */ - synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { - if (bundle.context() != expected) - return null; - - try { - SslBundle rebuilt = build(); - - if (Thread.currentThread().isInterrupted()) - throw new IgniteInterruptedCheckedException("TLS certificate renewal was interrupted"); - - Map> next = rebuilt.certificateChains(); - - if (next != null && next.equals(bundle.certificateChains())) { - renewalFailures++; - - return null; - } - - if (rebuilt.context() == bundle.context()) - throw new IgniteCheckedException("The SSL context factory returns the context in use, so the node gets no new " + - "certificates from it"); - - check(rebuilt, true); - - List cur = renewalChain(bundle); - long nextExpiry = chainNotAfter(renewalChain(rebuilt)); - - if (cur != null && nextExpiry <= chainNotAfter(cur)) { - throw new IgniteCheckedException("The new certificate chains expire no later than the ones in use; if a certificate " + - "authority expires first, renew it [chainNotAfter=" + Instant.ofEpochMilli(nextExpiry) + - ", inUseChainNotAfter=" + Instant.ofEpochMilli(chainNotAfter(cur)) + ']'); - } - - putInUse(rebuilt); - } - catch (Throwable e) { - if (!Thread.currentThread().isInterrupted() && - !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) { - renewalFailures++; - - onFailure(e); - } - - throw e; - } - - onSuccess(); - - return bundle; - } - - /** @param rebuilt Bundle that passed the checks. */ - private void putInUse(SslBundle rebuilt) { - List cur = renewalChain(bundle); - List next = renewalChain(rebuilt); - - if (next != null && (cur == null || chainNotAfter(next) > chainNotAfter(cur))) - renewalFailures = 0; - - bundle = rebuilt; - } - - /** - * Chooses the first chain to expire, using the earliest leaf validity start to break ties. - * - * @param bundle Context and its chains. - * @return Chain that determines renewal, or {@code null} if certificate metadata is unknown or contains no chains. - */ - static @Nullable List renewalChain(SslBundle bundle) { - Map> chains = bundle.certificateChains(); - - if (chains == null || chains.isEmpty()) - return null; - - List earliest = null; - long earliestExpiry = Long.MAX_VALUE; - - for (List chain : chains.values()) { - long expiry = chainNotAfter(chain); - - if (earliest == null || expiry < earliestExpiry || - expiry == earliestExpiry && chain.get(0).getNotBefore().before(earliest.get(0).getNotBefore())) { - earliest = chain; - earliestExpiry = expiry; - } - } - - return earliest; + /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ + public long nextRenewalTime() { + return sslFactory.nextRenewalTime(); } - /** - * @return Bundle built anew, or the context in use if a custom factory caches it. - * @throws IgniteCheckedException If the loaded material has no X.509 certificate chains. - */ - private SslBundle build() throws IgniteCheckedException { - SslBundle rebuilt = createBundle(); - Map> chains = rebuilt.certificateChains(); - - if (chains != null && chains.isEmpty()) - throw new IgniteCheckedException("The new key material has no X.509 certificate chains, " + - "so the node would present no certificate"); - - rebuilt.context().createSSLEngine(); - - return rebuilt; + /** @return Unsuccessful automatic attempts since the earliest chain expiry advanced. */ + int renewalFailures() { + return sslFactory.renewalFailures(); } /** - * @param next Bundle loaded by the factory. - * @param requireChains Whether at least one reported chain is required for renewal. - * @throws IgniteCheckedException If required certificate metadata is unknown or empty, - * or a certificate is outside its validity period. + * @param expected Context the renewal was planned for. + * @return Installed bundle, or {@code null} if unchanged or already replaced. + * @throws IgniteCheckedException If renewal is interrupted. */ - private void check(SslBundle next, boolean requireChains) throws IgniteCheckedException { - Map> chains = next.certificateChains(); - - if (requireChains && (chains == null || chains.isEmpty())) - throw new IgniteCheckedException("The new SSL context must provide at least one X.509 certificate chain " + - "to renew automatically"); - - if (chains == null) - return; - - long now = System.currentTimeMillis(); - - for (Map.Entry> entry : chains.entrySet()) { - X509Certificate invalid = SslCertificates.invalidAt(entry.getValue(), now); - - if (invalid != null) { - throw new IgniteCheckedException("The new certificate chain is not valid now [alias=" + entry.getKey() + ", " + - describe(invalid) + ", now=" + Instant.ofEpochMilli(now) + ']'); - } - } + @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { + return sslFactory.renew(expected); } - /** Records a successful reload. */ - private void onSuccess() { - outcome = new ReloadOutcome(System.currentTimeMillis(), 0, null, 0); - - if (renewal != null) - renewal.replan(); - } - - /** @param e Why the reload failed. */ - private void onFailure(Throwable e) { - ReloadOutcome o = outcome; - - outcome = new ReloadOutcome(o.successTime(), System.currentTimeMillis(), SslCertificates.reason(e), o.failures() + 1); + /** @return Context and certificate metadata accepted by the factory's initialization. */ + public SslBundle reload() { + return sslFactory.init(); } /** @return Snapshot of the last successful reload and subsequent failures. */ public ReloadOutcome reloadOutcome() { - return outcome; - } - - /** @return Failed automatic attempts since the earliest expiry across all chains was advanced, including unchanged certificates. */ - int renewalFailures() { - return renewalFailures; - } - - /** - * Snapshot of the last successful reload and subsequent failures. - * - * @param successTime Time of the last successful reload, {@code 0} if there was none. - * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. - * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. - * @param failures Failed reloads in a row since the last successful one. - */ - public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { - // No-op. - } - - /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ - public long nextRenewalTime() { - return renewal == null ? 0 : renewal.nextRenewalTime(); + return sslFactory.reloadOutcome(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index eb7fa6453df60..12d265a77fabb 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -28,8 +28,8 @@ import javax.net.ssl.SSLParameters; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; -import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.testframework.GridTestUtils; @@ -49,16 +49,15 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** - * What {@link SslContextProvider#reload()} puts in use and records, what it refuses; the registry keeps one provider per factory - * object and warns at registration about key material that reload refuses. Reload refusals follow the order of the checks in the - * code: nonempty X.509 chains when metadata is available, an engine from the new context, a context not in use, - * certificates valid now. + * The provider exposes the material and reload results owned by its factory. Reload validates the complete candidate before + * replacing the material in use. The registry shares a provider between transports using the same factory object. */ public class SslContextProviderTest extends GridCommonAbstractTest { /** */ @@ -82,9 +81,9 @@ public void testReloadInstallsNewBundle() throws Exception { KeyStore a = otherCa.keyStoreWithPrivateKey("a"); KeyStore b = otherCa.keyStoreWithPrivateKey("b"); SslBundle next = bundle(merged(a, b), ca.trustStore()); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next); + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -96,6 +95,29 @@ public void testReloadInstallsNewBundle() throws Exception { assertTrue(p.reloadOutcome().successTime() > 0); } + /** Calling the factory directly updates the provider's material and outcome without replacing its shared context. */ + @Test + public void testDirectFactoryInitUpdatesProvider() throws Exception { + SslBundle next = bundle(ca.keyStoreWithPrivateKey("replacement"), ca.trustStore()); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); + + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), next).when(f).createSslBundle(); + + SslContextProvider p = new SslContextProvider(f); + SSLContext shared = p.sharedContext(); + + assertEquals(new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); + + f.init(); + + assertSame(next, p.bundle()); + assertSame(next.context(), p.context()); + assertEquals(next.certificateChains(), p.certificateChains()); + assertSame(shared, p.sharedContext()); + assertSame(f.reloadOutcome(), p.reloadOutcome()); + assertTrue(p.reloadOutcome().successTime() > 0); + } + /** * A failed reload keeps the context, metadata and success time in use, counts failures in a row and records the time and the * reason without the messages of wrappers; a successful reload clears the failures. @@ -105,12 +127,12 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { SslBundle reloaded = bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore()); KeyStore recoveredKeys = ca.keyStoreWithPrivateKey("recovered"); IgniteException failure = new IgniteException("Failed to load", new SSLException(new IOException("bad file"))); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()) - .thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), reloaded) - .thenThrow(failure, failure) - .thenReturn(bundle(recoveredKeys, ca.trustStore())); + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), reloaded) + .doThrow(failure, failure) + .doReturn(bundle(recoveredKeys, ca.trustStore())) + .when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -142,11 +164,11 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { /** Key material with no certificate chains, such as a trust store put in place of the key store, is not applied. */ @Test public void testMaterialWithoutCertificateChainsNotApplied() throws Exception { - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.trustStore(), ca.trustStore())); + bundle(ca.trustStore(), ca.trustStore())).when(f).createSslBundle(); assertReloadRejected(new SslContextProvider(f), "The new key material has no X.509 certificate chains, so the node would present no certificate"); @@ -160,11 +182,12 @@ public void testUnsupportedProtocolNotApplied() throws Exception { params.setProtocols(new String[] {"SSLvDoesNotExist"}); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())); + new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())) + .when(f).createSslBundle(); assertReloadRejected(new SslContextProvider(f), "SSLvDoesNotExist"); } @@ -184,12 +207,12 @@ public void testEveryCertificateMustBeValidNow() throws Exception { KeyStore valid = ca.keyStoreWithPrivateKey("a-valid"); KeyStore notYetValid = ca.keyStoreWithPrivateKey("z-not-yet-valid", now + HOUR, now + 2 * HOUR); KeyStore expiredCaLeaf = new TestCertificateAuthority("expired-ca", now - 2 * HOUR, now - HOUR).keyStoreWithPrivateKey("leaf"); - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), bundle(merged(valid, notYetValid), ca.trustStore()), - bundle(expiredCaLeaf, ca.trustStore())); + bundle(expiredCaLeaf, ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -227,9 +250,9 @@ record Fixed(SSLContext ctx) implements Factory { /** Registration warns when the key material has no certificate chains, here a trust store put in place of the key store. */ @Test public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = mock(AbstractSslContextFactory.class, CALLS_REAL_METHODS); - when(f.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); + doReturn(bundle(ca.trustStore(), ca.trustStore())).when(f).createSslBundle(); ListeningTestLogger registryLog = new ListeningTestLogger(log); LogListener warned = LogListener.matches("The key material has no X.509 certificate chains, " + diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index dd593b901c852..5c2b5e938ab0a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -29,11 +29,11 @@ import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; -import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.metric.IntMetric; import org.apache.ignite.spi.metric.Metric; import org.apache.ignite.spi.metric.ReadOnlyMetricRegistry; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; @@ -218,6 +218,40 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { assertTrue(waitForCondition(() -> !renewalThreadAlive(name), 10_000)); } + /** Restarting a node with the same factory attaches renewal to the new executor and picks up replacement material. */ + @Test + public void testSameFactoryRenewsAfterNodeRestart() throws Exception { + long now = System.currentTimeMillis(); + Path keys = dir.resolve("node.jks"); + SslContextFactory factory = readingKeys(new SslContextFactory(), keys); + KeyStore replacement = ca.keyStoreWithPrivateKey("replacement", now - HOUR, now + 2 * HOUR); + + save(ca.keyStoreWithPrivateKey("in-window", now - 110 * MINUTE, now + 10 * MINUTE), keys); + factory.setRenewalEnabled(true); + + startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + }); + + assertTrue(waitForCondition(() -> factory.renewalFailures() > 0, 10_000)); + + stopGrid(0); + + assertEquals(0, factory.nextRenewalTime()); + + save(replacement, keys); + + IgniteEx restarted = startGrid(0, cfg -> { + cfg.setSslContextFactory(factory); + }); + + assertTrue(waitForCondition(() -> factory.sslBundle().certificateChains().containsKey("replacement"), 10_000)); + assertEquals(0, factory.renewalFailures()); + assertEquals(chain(replacement).get(0), servedCertificate(context(ca.keyStoreWithPrivateKey("probe"), ca.trustStore()), + discoveryPort(restarted))); + assertTrue(renewalThreadAlive(restarted.name())); + } + /** * The file-based factory with renewal enabled waits while its key store file holds the certificate in use and puts the file * replaced on disk in use with no command: the node logs the renewal, new discovery connections get the new certificate, and diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 2ddeb03d8c9f0..cd787c47737d9 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Delayed; import java.util.concurrent.FutureTask; import java.util.concurrent.ScheduledExecutorService; @@ -33,15 +34,14 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.IgniteInterruptedCheckedException; -import org.apache.ignite.internal.ssl.SslContextProvider.ReloadOutcome; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; import org.apache.ignite.internal.thread.context.Scope; import org.apache.ignite.ssl.AbstractSslContextFactory; +import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; @@ -61,10 +61,12 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** * Renewal without nodes: its settings, {@link SslContextProvider#renew(SSLContext)}, the count of unsuccessful renewals and the @@ -76,9 +78,9 @@ * at the window of the earliest chain, then no sooner than a minute after a success, backs off on failures and logs each one, * replans on reload, drops an outdated attempt and runs without the operator context. The log names the installed certificate, * sets the level of a failure by the time left and reports a wait once per lateness, again on expiry. Tests enable renewal on - * a factory mock and script its bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in - * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. Only one - * test waits: the operator context test runs on the kind of executor a node uses and waits for its attempt. The expiry test + * a factory and script its fresh bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in + * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. The operator + * context test runs on the kind of executor a node uses, and a concurrent initialization test coordinates two threads. The expiry test * advances the scheduler's clock explicitly. An appender on the logger of {@link SslRenewal} captures what renewal logs. */ public class SslRenewalTest extends GridCommonAbstractTest { @@ -151,7 +153,7 @@ public void testRenewInstallsAllNewChains() throws Exception { SslBundle renewed = bundle(merged(new1, new2), ca.trustStore()); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn(bundle(merged(old1, old2), ca.trustStore()), renewed); + doReturn(bundle(merged(old1, old2), ca.trustStore()), renewed).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -170,7 +172,7 @@ public void testRenewalCanRemoveEarliestChain() throws Exception { SslBundle replacement = bundle(remaining, ca.trustStore()); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn(bundle(merged(removed, remaining), ca.trustStore()), replacement); + doReturn(bundle(merged(removed, remaining), ca.trustStore()), replacement).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -185,9 +187,9 @@ public void testExpiredCertificateRenewed() throws Exception { KeyStore valid = ca.keyStoreWithPrivateKey("valid", now - HOUR, now + HOUR); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), ca.trustStore()), - bundle(valid, ca.trustStore())); + bundle(valid, ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -202,10 +204,11 @@ public void testRenewalAppliesReloadChecks() throws Exception { long now = System.currentTimeMillis(); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()), bundle(ca.trustStore(), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("not-yet-valid", now + 10 * MINUTE, now + 2 * HOUR), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("not-yet-valid", now + 10 * MINUTE, now + 2 * HOUR), ca.trustStore())) + .when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -227,8 +230,8 @@ public void testUnchangedCertificatesAwaited() throws Exception { AbstractSslContextFactory caching = renewingFactory(QUARTER); AbstractSslContextFactory expiredAgain = renewingFactory(QUARTER); - when(caching.createSslBundle()).thenReturn(cached); - when(expiredAgain.createSslBundle()).thenReturn(bundle(expired, ca.trustStore()), bundle(expired, ca.trustStore())); + doReturn(cached).when(caching).createSslBundle(); + doReturn(bundle(expired, ca.trustStore()), bundle(expired, ca.trustStore())).when(expiredAgain).createSslBundle(); SslContextProvider cachingProvider = new SslContextProvider(caching); SslContextProvider expiredProvider = new SslContextProvider(expiredAgain); @@ -250,7 +253,7 @@ public void testChangedMetadataCannotRenewCachedContext() throws Exception { KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))); + doReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))).when(f).createSslBundle(); assertRenewalRejected("Cached context", new SslContextProvider(f), "The SSL context factory returns the context in use"); } @@ -269,11 +272,11 @@ public void testRenewalMustAdvanceEarliestExpiry() throws Exception { KeyStore renewedA = ca.keyStoreWithPrivateKey("a", now - HOUR, now + 2 * HOUR); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(merged(a, b), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("other", now - HOUR, now + HOUR), ca.trustStore()), bundle(merged(renewedA, b), ca.trustStore()), - bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())); + bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -287,14 +290,14 @@ public void testRenewalMustAdvanceEarliestExpiry() throws Exception { public void testRenewForReplacedContextDoesNothing() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); SSLContext planned = p.context(); - p.reload(); + f.init(); SslBundle reloaded = p.bundle(); @@ -316,18 +319,17 @@ public void testInterruptedRenewalChangesNothing() throws Exception { SslBundle renewed = bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()) - .thenReturn(bundle(startup, ca.trustStore())) - .thenAnswer(inv -> { + doReturn(bundle(startup, ca.trustStore())) + .doAnswer(inv -> { Thread.currentThread().interrupt(); return same; }) - .thenAnswer(inv -> { + .doAnswer(inv -> { Thread.currentThread().interrupt(); return renewed; - }); + }).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); SslBundle inUse = p.bundle(); @@ -356,8 +358,8 @@ public void testInterruptedRenewalChangesNothing() throws Exception { } /** - * With renewal enabled at creation, reload refuses a bundle without certificate metadata, even after the factory has turned - * renewal off: the renewal settings are fixed when the provider is created. renew refuses such a bundle too. + * With renewal attached, direct initialization refuses a bundle without certificate metadata, even after the factory has turned + * renewal off: the renewal settings are fixed until the attached node stops. renew refuses such a bundle too. */ @Test public void testReloadCannotLoseMetadataWithRenewal() throws Exception { @@ -366,13 +368,15 @@ public void testReloadCannotLoseMetadataWithRenewal() throws Exception { SslBundle unknown = new SslBundle(context(ca.keyStoreWithPrivateKey("unknown"), ca.trustStore()), null); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()).thenReturn(startup, unknown, unknown); + doReturn(startup, unknown, unknown).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); + RecordingExecutor exec = new RecordingExecutor(); - when(f.isRenewalEnabled()).thenReturn(false); + p.startRenewal(exec, renewalLog); + f.setRenewalEnabled(false); - GridTestUtils.assertThrows(log, p::reload, IgniteCheckedException.class, reason); + GridTestUtils.assertThrows(log, f::init, IgniteException.class, reason); assertSame(startup, p.bundle()); assertRenewalRejected("Renewal", p, reason); @@ -387,8 +391,8 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { RecordingExecutor exec = new RecordingExecutor(); String reason = "The SSL context factory must provide at least one X.509 certificate chain to renew automatically"; - when(unknown.createSslBundle()).thenReturn(new SslBundle(ctx, null)); - when(noChains.createSslBundle()).thenReturn(bundle(ca.trustStore(), ca.trustStore())); + doReturn(new SslBundle(ctx, null)).when(unknown).createSslBundle(); + doReturn(bundle(ca.trustStore(), ca.trustStore())).when(noChains).createSslBundle(); SslContextProvider unknownProvider = new SslContextProvider(unknown); SslContextProvider noChainsProvider = new SslContextProvider(noChains); @@ -397,6 +401,149 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { GridTestUtils.assertThrows(log, () -> noChainsProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); } + /** A factory accepts one renewal executor, and attaching it again does not duplicate its schedule. */ + @Test + public void testRenewalAttachesOnce() throws Exception { + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); + RecordingExecutor other = new RecordingExecutor(); + + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())).when(f).createSslBundle(); + + f.create(); + f.startRenewal(exec, renewalLog, () -> "communication"); + + Task first = exec.last(); + + f.startRenewal(exec, renewalLog, () -> "communication"); + + assertEquals(List.of(first), exec.pending()); + GridTestUtils.assertThrows(log, () -> f.startRenewal(other, renewalLog, () -> "discovery"), + IgniteException.class, "another running node"); + assertTrue(other.pending().isEmpty()); + assertEquals(List.of(first), exec.pending()); + } + + /** Settings apply at attachment and stay fixed until the node stops, including when a factory was already initialized. */ + @Test + public void testRenewalSettingsAppliedAtAttachmentAndRestart() throws Exception { + long now = System.currentTimeMillis() / SECOND * SECOND; + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + 3 * HOUR), ca.trustStore()); + SslBundle replacement = bundle(ca.keyStoreWithPrivateKey("replacement", now - HOUR, now + 5 * HOUR), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor first = new RecordingExecutor(); + RecordingExecutor restarted = new RecordingExecutor(); + + doReturn(startup, replacement).when(f).createSslBundle(); + + f.setRenewalEnabled(false); + f.create(); + f.setRenewalEnabled(true); + f.startRenewal(first, renewalLog, () -> "communication"); + first.runNext(); + + assertEquals(now + 2 * HOUR, f.nextRenewalTime()); + + first.shutdownNow(); + + assertEquals(0, f.nextRenewalTime()); + + f.setRenewBeforeFraction(0.5); + f.startRenewal(restarted, renewalLog, () -> "communication"); + restarted.runNext(); + + assertEquals(now + HOUR, f.nextRenewalTime()); + + f.setRenewBeforeFraction(0.75); + f.startRenewal(restarted, renewalLog, () -> "communication"); + f.init(); + restarted.runNext(); + + assertEquals("The active node keeps its configured fraction", now + 2 * HOUR, f.nextRenewalTime()); + assertSame(replacement, f.sslBundle()); + } + + /** A restarted node can disable renewal and accept unknown metadata without retaining the stopped node's schedule. */ + @Test + public void testRenewalCanBeDisabledOnRestart() throws Exception { + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor first = new RecordingExecutor(); + RecordingExecutor restarted = new RecordingExecutor(); + SslBundle unknown = new SslBundle(context(ca.keyStoreWithPrivateKey("unknown"), ca.trustStore()), null); + + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), unknown).when(f).createSslBundle(); + + f.startRenewal(first, renewalLog, () -> "communication"); + first.runNext(); + first.shutdownNow(); + f.setRenewalEnabled(false); + f.startRenewal(restarted, renewalLog, () -> "communication"); + f.init(); + + assertSame(unknown, f.sslBundle()); + assertEquals(0, f.nextRenewalTime()); + assertTrue(restarted.pending().isEmpty()); + + f.setRenewalEnabled(true); + f.startRenewal(restarted, renewalLog, () -> "communication"); + + assertEquals("Enabling renewal waits for another restart", 0, f.nextRenewalTime()); + assertTrue(restarted.pending().isEmpty()); + } + + /** Direct initialization waits for an in-flight renewal, then resets its failure count and replans from its own material. */ + @Test + public void testDirectInitSerializesWithRenewal() throws Exception { + long now = System.currentTimeMillis() / SECOND * SECOND; + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()); + SslBundle replacement = bundle(ca.keyStoreWithPrivateKey("replacement", now, now + 2 * HOUR), ca.trustStore()); + AbstractSslContextFactory f = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); + CountDownLatch renewing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + doReturn(startup).doAnswer(inv -> { + renewing.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + + return startup; + }).doReturn(replacement).when(f).createSslBundle(); + + f.startRenewal(exec, renewalLog, () -> "communication"); + exec.runNext(); + + FutureTask renewal = new FutureTask<>(() -> f.renew(startup.context())); + FutureTask init = new FutureTask<>(f::init); + Thread renewalThread = new Thread(renewal, "test-factory-renewal"); + Thread initThread = new Thread(init, "test-factory-init"); + + renewalThread.start(); + + try { + assertTrue(renewing.await(10, TimeUnit.SECONDS)); + + initThread.start(); + + assertTrue(waitForCondition(() -> initThread.getState() == Thread.State.BLOCKED || init.isDone(), 10_000)); + assertFalse("Initialization must wait while renewal loads its candidate", init.isDone()); + } + finally { + release.countDown(); + renewalThread.join(10_000); + initThread.join(10_000); + } + + assertNull(renewal.get(10, TimeUnit.SECONDS)); + assertSame(replacement, init.get(10, TimeUnit.SECONDS)); + assertSame(replacement, f.sslBundle()); + assertEquals(0, f.renewalFailures()); + assertEquals(0, f.reloadOutcome().failures()); + + exec.runNext(); + + assertEquals(now + 2 * HOUR - 30 * MINUTE, f.nextRenewalTime()); + } + /** Both the chains in use and a factory failure count as unsuccessful renewals, and a renewal clears the count. */ @Test public void testUnsuccessfulRenewalsCounted() throws Exception { @@ -404,10 +551,9 @@ public void testUnsuccessfulRenewalsCounted() throws Exception { KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()) - .thenReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) - .thenThrow(new IgniteException(ISSUER_DOWN)) - .thenReturn(bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore())); + doReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) + .doThrow(new IgniteException(ISSUER_DOWN)) + .doReturn(bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -433,31 +579,30 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); AbstractSslContextFactory f = renewingFactory(QUARTER); - when(f.createSslBundle()) - .thenReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) - .thenThrow(new IgniteException(ISSUER_DOWN)) - .thenReturn( + doReturn(bundle(inUse, ca.trustStore()), bundle(inUse, ca.trustStore())) + .doThrow(new IgniteException(ISSUER_DOWN)) + .doReturn( bundle(ca.keyStoreWithPrivateKey("same-expiry", now - HOUR, now + HOUR), ca.trustStore()), bundle(shortCa.keyStoreWithPrivateKey("later-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("later-chain", now - HOUR, now + 2 * HOUR), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("later-chain", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); p.renew(p.context()); - GridTestUtils.assertThrows(log, p::reload, IgniteException.class, ISSUER_DOWN); + GridTestUtils.assertThrows(log, f::init, IgniteException.class, ISSUER_DOWN); assertEquals("Failed reload", 1, p.renewalFailures()); - p.reload(); + f.init(); assertEquals("Same expiry", 1, p.renewalFailures()); - p.reload(); + f.init(); assertEquals("Later leaf, authority expires at the same time", 1, p.renewalFailures()); - p.reload(); + f.init(); assertEquals("Later chain expiry", 0, p.renewalFailures()); } @@ -497,10 +642,10 @@ public void testNextAttemptNotSoonerThanMinPause() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup", now - 100 * MINUTE, now + 20 * MINUTE), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("renewed", now - 95 * MINUTE, now + 25 * MINUTE), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("reloaded", now - 90 * MINUTE, now + 30 * MINUTE), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("reloaded", now - 90 * MINUTE, now + 30 * MINUTE), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -511,7 +656,7 @@ public void testNextAttemptNotSoonerThanMinPause() throws Exception { assertEquals("Renewal", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); - p.reload(); + f.init(); exec.runNext(); assertEquals("Reload", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); @@ -527,9 +672,8 @@ public void testFailedAttemptsBackOff() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()) - .thenReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())) - .thenThrow(new IgniteException(ISSUER_DOWN)); + doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())) + .doThrow(new IgniteException(ISSUER_DOWN)).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -566,7 +710,7 @@ public void testReloadReplans() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(startup, startup, reloaded, reloaded); + doReturn(startup, startup, reloaded, reloaded).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -576,7 +720,7 @@ public void testReloadReplans() throws Exception { ScheduledFuture retry = exec.last(); - p.reload(); + f.init(); exec.runNext(); assertTrue(retry.isCancelled()); @@ -602,9 +746,9 @@ public void testStaleAttemptNotRetried() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -613,7 +757,7 @@ public void testStaleAttemptNotRetried() throws Exception { Task stale = exec.last(); - p.reload(); + f.init(); Task plan = exec.last(); @@ -636,11 +780,11 @@ public void testRenewalRunsWithoutOperatorContext() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); ScheduledExecutorService exec = newSingleThreadScheduledExecutor("ssl-renewal", getTestIgniteInstanceName()); - when(f.createSslBundle()).thenAnswer(inv -> { + doAnswer(inv -> { operators.add(OperationContext.get(OPERATOR)); return inWindow; - }); + }).when(f).createSslBundle(); try { try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { @@ -664,26 +808,27 @@ public void testRenewalLogsAppliedCertificate() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn( + doReturn( bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("reloaded", now - HOUR, now + 3 * HOUR), ca.trustStore())); + bundle(ca.keyStoreWithPrivateKey("reloaded", now - HOUR, now + 3 * HOUR), ca.trustStore())).when(f).createSslBundle(); + + SslContextProvider p = new SslContextProvider(f); - SslContextProvider p = new SslContextProvider(f) { - /** {@inheritDoc} */ - @Override SslBundle renew(SSLContext expected) throws IgniteCheckedException { - SslBundle renewed = super.renew(expected); + doAnswer(inv -> { + SslBundle renewed = (SslBundle)inv.callRealMethod(); - super.reload(); + f.init(); - return renewed; - } - }; + return renewed; + }).when(f).renew(any(SSLContext.class)); p.startRenewal(exec, renewalLog); exec.runNext(); exec.runNext(); + assertTrue(p.certificateChains().containsKey("reloaded")); + List reloadedLogs = appender.events().stream().map(e -> e.getMessage().getFormattedMessage()) .filter(msg -> msg.startsWith("TLS certificates reloaded [")).toList(); @@ -724,7 +869,7 @@ public void testWaitLoggedOncePerLateness() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(inUse); + doReturn(inUse).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -750,11 +895,11 @@ public void testWaitLoggedAgainOnExpiry() throws Exception { AbstractSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(bundle(inUse, ca.trustStore())); + doReturn(bundle(inUse, ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); - new SslRenewal(p, QUARTER, exec, renewalLog, now::get).replan(); + new SslRenewal(f, QUARTER, exec, renewalLog, p::transports, now::get).replan(); exec.runNext(); exec.runNext(); @@ -768,13 +913,13 @@ public void testWaitLoggedAgainOnExpiry() throws Exception { /** * @param fraction Share of the lifetime left when renewal starts. - * @return Factory mock with renewal enabled; tests script its bundles. + * @return Factory with renewal enabled; tests script its fresh bundles. */ private static AbstractSslContextFactory renewingFactory(double fraction) { - AbstractSslContextFactory f = mock(AbstractSslContextFactory.class); + AbstractSslContextFactory f = spy(new SslContextFactory()); - when(f.isRenewalEnabled()).thenReturn(true); - when(f.getRenewBeforeFraction()).thenReturn(fraction); + f.setRenewalEnabled(true); + f.setRenewBeforeFraction(fraction); return f; } @@ -788,7 +933,7 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { AbstractSslContextFactory f = renewingFactory(fraction); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(bundle(keys, ca.trustStore())); + doReturn(bundle(keys, ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); @@ -810,7 +955,7 @@ private void assertRenewalRejected(String desc, SslContextProvider p, String rea SslBundle inUse = p.bundle(); int failures = p.renewalFailures(); - GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteCheckedException.class, reason, desc + ": not refused"); + GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteException.class, reason, desc + ": not refused"); Assert.assertSame(desc, inUse, p.bundle()); assertEquals(desc, failures + 1, p.renewalFailures()); @@ -829,7 +974,7 @@ private void assertFailureLogged(String desc, KeyStore inUse, double fraction, S AbstractSslContextFactory f = renewingFactory(fraction); RecordingExecutor exec = new RecordingExecutor(); - when(f.createSslBundle()).thenReturn(bundle(inUse, ca.trustStore())).thenThrow(new IgniteException(ISSUER_DOWN)); + doReturn(bundle(inUse, ca.trustStore())).doThrow(new IgniteException(ISSUER_DOWN)).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); int before = problems().size(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index f637b7fe1481a..9b8c3f7a92d15 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -26,6 +26,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -43,10 +45,9 @@ import static org.mockito.Mockito.mock; /** - * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, - * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering - * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, - * and rejects empty per-alias chains. {@link SslContextFactory} names the store whose key it cannot open. + * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous + * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} reports nonempty X.509 chains + * without recovering keys and keeps its own key password. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -57,44 +58,131 @@ public class SslContextMetadataTest extends GridCommonAbstractTest { ca = new TestCertificateAuthority("metadata-ca"); } - /** Each bundle reads the key store once, builds a new context and takes its metadata from that read. */ + /** Each initialization publishes a fresh context with metadata from the same, single key store read. */ @Test - public void testEachBundleReadsKeyStoreOnce() throws Exception { + public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { KeyStore first = ca.keyStoreWithPrivateKey("first"); InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - SslBundle firstBundle = factory.createSslBundle(); + SslBundle firstBundle = factory.init(); assertEquals(1, factory.loads); + assertSame(firstBundle, factory.sslBundle()); assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); + SSLContext shared = factory.create(); KeyStore second = ca.keyStoreWithPrivateKey("second"); factory.store = second; - SslBundle secondBundle = factory.createSslBundle(); + SslBundle secondBundle = factory.init(); assertEquals(2, factory.loads); + assertSame(secondBundle, factory.sslBundle()); assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); + assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); assertNotSame(firstBundle.context(), secondBundle.context()); + assertSame(shared, factory.create()); } - /** A failed read is thrown instead of reusing an earlier context, and the next call reads the store again. */ + /** Concurrent first calls initialize once; obtaining the context and accepted snapshot never reloads the store. */ @Test - public void testFailedLoadCanBeRetried() throws Exception { + public void testCreateAndSnapshotDoNotReloadStore() throws Exception { InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + int threadCnt = 4; + CyclicBarrier start = new CyclicBarrier(threadCnt); + AtomicReference sharedRef = new AtomicReference<>(); + + GridTestUtils.runMultiThreaded(() -> { + start.await(); + + SSLContext ctx = factory.create(); + + sharedRef.compareAndSet(null, ctx); + assertSame(sharedRef.get(), ctx); + + return null; + }, threadCnt, "ssl-context-create"); + + SslBundle accepted = factory.sslBundle(); + SSLContext shared = sharedRef.get(); + + factory.loadFailure = "Store must not be read again"; + + assertSame(shared, factory.create()); + assertSame(accepted, factory.sslBundle()); + assertEquals(1, factory.loads); + } - factory.create(); + /** A failed reload leaves both the accepted snapshot and the forwarding context usable, and can be retried. */ + @Test + public void testFailedInitRetainsAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); factory.loadFailure = "Key store is unavailable"; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "Key store is unavailable"); + Throwable err = GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); assertTrue(err.getCause() instanceof SSLException); + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(2, factory.loads); + + factory.loadFailure = null; + + SslBundle replacement = factory.init(); + + assertNotSame(accepted, replacement); + assertSame(replacement, factory.sslBundle()); + assertSame(shared, factory.create()); + assertEquals(3, factory.loads); + } + + /** A failed first initialization leaves the factory able to initialize on the next context request. */ + @Test + public void testFirstInitializationFailureCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + + factory.loadFailure = "Key store is unavailable"; + + GridTestUtils.assertThrows(log, factory::create, IgniteException.class, factory.loadFailure); factory.loadFailure = null; - factory.create(); + SSLContext shared = factory.create(); + + assertSame(shared, factory.create()); + assertNotNull(factory.sslBundle().context().createSSLEngine()); + assertEquals(2, factory.loads); + } + + /** Unsupported TLS parameters are rejected before a replacement becomes visible. */ + @Test + public void testInvalidTlsParametersDoNotReplaceAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.init(); + SSLContext shared = factory.create(); + + factory.setProtocols("unsupported-protocol"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + + factory.setProtocols((String[])null); + factory.setCipherSuites("unsupported-cipher-suite"); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + + assertSame(accepted, factory.sslBundle()); + assertSame(shared, factory.create()); + assertNotNull(shared.createSSLEngine()); + assertEquals(3, factory.loads); } /** Missing parameters are reported before the key store is read. */ @@ -145,7 +233,7 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { empty.load(null, null); - SslBundle bundle = new InMemoryStoreFactory(empty).createSslBundle(); + SslBundle bundle = new InMemoryStoreFactory(empty).init(); assertEquals(Map.of(), bundle.certificateChains()); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 51ece516974b1..68d244df90bb5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -602,7 +602,7 @@ private CountingFactory(Path keys, Path trust) { @Override public SSLContext create() { loads.incrementAndGet(); - return delegate.create(); + return delegate.createSslBundle().context(); } } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 19792613b6650..242487c346b91 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -17,18 +17,33 @@ package org.apache.ignite.ssl; +import java.io.File; +import java.io.FileOutputStream; +import java.security.KeyStore; import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; -/** Communication keeps one SSL context: new connections do not call the SSL context factory on either node. */ +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.junit.Assert.assertArrayEquals; + +/** Consumers retain one SSL context while initialization changes the material used by new connections. */ public class SslContextReuseTest extends GridCommonAbstractTest { + /** Stores generated independently for each test. */ + @Rule public final TemporaryFolder tmp = new TemporaryFolder(); + /** {@inheritDoc} */ @Override protected void afterTest() throws Exception { stopAllGrids(); @@ -36,6 +51,78 @@ public class SslContextReuseTest extends GridCommonAbstractTest { super.afterTest(); } + /** New engines and socket factories use initialized TLS settings; previously obtained ones retain their settings. */ + @Test + public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Exception { + SslContextFactory factory = fileFactory(); + String[] firstProtocols = {"TLSv1.2"}; + String[] firstCiphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; + + factory.setProtocols(firstProtocols); + factory.setCipherSuites(firstCiphers); + + SSLContext shared = factory.create(); + SSLEngine existing = shared.createSSLEngine(); + SSLSocketFactory capturedFactory = shared.getSocketFactory(); + String[] replacementProtocols = {"TLSv1.3"}; + String[] replacementCiphers = {"TLS_AES_128_GCM_SHA256"}; + + factory.setProtocols(replacementProtocols); + factory.setCipherSuites(replacementCiphers); + + SslBundle replacement = factory.init(); + String host = "peer.example"; + int port = 10800; + SSLEngine addressed = shared.createSSLEngine(host, port); + SSLEngine unaddressed = shared.createSSLEngine(); + + assertSame(shared, factory.create()); + assertSame(replacement.context().getClientSessionContext(), shared.getClientSessionContext()); + assertSame(replacement.context().getServerSessionContext(), shared.getServerSessionContext()); + assertEquals(host, addressed.getPeerHost()); + assertEquals(port, addressed.getPeerPort()); + assertArrayEquals(replacementProtocols, addressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, addressed.getEnabledCipherSuites()); + assertNull(unaddressed.getPeerHost()); + assertEquals(-1, unaddressed.getPeerPort()); + assertArrayEquals(replacementProtocols, unaddressed.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, unaddressed.getEnabledCipherSuites()); + assertArrayEquals(firstProtocols, existing.getEnabledProtocols()); + assertArrayEquals(firstCiphers, existing.getEnabledCipherSuites()); + + try (SSLSocket captured = (SSLSocket)capturedFactory.createSocket(); + SSLSocket current = (SSLSocket)shared.getSocketFactory().createSocket()) { + assertArrayEquals(firstProtocols, captured.getEnabledProtocols()); + assertArrayEquals(firstCiphers, captured.getEnabledCipherSuites()); + assertArrayEquals(replacementProtocols, current.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, current.getEnabledCipherSuites()); + } + } + + /** An initialized factory serializes its configuration and initializes fresh runtime state after deserialization. */ + @Test + public void testInitializedFactorySerializationKeepsConfiguration() throws Exception { + SslContextFactory factory = fileFactory(); + String[] protocols = {"TLSv1.2"}; + + factory.setProtocols(protocols); + + SslBundle original = factory.init(); + SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), + getClass().getClassLoader()); + + assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); + assertEquals(factory.getTrustStoreFilePath(), restored.getTrustStoreFilePath()); + assertArrayEquals(protocols, restored.getProtocols()); + + SSLContext shared = restored.create(); + + assertNotSame(factory.create(), shared); + assertSame(shared, restored.create()); + assertArrayEquals(protocols, shared.createSSLEngine().getEnabledProtocols()); + assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); + } + /** Reopening a communication connection does not call the SSL context factory on either node. */ @Test public void testCommunicationReusesContextOnReconnect() throws Exception { @@ -62,4 +149,25 @@ public void testCommunicationReusesContextOnReconnect() throws Exception { assertEquals(createdAtStart, created.get()); } + + /** Creates a file-backed factory with a generated private key and currently valid certificate chain. */ + private SslContextFactory fileFactory() throws Exception { + KeyStore store = new TestCertificateAuthority("context-reuse-ca").keyStoreWithPrivateKey(); + File storeFile = tmp.newFile("context.jks"); + + try (FileOutputStream out = new FileOutputStream(storeFile)) { + store.store(out, PWD); + } + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreType(store.getType()); + factory.setKeyStoreFilePath(storeFile.getAbsolutePath()); + factory.setKeyStorePassword(PWD); + factory.setTrustStoreType(store.getType()); + factory.setTrustStoreFilePath(storeFile.getAbsolutePath()); + factory.setTrustStorePassword(PWD); + + return factory; + } } From f6b57b3637fbbc41e794377a2b5bb7a22ece5c09 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 17:04:59 +0300 Subject: [PATCH 101/242] IGNITE-29209 Keep initialization tests and helpers within API scope --- .../jdbc/thin/JdbcThinConnectionSSLTest.java | 39 +++++++------------ .../ignite/ssl/AbstractSslContextFactory.java | 1 + .../apache/ignite/ssl/CurrentSslContext.java | 2 +- .../tcp/TcpDiscoverySslParametersTest.java | 8 ++++ .../TestCertificateAuthority.java | 25 +----------- 5 files changed, 24 insertions(+), 51 deletions(-) diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index 4707668125f83..2b8667c42850a 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -447,41 +447,28 @@ public void testUnsupportedCustomCipher() throws Exception { } } - /** - * @throws Exception If failed. - */ + /** An unknown cipher suite prevents a node from starting or a client from connecting. */ @Test public void testInvalidCustomCipher() throws Exception { setSslCtxFactoryToCli = true; supportedCiphers = new String[] {"TLS_RSA_WITH_INVALID_SHA256"}; sslCtxFactory = getTestSslContextFactory(); - startGrids(1); + GridTestUtils.assertThrowsAnyCause(log, () -> startGrids(1), IllegalArgumentException.class, "TLS_RSA_WITH_INVALID_SHA256"); - try { - // Same invalid cipher. - GridTestUtils.assertThrows(log, () -> { - return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + - "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + - "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + - "&sslClientCertificateKeyStorePassword=123456" + - "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + - "&sslTrustCertificateKeyStorePassword=123456"); - }, SQLException.class, "Failed to connect to server"); + supportedCiphers = null; + sslCtxFactory = getTestSslContextFactory(); - // Default ciphers. - GridTestUtils.assertThrows(log, () -> { - return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + - "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + - "&sslClientCertificateKeyStorePassword=123456" + - "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + - "&sslTrustCertificateKeyStorePassword=123456"); - }, SQLException.class, "Failed to SSL connect to server"); - } - finally { - stopAllGrids(); - } + startGrids(1); + GridTestUtils.assertThrows(log, () -> + DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + + "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + + "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + + "&sslClientCertificateKeyStorePassword=123456" + + "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + + "&sslTrustCertificateKeyStorePassword=123456"), + SQLException.class, "Failed to connect to server"); } /** diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 64e7e210309b6..1fdc27d99d155 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -210,6 +210,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded or the context cannot be created. + * @throws IllegalArgumentException If a configured protocol or cipher suite is unsupported. */ public final synchronized SslBundle init() { SslBundle next = createSslBundle(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java index 5d0903870e41f..9c5a6e0588822 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java @@ -29,7 +29,7 @@ import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; -/** Delegates to the current SSL context so that new connections use reloaded certificates. */ +/** Delegates new engines and newly obtained socket factories to the current SSL context. */ class CurrentSslContext extends SSLContext { /** * @param initial First accepted context, used for its provider and protocol. diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java index 70a4312aa92e0..399ab0e62372a 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java @@ -181,6 +181,14 @@ public void testNonExistentProtocol() throws Exception { ); } + /** A node with a protocol the JVM does not know must not start, even alone. */ + @Test + public void testNonExistentProtocolRefusedAtStart() { + protocols = new String[] {"SSLvDoesNotExist"}; + + GridTestUtils.assertThrows(log, () -> startGrid(0), IgniteCheckedException.class, "SSLvDoesNotExist"); + } + /** * @throws Exception If failed. */ diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 7dd13e7a8fbe1..42c09382bd0c6 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -33,8 +33,6 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; import javax.security.auth.x500.X500Principal; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; @@ -46,7 +44,7 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; /** - * Creates test stores protected by {@link #PWD}. Private keys use EC P-256, and trust stores hold authority certificates. + * Creates test stores protected by {@link #PWD}. Private keys use EC P-256. * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, * so the same material works for node-to-node TLS. */ @@ -130,15 +128,6 @@ public static KeyStore keyStoreWithoutCertificateChain(String type, String alias return store; } - /** @return Trust store with this authority alone. */ - public KeyStore trustStore() throws Exception { - KeyStore store = emptyStore(); - - store.setCertificateEntry(name.getName(), cert); - - return store; - } - /** * @param keyStore Store with exactly one private key. * @return Its certificate chain in the form certificate metadata uses. @@ -164,18 +153,6 @@ public static List chain(KeyStore keyStore) throws Exception { return chain; } - /** - * @param trustStore Store with the authorities to trust. - * @return Trust managers that trust them. - */ - public static TrustManager[] trustManagers(KeyStore trustStore) throws Exception { - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - tmf.init(trustStore); - - return tmf.getTrustManagers(); - } - /** * @param subj Subject. * @param subjKeys Key pair of the subject. From 9b80a431fea9d207d2b3e4712460d6fc09437fd9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 17:09:55 +0300 Subject: [PATCH 102/242] IGNITE-29210 Remove test-only SSL provider context accessor --- .../apache/ignite/internal/ssl/SslContextProvider.java | 5 ----- .../ignite/internal/ssl/SslContextProviderTest.java | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index fcd6d9e8d3514..d1e2accd1bb1f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -60,11 +60,6 @@ Factory factory() { return factory; } - /** @return Context to open the next connection with. */ - SSLContext context() { - return bundle().context(); - } - /** @return Context and certificate metadata currently accepted by the factory. */ SslBundle bundle() { return sslFactory.sslBundle(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 12d265a77fabb..5362fcec3cb80 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -89,7 +89,7 @@ public void testReloadInstallsNewBundle() throws Exception { p.reload(); - assertSame(next.context(), p.context()); + assertSame(next.context(), p.bundle().context()); assertEquals(Map.of("a", chain(a), "b", chain(b)), p.certificateChains()); verify(f, times(2)).createSslBundle(); assertTrue(p.reloadOutcome().successTime() > 0); @@ -111,7 +111,7 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { f.init(); assertSame(next, p.bundle()); - assertSame(next.context(), p.context()); + assertSame(next.context(), p.bundle().context()); assertEquals(next.certificateChains(), p.certificateChains()); assertSame(shared, p.sharedContext()); assertSame(f.reloadOutcome(), p.reloadOutcome()); @@ -147,7 +147,7 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { ReloadOutcome failed = p.reloadOutcome(); - assertSame(reloaded.context(), p.context()); + assertSame(reloaded.context(), p.bundle().context()); assertEquals(reloaded.certificateChains(), p.certificateChains()); assertTrue(failed.failureTime() >= failureStart); assertEquals(new ReloadOutcome(successTime, failed.failureTime(), "Failed to load: bad file", 2), failed); @@ -275,13 +275,13 @@ public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { * @param reason Expected part of the failure message; also names the case in assertion messages. */ private void assertReloadRejected(SslContextProvider p, String reason) { - SSLContext inUse = p.context(); + SSLContext inUse = p.bundle().context(); Map> chains = p.certificateChains(); int failures = p.reloadOutcome().failures(); GridTestUtils.assertThrows(log, p::reload, Exception.class, reason); - Assert.assertSame(reason, inUse, p.context()); + Assert.assertSame(reason, inUse, p.bundle().context()); assertEquals(reason, chains, p.certificateChains()); assertEquals(reason, failures + 1, p.reloadOutcome().failures()); assertContains(log, p.reloadOutcome().failureReason(), reason); From dec35ba9154a944950f886acf5b75251c604e777 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 17:11:40 +0300 Subject: [PATCH 103/242] IGNITE-29211 Remove test-only SSL provider forwarding --- .../internal/ssl/SslContextProvider.java | 15 --------- .../internal/ssl/SslContextProviderTest.java | 10 +++--- .../internal/ssl/SslRenewalNodeTest.java | 10 +++--- .../ignite/internal/ssl/SslRenewalTest.java | 31 ++++++++++--------- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index f8988ad7d6a69..171be7e244323 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -25,7 +25,6 @@ import java.util.concurrent.ScheduledExecutorService; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; @@ -63,11 +62,6 @@ Factory factory() { return factory; } - /** @return Context to open the next connection with. */ - SSLContext context() { - return bundle().context(); - } - /** @return Context and certificate metadata currently accepted by the factory. */ SslBundle bundle() { return sslFactory.sslBundle(); @@ -111,15 +105,6 @@ int renewalFailures() { return sslFactory.renewalFailures(); } - /** - * @param expected Context the renewal was planned for. - * @return Installed bundle, or {@code null} if unchanged or already replaced. - * @throws IgniteCheckedException If renewal is interrupted. - */ - @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { - return sslFactory.renew(expected); - } - /** @return Context and certificate metadata accepted by the factory's initialization. */ public SslBundle reload() { return sslFactory.init(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 12d265a77fabb..5362fcec3cb80 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -89,7 +89,7 @@ public void testReloadInstallsNewBundle() throws Exception { p.reload(); - assertSame(next.context(), p.context()); + assertSame(next.context(), p.bundle().context()); assertEquals(Map.of("a", chain(a), "b", chain(b)), p.certificateChains()); verify(f, times(2)).createSslBundle(); assertTrue(p.reloadOutcome().successTime() > 0); @@ -111,7 +111,7 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { f.init(); assertSame(next, p.bundle()); - assertSame(next.context(), p.context()); + assertSame(next.context(), p.bundle().context()); assertEquals(next.certificateChains(), p.certificateChains()); assertSame(shared, p.sharedContext()); assertSame(f.reloadOutcome(), p.reloadOutcome()); @@ -147,7 +147,7 @@ public void testReloadFailureRetainsContextUntilRecovery() throws Exception { ReloadOutcome failed = p.reloadOutcome(); - assertSame(reloaded.context(), p.context()); + assertSame(reloaded.context(), p.bundle().context()); assertEquals(reloaded.certificateChains(), p.certificateChains()); assertTrue(failed.failureTime() >= failureStart); assertEquals(new ReloadOutcome(successTime, failed.failureTime(), "Failed to load: bad file", 2), failed); @@ -275,13 +275,13 @@ public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { * @param reason Expected part of the failure message; also names the case in assertion messages. */ private void assertReloadRejected(SslContextProvider p, String reason) { - SSLContext inUse = p.context(); + SSLContext inUse = p.bundle().context(); Map> chains = p.certificateChains(); int failures = p.reloadOutcome().failures(); GridTestUtils.assertThrows(log, p::reload, Exception.class, reason); - Assert.assertSame(reason, inUse, p.context()); + Assert.assertSame(reason, inUse, p.bundle().context()); assertEquals(reason, chains, p.certificateChains()); assertEquals(reason, failures + 1, p.reloadOutcome().failures()); assertContains(log, p.reloadOutcome().failureReason(), reason); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index 5c2b5e938ab0a..ff9e0edfff1f9 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -43,7 +43,6 @@ import org.apache.logging.log4j.Level; import org.junit.Test; -import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; @@ -147,7 +146,6 @@ public void testRenewalFailuresMetric() throws Exception { .setSslEnabled(true).setSslClientAuth(false).setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory)); }); SslContextProvider node = provider(g, COMMUNICATION); - SslContextProvider client = provider(g, CLIENT_CONNECTOR); ReadOnlyMetricRegistry ssl = g.context().metric().registry("ssl"); Iterator metrics = ssl.iterator(); IntMetric failures = ssl.findMetric("ConsecutiveRenewalFailures"); @@ -155,15 +153,15 @@ public void testRenewalFailuresMetric() throws Exception { assertEquals("ssl.ConsecutiveRenewalFailures", metrics.next().name()); assertFalse(metrics.hasNext()); - node.renew(node.context()); - client.renew(client.context()); - client.renew(client.context()); + nodeFactory.renew(nodeFactory.sslBundle().context()); + clientFactory.renew(clientFactory.sslBundle().context()); + clientFactory.renew(clientFactory.sslBundle().context()); assertEquals("Both factories", 3, failures.value()); save(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), nodeKeys); - node.renew(node.context()); + nodeFactory.renew(nodeFactory.sslBundle().context()); assertEquals("Node factory renewed", 2, failures.value()); assertEquals(0, node.nextRenewalTime()); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index cd787c47737d9..60b84e54cdfb0 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -157,9 +157,9 @@ public void testRenewInstallsAllNewChains() throws Exception { SslContextProvider p = new SslContextProvider(f); - p.renew(p.context()); + f.renew(f.sslBundle().context()); - assertSame(renewed.context(), p.context()); + assertSame(renewed.context(), p.bundle().context()); assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), p.certificateChains()); } @@ -176,7 +176,7 @@ public void testRenewalCanRemoveEarliestChain() throws Exception { SslContextProvider p = new SslContextProvider(f); - assertSame(replacement, p.renew(p.context())); + assertSame(replacement, f.renew(f.sslBundle().context())); assertEquals(Map.of("remaining", chain(remaining)), p.certificateChains()); } @@ -193,7 +193,7 @@ public void testExpiredCertificateRenewed() throws Exception { SslContextProvider p = new SslContextProvider(f); - p.renew(p.context()); + f.renew(f.sslBundle().context()); assertEquals(Map.of("valid", chain(valid)), p.certificateChains()); } @@ -236,11 +236,11 @@ public void testUnchangedCertificatesAwaited() throws Exception { SslContextProvider cachingProvider = new SslContextProvider(caching); SslContextProvider expiredProvider = new SslContextProvider(expiredAgain); - assertNull("Cached bundle", cachingProvider.renew(cachingProvider.context())); + assertNull("Cached bundle", caching.renew(caching.sslBundle().context())); assertEquals("Cached bundle", 1, cachingProvider.renewalFailures()); assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), cachingProvider.reloadOutcome()); - assertNull("Expired chains", expiredProvider.renew(expiredProvider.context())); + assertNull("Expired chains", expiredAgain.renew(expiredAgain.sslBundle().context())); assertEquals("Expired chains", 1, expiredProvider.renewalFailures()); assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredProvider.reloadOutcome()); } @@ -295,13 +295,13 @@ public void testRenewForReplacedContextDoesNothing() throws Exception { bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); SslContextProvider p = new SslContextProvider(f); - SSLContext planned = p.context(); + SSLContext planned = p.bundle().context(); f.init(); SslBundle reloaded = p.bundle(); - assertNull(p.renew(planned)); + assertNull(f.renew(planned)); assertSame(reloaded, p.bundle()); assertEquals(0, p.renewalFailures()); verify(f, times(2)).createSslBundle(); @@ -335,7 +335,7 @@ public void testInterruptedRenewalChangesNothing() throws Exception { SslBundle inUse = p.bundle(); try { - GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); + GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); } finally { assertTrue("Same chains: the interrupt flag stays set", Thread.interrupted()); @@ -346,7 +346,7 @@ public void testInterruptedRenewalChangesNothing() throws Exception { assertEquals("Same chains", new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); try { - GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); + GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); } finally { assertTrue("New chains: the interrupt flag stays set", Thread.interrupted()); @@ -557,14 +557,14 @@ public void testUnsuccessfulRenewalsCounted() throws Exception { SslContextProvider p = new SslContextProvider(f); - assertNull(p.renew(p.context())); + assertNull(f.renew(f.sslBundle().context())); assertEquals("Chains in use", 1, p.renewalFailures()); - GridTestUtils.assertThrows(log, () -> p.renew(p.context()), IgniteException.class, ISSUER_DOWN); + GridTestUtils.assertThrows(log, () -> f.renew(f.sslBundle().context()), IgniteException.class, ISSUER_DOWN); assertEquals("Factory failure", 2, p.renewalFailures()); - assertNotNull(p.renew(p.context())); + assertNotNull(f.renew(f.sslBundle().context())); assertEquals("Renewed", 0, p.renewalFailures()); } @@ -588,7 +588,7 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio SslContextProvider p = new SslContextProvider(f); - p.renew(p.context()); + f.renew(f.sslBundle().context()); GridTestUtils.assertThrows(log, f::init, IgniteException.class, ISSUER_DOWN); @@ -952,10 +952,11 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { * @param reason Expected part of the failure message. */ private void assertRenewalRejected(String desc, SslContextProvider p, String reason) { + AbstractSslContextFactory f = (AbstractSslContextFactory)p.factory(); SslBundle inUse = p.bundle(); int failures = p.renewalFailures(); - GridTestUtils.assertThrows(log, () -> p.renew(inUse.context()), IgniteException.class, reason, desc + ": not refused"); + GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteException.class, reason, desc + ": not refused"); Assert.assertSame(desc, inUse, p.bundle()); assertEquals(desc, failures + 1, p.renewalFailures()); From de9fcf94f0b02886a8751b526bd611ea0cf364d2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 17:14:40 +0300 Subject: [PATCH 104/242] IGNITE-29210 Keep consistency cancellation fix in its own branch --- .../ignite/util/KillCommandsControlShTest.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java index 14ce472d93c41..e195c5ba00144 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/KillCommandsControlShTest.java @@ -281,7 +281,7 @@ public void testCancelConsistencyMissedTask() { * */ @Test - public void testCancelConsistencyTaskParallel() throws Exception { + public void testCancelConsistencyTaskParallel() throws InterruptedException { testCancelConsistencyTask(true); } @@ -289,14 +289,14 @@ public void testCancelConsistencyTaskParallel() throws Exception { * */ @Test - public void testCancelConsistencyTaskSequential() throws Exception { + public void testCancelConsistencyTaskSequential() throws InterruptedException { testCancelConsistencyTask(false); } /** * */ - private void testCancelConsistencyTask(boolean parallel) throws Exception { + private void testCancelConsistencyTask(boolean parallel) throws InterruptedException { Assume.assumeTrue(cliCommandHandler()); String consistencyCacheName = "consistencyCache"; @@ -427,13 +427,11 @@ else if (foundLocally) { testOut.reset(); - boolean cleared = GridTestUtils.waitForCondition(() -> { - assertEquals(EXIT_CODE_OK, execute("--consistency", "status")); + int res = execute("--consistency", "status"); - return testOut.toString().contains(ConsistencyStatusTask.NOTHING_FOUND); - }, 5_000); + assertEquals(EXIT_CODE_OK, res); - assertTrue("Consistency status did not clear:\n" + testOut, cleared); + assertContains(log, testOut.toString(), ConsistencyStatusTask.NOTHING_FOUND); assertNotContains(log, testOut.toString(), "Status"); } } From 79323d14dc65f0e66def9833f43c99492cc7f53b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 18:07:54 +0300 Subject: [PATCH 105/242] IGNITE-29211 Honor overridden SSL renewal settings --- .../ignite/ssl/AbstractSslContextFactory.java | 10 ++-- .../ignite/internal/ssl/SslRenewalTest.java | 51 ++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 053f2582393c1..a5098b89fb3a2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -334,8 +334,10 @@ public final synchronized SslBundle init() { * @throws IgniteException If renewal is already attached to another running node or certificate metadata is unavailable. */ public final synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { + boolean enabled = isRenewalEnabled(); + if (renewalExec != null && !renewalExec.isShutdown()) { - if (renewalExec != exec && (renewal != null || renewalEnabled)) + if (renewalExec != exec && (renewal != null || enabled)) throw new IgniteException("The SSL context factory already renews certificates for another running node; " + "configure a separate factory instance on each node"); @@ -345,15 +347,15 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit renewal = null; renewalExec = null; - if (renewalEnabled && SslCertificates.renewalChain(sslBundle()) == null) { + if (enabled && SslCertificates.renewalChain(sslBundle()) == null) { throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + "to renew automatically [transports=" + transports.get() + ']'); } renewalExec = exec; - if (renewalEnabled) { - renewal = new SslRenewal(this, renewBeforeFraction, exec, log, transports); + if (enabled) { + renewal = new SslRenewal(this, getRenewBeforeFraction(), exec, log, transports); renewal.replan(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 60b84e54cdfb0..8a1013e3cc8c1 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -64,12 +64,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; /** - * Renewal without nodes: its settings, {@link SslContextProvider#renew(SSLContext)}, the count of unsuccessful renewals and the + * Renewal without nodes: its settings, {@link AbstractSslContextFactory#renew(SSLContext)}, the count of unsuccessful renewals and the * scheduler. Renewal is off by default. renew installs all new chains, even in place of an expired certificate. Its refusals * follow the order of the checks in the code: it applies the checks of reload, waits while the factory returns the chains in * use, refuses a cached context with new metadata and refuses chains that do not advance the earliest expiry. It ignores a @@ -401,6 +403,53 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { GridTestUtils.assertThrows(log, () -> noChainsProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); } + /** A subclass can disable renewal even when configured, without requiring chains or using the executor. */ + @Test + public void testRenewalCanBeDisabledBySubclass() throws Exception { + SslBundle noChains = bundle(ca.trustStore(), ca.trustStore()); + AbstractSslContextFactory factory = new SslContextFactory() { + @Override public boolean isRenewalEnabled() { + return false; + } + + @Override public SslBundle createSslBundle() { + return noChains; + } + }; + ScheduledExecutorService exec = mock(ScheduledExecutorService.class); + + factory.setRenewalEnabled(true); + factory.init(); + factory.startRenewal(exec, renewalLog, () -> ""); + + assertSame(noChains, factory.sslBundle()); + assertEquals(0, factory.nextRenewalTime()); + verifyNoInteractions(exec); + } + + /** Renewal uses the fraction supplied by a subclass when the node attaches its executor. */ + @Test + public void testRenewalWindowUsesOverriddenFraction() throws Exception { + long now = System.currentTimeMillis() / SECOND * SECOND; + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + 3 * HOUR), ca.trustStore()); + AbstractSslContextFactory factory = new SslContextFactory() { + @Override public double getRenewBeforeFraction() { + return QUARTER; + } + + @Override public SslBundle createSslBundle() { + return startup; + } + }; + RecordingExecutor exec = new RecordingExecutor(); + + factory.setRenewalEnabled(true); + factory.startRenewal(exec, renewalLog, () -> ""); + exec.runNext(); + + assertEquals(now + 2 * HOUR, factory.nextRenewalTime()); + } + /** A factory accepts one renewal executor, and attaching it again does not duplicate its schedule. */ @Test public void testRenewalAttachesOnce() throws Exception { From 19c2dd7647d1a6a767ff684c79b31bd1f3e548cc Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 19:55:49 +0300 Subject: [PATCH 106/242] IGNITE-29209 Return accepted SSL context snapshots --- .../ignite/ssl/AbstractSslContextFactory.java | 49 ++++------ .../apache/ignite/ssl/CurrentSslContext.java | 97 ------------------- .../ignite/ssl/SslContextMetadataTest.java | 57 +++++++++-- .../ignite/ssl/SslContextReuseTest.java | 34 +++---- 4 files changed, 84 insertions(+), 153 deletions(-) delete mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 1fdc27d99d155..8b23b92bcfba9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -30,10 +30,10 @@ /** * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; - * {@link #create()} returns a stable context. Its newly created engines and newly obtained socket factories use the - * accepted material; previously obtained engines, socket factories and sockets retain their original material. + * {@link #create()} returns the accepted context. Obtain it again after initialization to use the replacement; + * previously obtained contexts, engines, socket factories and sockets retain their original material. * Configure the factory before its first use. Reusing an initialized factory, including after a node restart, retains - * its material until {@code init()} is called. + * its material until {@code init()} succeeds. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -45,9 +45,6 @@ public abstract class AbstractSslContextFactory implements Factory { /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ private transient volatile SslBundle bundle; - /** Stable context returned to consumers after the first successful initialization. */ - private transient volatile SSLContext sharedCtx; - /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -205,8 +202,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException /** * Loads keys and trust settings and atomically replaces the accepted context and its metadata. A failed initialization - * leaves the previous material in use. New engines and socket factories obtained through {@link #create()} use the - * replacement; already opened connections keep their original material. + * leaves the previous material in use. Call {@link #create()} again to obtain the replacement; previously obtained + * contexts and their connections keep their original material. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded or the context cannot be created. @@ -217,13 +214,7 @@ public final synchronized SslBundle init() { next.context().createSSLEngine(); - SSLContext shared = sharedCtx; - - if (shared == null) - shared = new CurrentSslContext(next.context(), () -> bundle.context()); - bundle = next; - sharedCtx = shared; return next; } @@ -235,27 +226,29 @@ public final synchronized SslBundle init() { * @throws IgniteException If the first initialization fails. */ public final SslBundle sslBundle() { - create(); + SslBundle cur = bundle; + + if (cur == null) { + synchronized (this) { + cur = bundle; + + if (cur == null) + cur = init(); + } + } - return bundle; + return cur; } /** - * Returns the same forwarding context on every call, initializing it on first use. Obtain new engines and socket - * factories from this context to use the material accepted by the last successful {@link #init()}. Repeated calls - * do not read the key or trust stores. + * Returns the accepted context, initializing it on first use. Repeated calls do not read the key or trust stores. + * After a successful {@link #init()}, call this method again to obtain the replacement. Previously returned contexts + * retain their original material. Do not reinitialize the returned context directly. * - * @return Stable initialized context. + * @return Currently accepted context. * @throws IgniteException If the first initialization fails. */ @Override public final SSLContext create() { - if (sharedCtx == null) { - synchronized (this) { - if (sharedCtx == null) - init(); - } - } - - return sharedCtx; + return sslBundle().context(); } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java b/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java deleted file mode 100644 index 9c5a6e0588822..0000000000000 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/CurrentSslContext.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.security.SecureRandom; -import java.util.function.Supplier; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLContextSpi; -import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.SSLSessionContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; - -/** Delegates new engines and newly obtained socket factories to the current SSL context. */ -class CurrentSslContext extends SSLContext { - /** - * @param initial First accepted context, used for its provider and protocol. - * @param ctx Supplier of the context currently in use. - */ - CurrentSslContext(SSLContext initial, Supplier ctx) { - super(new Spi(ctx), initial.getProvider(), initial.getProtocol()); - } - - /** */ - private static class Spi extends SSLContextSpi { - /** */ - private final Supplier ctx; - - /** @param ctx Supplier of the context currently in use. */ - private Spi(Supplier ctx) { - this.ctx = ctx; - } - - /** {@inheritDoc} */ - @Override protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom rnd) { - throw new UnsupportedOperationException("The SSL context is built by its factory"); - } - - /** {@inheritDoc} */ - @Override protected SSLSocketFactory engineGetSocketFactory() { - return ctx.get().getSocketFactory(); - } - - /** {@inheritDoc} */ - @Override protected SSLServerSocketFactory engineGetServerSocketFactory() { - return ctx.get().getServerSocketFactory(); - } - - /** {@inheritDoc} */ - @Override protected SSLEngine engineCreateSSLEngine() { - return ctx.get().createSSLEngine(); - } - - /** {@inheritDoc} */ - @Override protected SSLEngine engineCreateSSLEngine(String host, int port) { - return ctx.get().createSSLEngine(host, port); - } - - /** {@inheritDoc} */ - @Override protected SSLSessionContext engineGetServerSessionContext() { - return ctx.get().getServerSessionContext(); - } - - /** {@inheritDoc} */ - @Override protected SSLSessionContext engineGetClientSessionContext() { - return ctx.get().getClientSessionContext(); - } - - /** {@inheritDoc} */ - @Override protected SSLParameters engineGetDefaultSSLParameters() { - return ctx.get().getDefaultSSLParameters(); - } - - /** {@inheritDoc} */ - @Override protected SSLParameters engineGetSupportedSSLParameters() { - return ctx.get().getSupportedSSLParameters(); - } - } -} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 9b8c3f7a92d15..5d21532d7b085 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -26,7 +26,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; import javax.net.ssl.SSLContext; @@ -34,6 +36,7 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; @@ -42,7 +45,9 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; /** * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous @@ -70,7 +75,8 @@ public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { assertSame(firstBundle, factory.sslBundle()); assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); - SSLContext shared = factory.create(); + assertSame(firstBundle.context(), factory.create()); + KeyStore second = ca.keyStoreWithPrivateKey("second"); factory.store = second; @@ -82,7 +88,7 @@ public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); assertNotSame(firstBundle.context(), secondBundle.context()); - assertSame(shared, factory.create()); + assertSame(secondBundle.context(), factory.create()); } /** Concurrent first calls initialize once; obtaining the context and accepted snapshot never reloads the store. */ @@ -114,21 +120,52 @@ public void testCreateAndSnapshotDoNotReloadStore() throws Exception { assertEquals(1, factory.loads); } - /** A failed reload leaves both the accepted snapshot and the forwarding context usable, and can be retried. */ + /** Readers retain the accepted material while initialization fails; a later attempt can replace it. */ @Test public void testFailedInitRetainsAcceptedBundle() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + InMemoryStoreFactory factory = spy(new InMemoryStoreFactory(ca.keyStoreWithPrivateKey())); SslBundle accepted = factory.init(); - SSLContext shared = factory.create(); + CountDownLatch loading = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + long timeout = 10_000; factory.loadFailure = "Key store is unavailable"; - Throwable err = GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); + doAnswer(inv -> { + loading.countDown(); + release.await(); + + return inv.callRealMethod(); + }).doCallRealMethod().when(factory).createSslBundle(); + + IgniteInternalFuture initialization = GridTestUtils.runAsync(() -> { + return factory.init(); + }); + IgniteInternalFuture reading = null; + + try { + assertTrue(loading.await(timeout, TimeUnit.MILLISECONDS)); + + reading = GridTestUtils.runAsync(() -> { + assertSame(accepted.context(), factory.create()); + + return factory.sslBundle(); + }); + + assertSame(accepted, reading.get(timeout)); + } + finally { + release.countDown(); + + if (reading != null) + reading.cancel(); + + GridTestUtils.assertThrowsWithCause(() -> initialization.get(timeout), SSLException.class); + } - assertTrue(err.getCause() instanceof SSLException); assertSame(accepted, factory.sslBundle()); - assertSame(shared, factory.create()); - assertNotNull(shared.createSSLEngine()); + assertSame(accepted.context(), factory.create()); + assertNotNull(accepted.context().createSSLEngine()); assertEquals(2, factory.loads); factory.loadFailure = null; @@ -137,7 +174,7 @@ public void testFailedInitRetainsAcceptedBundle() throws Exception { assertNotSame(accepted, replacement); assertSame(replacement, factory.sslBundle()); - assertSame(shared, factory.create()); + assertSame(replacement.context(), factory.create()); assertEquals(3, factory.loads); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index 242487c346b91..af0f024dc3616 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -39,7 +39,7 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; import static org.junit.Assert.assertArrayEquals; -/** Consumers retain one SSL context while initialization changes the material used by new connections. */ +/** Context snapshots keep their material; consumers obtain a replacement from the factory after initialization. */ public class SslContextReuseTest extends GridCommonAbstractTest { /** Stores generated independently for each test. */ @Rule public final TemporaryFolder tmp = new TemporaryFolder(); @@ -51,9 +51,9 @@ public class SslContextReuseTest extends GridCommonAbstractTest { super.afterTest(); } - /** New engines and socket factories use initialized TLS settings; previously obtained ones retain their settings. */ + /** A new context uses initialized TLS settings; the previous context, engines and socket factories keep their settings. */ @Test - public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Exception { + public void testCreateReturnsInitializedContextSnapshot() throws Exception { SslContextFactory factory = fileFactory(); String[] firstProtocols = {"TLSv1.2"}; String[] firstCiphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; @@ -61,9 +61,9 @@ public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Excep factory.setProtocols(firstProtocols); factory.setCipherSuites(firstCiphers); - SSLContext shared = factory.create(); - SSLEngine existing = shared.createSSLEngine(); - SSLSocketFactory capturedFactory = shared.getSocketFactory(); + SSLContext previous = factory.create(); + SSLEngine existing = previous.createSSLEngine(); + SSLSocketFactory capturedFactory = previous.getSocketFactory(); String[] replacementProtocols = {"TLSv1.3"}; String[] replacementCiphers = {"TLS_AES_128_GCM_SHA256"}; @@ -71,31 +71,29 @@ public void testNewEnginesAndSocketFactoriesUseInitializedContext() throws Excep factory.setCipherSuites(replacementCiphers); SslBundle replacement = factory.init(); + SSLContext cur = factory.create(); String host = "peer.example"; int port = 10800; - SSLEngine addressed = shared.createSSLEngine(host, port); - SSLEngine unaddressed = shared.createSSLEngine(); + SSLEngine addressed = cur.createSSLEngine(host, port); + SSLEngine fromPrevious = previous.createSSLEngine(); - assertSame(shared, factory.create()); - assertSame(replacement.context().getClientSessionContext(), shared.getClientSessionContext()); - assertSame(replacement.context().getServerSessionContext(), shared.getServerSessionContext()); + assertSame(replacement.context(), cur); + assertNotSame(previous, cur); assertEquals(host, addressed.getPeerHost()); assertEquals(port, addressed.getPeerPort()); assertArrayEquals(replacementProtocols, addressed.getEnabledProtocols()); assertArrayEquals(replacementCiphers, addressed.getEnabledCipherSuites()); - assertNull(unaddressed.getPeerHost()); - assertEquals(-1, unaddressed.getPeerPort()); - assertArrayEquals(replacementProtocols, unaddressed.getEnabledProtocols()); - assertArrayEquals(replacementCiphers, unaddressed.getEnabledCipherSuites()); assertArrayEquals(firstProtocols, existing.getEnabledProtocols()); assertArrayEquals(firstCiphers, existing.getEnabledCipherSuites()); + assertArrayEquals(firstProtocols, fromPrevious.getEnabledProtocols()); + assertArrayEquals(firstCiphers, fromPrevious.getEnabledCipherSuites()); try (SSLSocket captured = (SSLSocket)capturedFactory.createSocket(); - SSLSocket current = (SSLSocket)shared.getSocketFactory().createSocket()) { + SSLSocket replacementSocket = (SSLSocket)cur.getSocketFactory().createSocket()) { assertArrayEquals(firstProtocols, captured.getEnabledProtocols()); assertArrayEquals(firstCiphers, captured.getEnabledCipherSuites()); - assertArrayEquals(replacementProtocols, current.getEnabledProtocols()); - assertArrayEquals(replacementCiphers, current.getEnabledCipherSuites()); + assertArrayEquals(replacementProtocols, replacementSocket.getEnabledProtocols()); + assertArrayEquals(replacementCiphers, replacementSocket.getEnabledCipherSuites()); } } From f07e08043f20757f4c961732d95d4a55d7784777 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 20:07:56 +0300 Subject: [PATCH 107/242] IGNITE-29209 Initialize key managers with loaded key material --- .../org/apache/ignite/ssl/KeyMaterial.java | 46 ++++++++++--------- .../ignite/ssl/SslContextMetadataTest.java | 21 +++++---- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java index 565997946062b..55c888d72d304 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -36,45 +36,33 @@ import org.jetbrains.annotations.Nullable; /** - * A loaded key store and the settings needed to create its key managers. Both managers and certificate metadata are read from - * this store without loading it again. Do not modify the store while any context created from it is in use: key managers - * such as PKIX read it during handshakes. Load replacement material into a separate store. + * Key managers and certificate metadata initialized from one loaded key store. Do not modify the store while any context + * created from it is in use: key managers such as PKIX read it during handshakes. Load replacement material into a separate + * store. */ public final class KeyMaterial { /** */ - private final KeyStore store; + private final KeyManager[] keyMgrs; /** */ - private final char[] keyPwd; - - /** */ - private final String algorithm; + private final Map> chains; /** * @param store Loaded key store, never {@code null}. For a context without private keys, initialize an empty store with * {@code KeyStore.load(null, null)}. * @param keyPwd Password for its private keys, or {@code null} if the store requires no password. * @param algorithm Key manager algorithm. + * @throws SSLException If the algorithm is unavailable or the store cannot be read. */ - public KeyMaterial(KeyStore store, @Nullable char[] keyPwd, String algorithm) { + public KeyMaterial(KeyStore store, @Nullable char[] keyPwd, String algorithm) throws SSLException { A.notNull(store, "store", algorithm, "algorithm"); - this.store = store; - this.keyPwd = keyPwd == null ? null : keyPwd.clone(); - this.algorithm = algorithm; - } - - /** - * @return Key managers initialized from the loaded store. - * @throws SSLException If the algorithm is unavailable or the keys cannot be read. - */ - public KeyManager[] keyManagers() throws SSLException { try { KeyManagerFactory factory = KeyManagerFactory.getInstance(algorithm); factory.init(store, keyPwd); - return factory.getKeyManagers(); + keyMgrs = factory.getKeyManagers(); } catch (NoSuchAlgorithmException e) { throw new SSLException("Unsupported key algorithm: " + algorithm, e); @@ -82,15 +70,29 @@ public KeyManager[] keyManagers() throws SSLException { catch (GeneralSecurityException e) { throw new SSLException("Failed to initialize Key Manager (security exception occurred)", e); } + + chains = certificateChains(store); + } + + /** @return A copy of the array of initialized key managers. */ + public KeyManager[] keyManagers() { + return keyMgrs.clone(); } /** * @return Immutable, nonempty X.509 chains of key entries, ordered by alias and with each key's own certificate first. * Entries without a chain or with a non-X.509 certificate are omitted. An empty map means no such chains were found. - * Reading the chains does not recover the keys. + */ + public Map> certificateChains() { + return chains; + } + + /** + * @param store Loaded key store. + * @return Immutable certificate metadata. * @throws SSLException If the loaded store cannot be read. */ - public Map> certificateChains() throws SSLException { + private static Map> certificateChains(KeyStore store) throws SSLException { try { Map> chains = new TreeMap<>(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 5d21532d7b085..41c893fb40552 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -31,6 +31,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; @@ -51,8 +52,8 @@ /** * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous - * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} reports nonempty X.509 chains - * without recovering keys and keeps its own key password. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. + * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} initializes key managers and + * reports nonempty X.509 chains. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -238,7 +239,7 @@ public void testInvalidParametersDoNotLoadStore() throws Exception { assertEquals(0, factory.loads); } - /** Metadata includes only nonempty X.509 chains of key entries, without recovering the keys. */ + /** Metadata includes only nonempty X.509 chains of key entries. */ @Test public void testOnlyKeyEntriesWithX509Chains() throws Exception { String firstAlias = "first-key"; @@ -254,9 +255,9 @@ public void testOnlyKeyEntriesWithX509Chains() throws Exception { store.setEntry(secondAlias, second.getEntry(secondAlias, prot), prot); store.setCertificateEntry("trusted", first.getCertificate(firstAlias)); store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), - new KeyStore.PasswordProtection("unrelated-password".toCharArray())); + prot); store.setKeyEntry("non-x509-chain", first.getKey(firstAlias, PWD), PWD, - new Certificate[] {first.getCertificate(firstAlias), mock(Certificate.class)}); + new Certificate[] {mock(Certificate.class), first.getCertificate(firstAlias)}); KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); @@ -275,17 +276,21 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { assertEquals(Map.of(), bundle.certificateChains()); } - /** Material keeps its own copy of the key password, so clearing the caller's array does not lock the keys. */ + /** Initialized managers remain usable after clearing the password or changing a returned manager array. */ @Test - public void testLoadedMaterialCopiesKeyPassword() throws Exception { + public void testLoadedMaterialKeepsInitializedKeyManagers() throws Exception { char[] pwd = PWD.clone(); KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey("node"), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); Arrays.fill(pwd, '?'); - X509KeyManager mgr = (X509KeyManager)material.keyManagers()[0]; + KeyManager[] mgrs = material.keyManagers(); + X509KeyManager mgr = (X509KeyManager)mgrs[0]; + + mgrs[0] = null; assertNotNull(mgr.getPrivateKey("node")); + assertSame(mgr, material.keyManagers()[0]); } /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ From 25894249531a37c0963178a7f4769453f6f94f42 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 20:13:43 +0300 Subject: [PATCH 108/242] IGNITE-29209 Keep SSL bundle construction as a protected hook --- .../apache/ignite/ssl/AbstractSslContextFactory.java | 2 +- .../org/apache/ignite/ssl/SslContextMetadataTest.java | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 8b23b92bcfba9..3bacae5565e1d 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -120,7 +120,7 @@ public void setProtocols(String... protocols) { * @return Fresh context and the certificate metadata available to this factory. * @throws IgniteException If the context could not be created. */ - public SslBundle createSslBundle() { + protected SslBundle createSslBundle() { try { checkParameters(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 41c893fb40552..9f06c1c0dd104 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -257,9 +257,10 @@ public void testOnlyKeyEntriesWithX509Chains() throws Exception { store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), prot); store.setKeyEntry("non-x509-chain", first.getKey(firstAlias, PWD), PWD, - new Certificate[] {mock(Certificate.class), first.getCertificate(firstAlias)}); + new Certificate[] {first.getCertificate(firstAlias), mock(Certificate.class)}); - KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); + // PKIX defers key selection, allowing metadata filtering to inspect the entire mixed certificate chain. + KeyMaterial material = new KeyMaterial(store, PWD, "PKIX"); assertEquals(Map.of(firstAlias, chain(first), secondAlias, chain(second)), material.certificateChains()); } @@ -279,8 +280,9 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { /** Initialized managers remain usable after clearing the password or changing a returned manager array. */ @Test public void testLoadedMaterialKeepsInitializedKeyManagers() throws Exception { + String alias = "node"; char[] pwd = PWD.clone(); - KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey("node"), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); + KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey(alias), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); Arrays.fill(pwd, '?'); @@ -289,7 +291,7 @@ public void testLoadedMaterialKeepsInitializedKeyManagers() throws Exception { mgrs[0] = null; - assertNotNull(mgr.getPrivateKey("node")); + assertNotNull(mgr.getPrivateKey(alias)); assertSame(mgr, material.keyManagers()[0]); } From 1b17a46fd4c4e28da603f459174f41fa9ce10da5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 20:20:24 +0300 Subject: [PATCH 109/242] IGNITE-29209 Clarify prepared key material hook contract --- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 3bacae5565e1d..772ac9c83351e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -186,7 +186,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract void checkParameters() throws SSLException; /** - * Loads the key store and the settings used to read its keys. Called once per context, after the parameters are checked. + * Loads the key store and initializes its key managers and certificate metadata. Called once per context, after the + * parameters are checked. * For a context without private keys, return material over an empty store initialized with {@code KeyStore.load(null, null)}. * * @return Loaded key material, never {@code null}. From f77be52c333304cc5057c39938fc4c85423167fa Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:26:30 +0300 Subject: [PATCH 110/242] IGNITE-29210 Simplify SSL test material and factory hooks --- .../util/GridCommandHandlerSslReloadTest.java | 45 +++++++++++-------- .../internal/ssl/SslContextProvider.java | 2 +- .../internal/ssl/SslContextProviderTest.java | 2 +- .../ignite/ssl/SslContextReloadTest.java | 12 +++-- .../TestCertificateAuthority.java | 18 +++++--- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 59585a0ab06a3..cdea9be66c6a8 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -17,10 +17,8 @@ package org.apache.ignite.util; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.List; import java.util.Map; @@ -32,7 +30,7 @@ import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.management.ssl.SslReloadCommand; import org.apache.ignite.ssl.SslContextFactory; -import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; import org.junit.Assume; import org.junit.Test; @@ -41,6 +39,8 @@ import static org.apache.ignite.ssl.SslTestUtils.node; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.apache.ignite.testframework.TestCertificateAuthority.save; /** * {@code --ssl reload} and {@code --ssl status} through control.sh. Only the CLI handler runs the test: it connects to a node over @@ -51,6 +51,9 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractT /** Key store the nodes run on; replaced on disk to rotate the certificate. */ private Path keyStore; + /** CLI credentials, kept readable when the node key store is replaced or corrupted. */ + private Path clientKeyStore; + /** {@inheritDoc} */ @Override protected boolean sslEnabled() { return true; @@ -61,19 +64,23 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractT SslContextFactory factory = new SslContextFactory(); factory.setKeyStoreFilePath(keyStore.toString()); - factory.setKeyStorePassword(GridTestUtils.keyStorePassword().toCharArray()); + factory.setKeyStorePassword(PWD); factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); return factory; } + /** {@inheritDoc} */ + @Override protected void extendSslParams(List params) { + params.addAll(List.of("--keystore", clientKeyStore.toString(), "--keystore-password", new String(PWD))); + } + /** {@inheritDoc} */ @Override protected void beforeTest() throws Exception { Assume.assumeTrue(cliCommandHandler()); keyStore = Files.createTempFile("ignite-cli-ssl-reload-", ".jks"); - - place("node01", keyStore); + clientKeyStore = Files.createTempFile("ignite-cli-ssl-client-", ".jks"); super.beforeTest(); @@ -88,6 +95,9 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractT if (keyStore != null) Files.deleteIfExists(keyStore); + + if (clientKeyStore != null) + Files.deleteIfExists(clientKeyStore); } /** @@ -98,6 +108,13 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerAbstractT public void testReloadAndStatus() throws Exception { assertNotNull("The reload must ask for confirmation", new SslReloadCommand().confirmationPrompt(new NoArg())); + TestCertificateAuthority ca = new TestCertificateAuthority("cli-ca"); + String initial = "initial-key"; + String replacement = "replacement-key"; + + save(ca.keyStoreWithPrivateKey(initial), keyStore); + save(ca.keyStoreWithPrivateKey(), clientKeyStore); + IgniteEx srv0 = startGrid(0); IgniteEx srv1 = startGrid(1); IgniteEx cli = startClientGrid(CLIENT_NODE_NAME_PREFIX); @@ -108,11 +125,9 @@ public void testReloadAndStatus() throws Exception { srv1, "binary REST, client connector, communication, discovery", cli, "client connector, communication, discovery"); - String replacement = "node02"; - String replacementSubj = "CN=" + replacement; - String loaded = "loaded alias=" + replacement + ", subject=" + replacementSubj; + String loaded = "loaded alias=" + replacement + ", subject=CN=" + replacement; - place(replacement, keyStore); + save(ca.keyStoreWithPrivateKey(replacement), keyStore); long start = System.currentTimeMillis(); String reloadOutput = executeCommand(EXIT_CODE_OK, "--ssl", "reload"); @@ -124,7 +139,7 @@ public void testReloadAndStatus() throws Exception { transports.forEach((g, t) -> assertContains(log, statusOutput, node(g) + ": " + t + '\n')); assertContains(log, statusOutput, loaded); - assertNotContains(log, statusOutput, "subject=CN=node01"); + assertNotContains(log, statusOutput, "subject=CN=" + initial); List succeeded = Pattern.compile("last reload succeeded at (\\S+)").matcher(statusOutput).results() .map(m -> Instant.parse(m.group(1))).collect(Collectors.toList()); @@ -136,12 +151,4 @@ public void testReloadAndStatus() throws Exception { assertContains(log, executeCommand(EXIT_CODE_UNEXPECTED_ERROR, "--ssl", "reload"), "Failed to initialize key store"); } - - /** - * @param name Test store, as {@code tests.properties} names it. - * @param dest File to replace. - */ - private static void place(String name, Path dest) throws IOException { - Files.copy(Path.of(GridTestUtils.keyStorePath(name)), dest, StandardCopyOption.REPLACE_EXISTING); - } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 8e44a772e3ac1..b280742fe00b9 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -50,7 +50,7 @@ public class SslContextProvider { sslFactory = factory instanceof AbstractSslContextFactory f ? f : new SslContextFactory() { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override protected SslBundle createSslBundle() { return new SslBundle(factory.create(), null); } }; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index f9e807564d6b1..e1d2948fc92b2 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -345,7 +345,7 @@ private void assertReloadRejected(SslContextProvider p, String reason) { /** Exposes the loading hook so tests can stub the material source. */ private static class TestSslContextFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override public SslBundle createSslBundle() throws SSLException { return super.createSslBundle(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 68d244df90bb5..6fe8e9d25e161 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -584,8 +584,11 @@ private static class CountingFactory implements Factory { /** */ private static final long serialVersionUID = 0L; - /** Factory that reads the stores. */ - private final SslContextFactory delegate; + /** Key store file. */ + private final Path keys; + + /** Trust store file. */ + private final Path trust; /** Calls of {@link #create()}. */ private final AtomicInteger loads = new AtomicInteger(); @@ -595,14 +598,15 @@ private static class CountingFactory implements Factory { * @param trust Trust store file. */ private CountingFactory(Path keys, Path trust) { - delegate = storeFactory(keys, trust); + this.keys = keys; + this.trust = trust; } /** {@inheritDoc} */ @Override public SSLContext create() { loads.incrementAndGet(); - return delegate.createSslBundle().context(); + return storeFactory(keys, trust).create(); } } } diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index e490580e9c2c8..0b20f3cde7ed3 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -33,7 +33,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.KeyManager; @@ -42,7 +44,6 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.security.auth.x500.X500Principal; -import org.apache.ignite.ssl.KeyMaterial; import org.apache.ignite.ssl.SslBundle; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.ExtendedKeyUsage; @@ -229,17 +230,22 @@ public static SSLContext context(KeyStore keyStore, KeyStore trustStore) throws } /** - * @param keyStore Store with the certificates to present. + * @param keyStore Generated store with zero or more private keys. * @param trustStore Store with the authorities to trust. * @return Context and certificate chains from the supplied stores. */ public static SslBundle bundle(KeyStore keyStore, KeyStore trustStore) throws Exception { - KeyMaterial material = new KeyMaterial(keyStore, PWD, KeyManagerFactory.getDefaultAlgorithm()); - SSLContext ctx = SSLContext.getInstance("TLS"); + Map> chains = new HashMap<>(); + + for (String alias : Collections.list(keyStore.aliases())) { + if (keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) { + Certificate[] certs = keyStore.getCertificateChain(alias); - ctx.init(material.keyManagers(), trustManagers(trustStore), null); + chains.put(alias, List.of(Arrays.copyOf(certs, certs.length, X509Certificate[].class))); + } + } - return new SslBundle(ctx, material.certificateChains()); + return new SslBundle(context(keyStore, trustStore), chains); } /** From ea50365d767826c4affd76179dd4cc3202693705 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:27:39 +0300 Subject: [PATCH 111/242] IGNITE-29211 Test renewal through factories and snapshot status time --- .../management/ssl/SslStatusTask.java | 6 +- .../ignite/internal/ssl/SslRenewalTest.java | 185 +++++++++--------- 2 files changed, 100 insertions(+), 91 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index ec5981943d96d..b6b92f002d18a 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -110,8 +110,10 @@ else if (chains.isEmpty()) else if (outcome.successTime() > 0) lines.add(DOUBLE_INDENT + "last reload succeeded at " + Instant.ofEpochMilli(outcome.successTime())); - if (provider.nextRenewalTime() > 0) - lines.add(DOUBLE_INDENT + "next automatic renewal at " + Instant.ofEpochMilli(provider.nextRenewalTime())); + long nextRenewalTime = provider.nextRenewalTime(); + + if (nextRenewalTime > 0) + lines.add(DOUBLE_INDENT + "next automatic renewal at " + Instant.ofEpochMilli(nextRenewalTime)); } return new IgniteBiTuple<>(!invalid, String.join("\n", lines)); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index b05e135209fad..c3928a79af302 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -34,6 +34,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.IgniteInterruptedCheckedException; @@ -157,12 +158,12 @@ public void testRenewInstallsAllNewChains() throws Exception { doReturn(bundle(merged(old1, old2), ca.trustStore()), renewed).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); f.renew(f.sslBundle().context()); - assertSame(renewed.context(), p.bundle().context()); - assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), p.certificateChains()); + assertSame(renewed.context(), f.sslBundle().context()); + assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), f.sslBundle().certificateChains()); } /** Removing the first-expiring chain advances the earliest expiry when the remaining chain expires later. */ @@ -176,10 +177,10 @@ public void testRenewalCanRemoveEarliestChain() throws Exception { doReturn(bundle(merged(removed, remaining), ca.trustStore()), replacement).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); assertSame(replacement, f.renew(f.sslBundle().context())); - assertEquals(Map.of("remaining", chain(remaining)), p.certificateChains()); + assertEquals(Map.of("remaining", chain(remaining)), f.sslBundle().certificateChains()); } /** renew replaces an expired certificate in use with a valid one. */ @@ -193,11 +194,11 @@ public void testExpiredCertificateRenewed() throws Exception { bundle(ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), ca.trustStore()), bundle(valid, ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); f.renew(f.sslBundle().context()); - assertEquals(Map.of("valid", chain(valid)), p.certificateChains()); + assertEquals(Map.of("valid", chain(valid)), f.sslBundle().certificateChains()); } /** renew applies the checks of reload: here a candidate without certificate chains and one that is not valid yet are refused. */ @@ -212,11 +213,11 @@ public void testRenewalAppliesReloadChecks() throws Exception { bundle(ca.keyStoreWithPrivateKey("not-yet-valid", now + 10 * MINUTE, now + 2 * HOUR), ca.trustStore())) .when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - assertRenewalRejected("No certificate chains", p, + assertRenewalRejected("No certificate chains", f, "The new key material has no X.509 certificate chains, so the node would present no certificate"); - assertRenewalRejected("Not valid yet", p, "The new certificate chain is not valid now [alias=not-yet-valid, "); + assertRenewalRejected("Not valid yet", f, "The new certificate chain is not valid now [alias=not-yet-valid, "); } /** @@ -235,16 +236,16 @@ public void testUnchangedCertificatesAwaited() throws Exception { doReturn(cached).when(caching).createSslBundle(); doReturn(bundle(expired, ca.trustStore()), bundle(expired, ca.trustStore())).when(expiredAgain).createSslBundle(); - SslContextProvider cachingProvider = new SslContextProvider(caching); - SslContextProvider expiredProvider = new SslContextProvider(expiredAgain); + caching.init(); + expiredAgain.init(); assertNull("Cached bundle", caching.renew(caching.sslBundle().context())); - assertEquals("Cached bundle", 1, cachingProvider.renewalFailures()); - assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), cachingProvider.reloadOutcome()); + assertEquals("Cached bundle", 1, caching.renewalFailures()); + assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), caching.reloadOutcome()); assertNull("Expired chains", expiredAgain.renew(expiredAgain.sslBundle().context())); - assertEquals("Expired chains", 1, expiredProvider.renewalFailures()); - assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredProvider.reloadOutcome()); + assertEquals("Expired chains", 1, expiredAgain.renewalFailures()); + assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredAgain.reloadOutcome()); } /** New metadata cannot make the context in use, which a caching factory returns again, serve a renewed certificate. */ @@ -257,7 +258,9 @@ public void testChangedMetadataCannotRenewCachedContext() throws Exception { doReturn(startup, new SslBundle(startup.context(), Map.of("renewed", chain(renewed)))).when(f).createSslBundle(); - assertRenewalRejected("Cached context", new SslContextProvider(f), "The SSL context factory returns the context in use"); + f.init(); + + assertRenewalRejected("Cached context", f, "The SSL context factory returns the context in use"); } /** @@ -280,11 +283,11 @@ public void testRenewalMustAdvanceEarliestExpiry() throws Exception { bundle(merged(renewedA, b), ca.trustStore()), bundle(shortCa.keyStoreWithPrivateKey("short-ca-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - assertRenewalRejected("Another key with the same expiry", p, reason); - assertRenewalRejected("One of two keys renewed", p, reason); - assertRenewalRejected("A later leaf from an authority that expires no later", p, reason); + assertRenewalRejected("Another key with the same expiry", f, reason); + assertRenewalRejected("One of two keys renewed", f, reason); + assertRenewalRejected("A later leaf from an authority that expires no later", f, reason); } /** renew planned for a context that a reload has replaced does not call the factory and changes nothing. */ @@ -296,16 +299,17 @@ public void testRenewForReplacedContextDoesNothing() throws Exception { bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); - SSLContext planned = p.bundle().context(); + f.init(); + + SSLContext planned = f.sslBundle().context(); f.init(); - SslBundle reloaded = p.bundle(); + SslBundle reloaded = f.sslBundle(); assertNull(f.renew(planned)); - assertSame(reloaded, p.bundle()); - assertEquals(0, p.renewalFailures()); + assertSame(reloaded, f.sslBundle()); + assertEquals(0, f.renewalFailures()); verify(f, times(2)).createSslBundle(); } @@ -333,8 +337,9 @@ public void testInterruptedRenewalChangesNothing() throws Exception { return renewed; }).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); - SslBundle inUse = p.bundle(); + f.init(); + + SslBundle inUse = f.sslBundle(); try { GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); @@ -343,9 +348,9 @@ public void testInterruptedRenewalChangesNothing() throws Exception { assertTrue("Same chains: the interrupt flag stays set", Thread.interrupted()); } - Assert.assertSame("Same chains", inUse, p.bundle()); - assertEquals("Same chains", 0, p.renewalFailures()); - assertEquals("Same chains", new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); + Assert.assertSame("Same chains", inUse, f.sslBundle()); + assertEquals("Same chains", 0, f.renewalFailures()); + assertEquals("Same chains", new ReloadOutcome(0, 0, null, 0), f.reloadOutcome()); try { GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); @@ -354,9 +359,9 @@ public void testInterruptedRenewalChangesNothing() throws Exception { assertTrue("New chains: the interrupt flag stays set", Thread.interrupted()); } - Assert.assertSame("New chains", inUse, p.bundle()); - assertEquals("New chains", 0, p.renewalFailures()); - assertEquals("New chains", new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); + Assert.assertSame("New chains", inUse, f.sslBundle()); + assertEquals("New chains", 0, f.renewalFailures()); + assertEquals("New chains", new ReloadOutcome(0, 0, null, 0), f.reloadOutcome()); } /** @@ -372,16 +377,17 @@ public void testReloadCannotLoseMetadataWithRenewal() throws Exception { doReturn(startup, unknown, unknown).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); + RecordingExecutor exec = new RecordingExecutor(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); f.setRenewalEnabled(false); GridTestUtils.assertThrows(log, f::init, IgniteException.class, reason); - assertSame(startup, p.bundle()); - assertRenewalRejected("Renewal", p, reason); + assertSame(startup, f.sslBundle()); + assertRenewalRejected("Renewal", f, reason); } /** Renewal does not start when certificate metadata is unknown or contains no chains. */ @@ -396,11 +402,11 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { doReturn(new SslBundle(ctx, null)).when(unknown).createSslBundle(); doReturn(bundle(ca.trustStore(), ca.trustStore())).when(noChains).createSslBundle(); - SslContextProvider unknownProvider = new SslContextProvider(unknown); - SslContextProvider noChainsProvider = new SslContextProvider(noChains); + unknown.init(); + noChains.init(); - GridTestUtils.assertThrows(log, () -> unknownProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); - GridTestUtils.assertThrows(log, () -> noChainsProvider.startRenewal(exec, renewalLog), IgniteException.class, reason); + GridTestUtils.assertThrows(log, () -> unknown.startRenewal(exec, renewalLog, () -> ""), IgniteException.class, reason); + GridTestUtils.assertThrows(log, () -> noChains.startRenewal(exec, renewalLog, () -> ""), IgniteException.class, reason); } /** A subclass can disable renewal even when configured, without requiring chains or using the executor. */ @@ -604,17 +610,17 @@ public void testUnsuccessfulRenewalsCounted() throws Exception { .doThrow(new IgniteException(ISSUER_DOWN)) .doReturn(bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); assertNull(f.renew(f.sslBundle().context())); - assertEquals("Chains in use", 1, p.renewalFailures()); + assertEquals("Chains in use", 1, f.renewalFailures()); GridTestUtils.assertThrows(log, () -> f.renew(f.sslBundle().context()), IgniteException.class, ISSUER_DOWN); - assertEquals("Factory failure", 2, p.renewalFailures()); + assertEquals("Factory failure", 2, f.renewalFailures()); assertNotNull(f.renew(f.sslBundle().context())); - assertEquals("Renewed", 0, p.renewalFailures()); + assertEquals("Renewed", 0, f.renewalFailures()); } /** @@ -635,25 +641,25 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio bundle(shortCa.keyStoreWithPrivateKey("later-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("later-chain", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); f.renew(f.sslBundle().context()); GridTestUtils.assertThrows(log, f::init, IgniteException.class, ISSUER_DOWN); - assertEquals("Failed reload", 1, p.renewalFailures()); + assertEquals("Failed reload", 1, f.renewalFailures()); f.init(); - assertEquals("Same expiry", 1, p.renewalFailures()); + assertEquals("Same expiry", 1, f.renewalFailures()); f.init(); - assertEquals("Later leaf, authority expires at the same time", 1, p.renewalFailures()); + assertEquals("Later leaf, authority expires at the same time", 1, f.renewalFailures()); f.init(); - assertEquals("Later chain expiry", 0, p.renewalFailures()); + assertEquals("Later chain expiry", 0, f.renewalFailures()); } /** @@ -696,19 +702,19 @@ public void testNextAttemptNotSoonerThanMinPause() throws Exception { bundle(ca.keyStoreWithPrivateKey("renewed", now - 95 * MINUTE, now + 25 * MINUTE), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("reloaded", now - 90 * MINUTE, now + 30 * MINUTE), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); exec.runNext(); - assertEquals("Renewal", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); + assertEquals("Renewal", f.reloadOutcome().successTime() + MINUTE, f.nextRenewalTime()); f.init(); exec.runNext(); - assertEquals("Reload", p.reloadOutcome().successTime() + MINUTE, p.nextRenewalTime()); + assertEquals("Reload", f.reloadOutcome().successTime() + MINUTE, f.nextRenewalTime()); } /** @@ -724,9 +730,9 @@ public void testFailedAttemptsBackOff() throws Exception { doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())) .doThrow(new IgniteException(ISSUER_DOWN)).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); for (long pause : new long[] {1, 2, 4, 8, 16, 32, 60, 60}) { @@ -735,7 +741,7 @@ public void testFailedAttemptsBackOff() throws Exception { exec.runNext(); long after = System.currentTimeMillis(); - long next = p.nextRenewalTime(); + long next = f.nextRenewalTime(); long delay = exec.last().getDelay(TimeUnit.MILLISECONDS); assertTrue("Pause of " + pause + " min: " + (next - before) + " ms", @@ -761,9 +767,9 @@ public void testReloadReplans() throws Exception { doReturn(startup, startup, reloaded, reloaded).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); @@ -773,14 +779,14 @@ public void testReloadReplans() throws Exception { exec.runNext(); assertTrue(retry.isCancelled()); - assertEquals(now + 2 * HOUR - 30 * MINUTE, p.nextRenewalTime()); + assertEquals(now + 2 * HOUR - 30 * MINUTE, f.nextRenewalTime()); long before = System.currentTimeMillis(); exec.runNext(); long after = System.currentTimeMillis(); - long next = p.nextRenewalTime(); + long next = f.nextRenewalTime(); assertTrue("Retry after the reload: " + (next - before) + " ms", next >= before + MINUTE && next <= after + MINUTE * 3 / 2); assertEquals(List.of("WARN " + WAITING, "WARN " + WAITING), problems()); @@ -799,9 +805,9 @@ public void testStaleAttemptNotRetried() throws Exception { bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); Task stale = exec.last(); @@ -819,7 +825,7 @@ public void testStaleAttemptNotRetried() throws Exception { /** * An attempt runs without the operation context of whoever started renewal, here an operator, even on the kind of executor a - * node uses, which passes the context of a submitter to its tasks. The factory sees the operator when the provider is created under it. + * node uses, which passes the context of a submitter to its tasks. The factory sees the operator during its first initialization. */ @Test public void testRenewalRunsWithoutOperatorContext() throws Exception { @@ -837,7 +843,8 @@ public void testRenewalRunsWithoutOperatorContext() throws Exception { try { try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { - new SslContextProvider(f).startRenewal(exec, renewalLog); + f.init(); + f.startRenewal(exec, renewalLog, () -> ""); } assertTrue(waitForCondition(() -> operators.size() == 2, 10_000)); @@ -862,7 +869,7 @@ public void testRenewalLogsAppliedCertificate() throws Exception { bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("reloaded", now - HOUR, now + 3 * HOUR), ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); doAnswer(inv -> { SslBundle renewed = (SslBundle)inv.callRealMethod(); @@ -872,11 +879,11 @@ public void testRenewalLogsAppliedCertificate() throws Exception { return renewed; }).when(f).renew(any(SSLContext.class)); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); - assertTrue(p.certificateChains().containsKey("reloaded")); + assertTrue(f.sslBundle().certificateChains().containsKey("reloaded")); List reloadedLogs = appender.events().stream().map(e -> e.getMessage().getFormattedMessage()) .filter(msg -> msg.startsWith("TLS certificates reloaded [")).toList(); @@ -920,9 +927,9 @@ public void testWaitLoggedOncePerLateness() throws Exception { doReturn(inUse).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); for (int i = 0; i < 8; i++) exec.runNext(); @@ -946,9 +953,9 @@ public void testWaitLoggedAgainOnExpiry() throws Exception { doReturn(bundle(inUse, ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - new SslRenewal(f, QUARTER, exec, renewalLog, p::transports, now::get).replan(); + new SslRenewal(f, QUARTER, exec, renewalLog, () -> "", now::get).replan(); exec.runNext(); exec.runNext(); @@ -975,7 +982,7 @@ private static TestSslContextFactory renewingFactory(double fraction) { /** * @param fraction Share of the lifetime left when renewal starts. - * @param keys Key store a provider starts on. + * @param keys Key store the factory starts on. * @return Time of the first attempt that renewal plans for it. */ private long firstAttempt(double fraction, KeyStore keys) throws Exception { @@ -984,12 +991,12 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { doReturn(bundle(keys, ca.trustStore())).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); - return p.nextRenewalTime(); + return f.nextRenewalTime(); } /** @@ -997,26 +1004,25 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { * as the last reload failure, which status shows. * * @param desc Case, named in assertion messages. - * @param p Provider. + * @param f Factory. * @param reason Expected part of the failure message. */ - private void assertRenewalRejected(String desc, SslContextProvider p, String reason) { - AbstractSslContextFactory f = (AbstractSslContextFactory)p.factory(); - SslBundle inUse = p.bundle(); - int failures = p.renewalFailures(); + private void assertRenewalRejected(String desc, AbstractSslContextFactory f, String reason) { + SslBundle inUse = f.sslBundle(); + int failures = f.renewalFailures(); GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteException.class, reason, desc + ": not refused"); - Assert.assertSame(desc, inUse, p.bundle()); - assertEquals(desc, failures + 1, p.renewalFailures()); - assertTrue(desc, p.reloadOutcome().failureReason().contains(reason)); + Assert.assertSame(desc, inUse, f.sslBundle()); + assertEquals(desc, failures + 1, f.renewalFailures()); + assertTrue(desc, f.reloadOutcome().failureReason().contains(reason)); } /** * Starts renewal on the key store, lets the factory fail the first attempt and checks how renewal logs the failure. * * @param desc Case, named in assertion messages. - * @param inUse Key store a provider starts on. + * @param inUse Key store the factory starts on. * @param fraction Share of the lifetime left when renewal starts. * @param expected Level and message of the failure, without the details in brackets. */ @@ -1026,10 +1032,11 @@ private void assertFailureLogged(String desc, KeyStore inUse, double fraction, S doReturn(bundle(inUse, ca.trustStore())).doThrow(new IgniteException(ISSUER_DOWN)).when(f).createSslBundle(); - SslContextProvider p = new SslContextProvider(f); + f.init(); + int before = problems().size(); - p.startRenewal(exec, renewalLog); + f.startRenewal(exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); @@ -1051,7 +1058,7 @@ private List problems() { /** Exposes the loading hook so tests can stub the material source. */ private static class TestSslContextFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override public SslBundle createSslBundle() throws SSLException { return super.createSslBundle(); } } From c5c8e48138586912c9d821ef755368b38c2faa58 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:28:13 +0300 Subject: [PATCH 112/242] IGNITE-29210 Document direct SSL bundle loading hooks --- docs/_docs/security/ssl-tls.adoc | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index abf98f91bb18a..0e0387ec9bbee 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -302,6 +302,7 @@ This section is for developers integrating a certificate source, such as a PKI s To support reload with certificate status, extend link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html[AbstractSslContextFactory]. Its protected `createSslBundle()` method builds a fresh link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/SslBundle.html[SslBundle] containing the initialized context and the certificate chains loaded with it, indexed by key alias. +The hook may throw `SSLException`; initialization adds the factory details to that failure. The factory owns the accepted bundle. Its final `create()` method initializes the factory on first use and returns the context from its current bundle, without loading the stores again. Call `init()` to load and validate a replacement; the node's reload command calls this method too. A successful call replaces the context and its metadata together. If loading or validation fails, the factory keeps the previous bundle. Outside loading hooks, use `sslBundle()` to inspect the accepted material. A context already returned by `create()` keeps its original material after `init()` succeeds; call `create()` again to obtain the replacement. @@ -310,9 +311,9 @@ Node transports follow successful replacements through the node's shared SSL con State belongs to the factory instance. Reusing an initialized factory, including across embedded node restarts, retains its material until `init()` is called. Create a separate factory instance for each node. -If the source provides a key store, override `loadKeyMaterial()` to construct link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/KeyMaterial.html[KeyMaterial] from that store, its key password and key manager algorithm. -`KeyMaterial` initializes the key managers and captures its certificate chains once. -The base factory builds the context and metadata from the same store load. Keep that store unchanged for as long as a context built from it is in use; load a new store to change certificates. +If the source provides a key store, override `createSslBundle()` to check source parameters, load the store once and return `createSslBundle(store, keyPassword, algorithm)`. +This protected helper initializes the key managers and builds the context and metadata from that store, using the factory's trust and TLS settings. +Keep the store unchanged for as long as its context is in use; load a new store to change certificates. To use custom key managers, override `createSslBundle()` to initialize a fresh `SSLContext` with those managers and return it with matching certificate metadata in an `SslBundle`. The override must configure the context's trust managers and TLS settings as well. Load the material once so that the context and reported chains describe the same certificates, and leave the accepted material unchanged: `init()` publishes the candidate after validation. @@ -325,8 +326,8 @@ See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ign The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed, and `create()` is now final. Update affected subclasses and recompile before upgrading: -* Move custom store loading into `loadKeyMaterial()`, constructing `KeyMaterial` from the loaded store and its settings. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. -* Remove `create()` overrides. If the override customizes an already built context, perform that customization in `createSslBundle()` after calling `super.createSslBundle()`, and retain its certificate chains in the returned bundle. +* Move custom store loading into `createSslBundle()`: check source parameters, load the store once and return `createSslBundle(store, keyPassword, algorithm)`. Declare `throws SSLException` when needed. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. +* Remove `create()` overrides. A subclass of `SslContextFactory` that customizes an already built context can do so in `createSslBundle()` after calling `super.createSslBundle()`. Retain its certificate chains in the returned bundle and propagate `SSLException` from the loading hook. * Remove custom context caching. The base factory retains the accepted bundle until a successful `init()` replaces it. Use `create()` to obtain the currently accepted context. A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override as long as it returns a loaded store, never `null`. For a client context without private keys, return an empty loaded store. From 3661fc36ac0455b812654fb12368e8e7401a4a3a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:30:48 +0300 Subject: [PATCH 113/242] IGNITE-29211 Adapt node test hooks to checked SSL loading --- .../org/apache/ignite/internal/ssl/SslRenewalNodeTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index ff9e0edfff1f9..2cc99c1ee2346 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.configuration.ClientConnectorConfiguration; @@ -111,7 +112,7 @@ public void testUnknownCertificateRefusedAtStart() throws Exception { Path keys = dir.resolve("node.jks"); SslContextFactory factory = readingKeys(new SslContextFactory() { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override protected SslBundle createSslBundle() throws SSLException { return new SslBundle(super.createSslBundle().context(), null); } }, keys); @@ -179,7 +180,7 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { CountDownLatch attempting = new CountDownLatch(1); SslContextFactory factory = readingKeys(new SslContextFactory() { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override protected SslBundle createSslBundle() throws SSLException { if (Thread.currentThread().getName().startsWith("ssl-renewal")) { attempting.countDown(); From 06489a8566b9b9237e9ad00c361eecc2ed0b62da Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:34:32 +0300 Subject: [PATCH 114/242] IGNITE-29209 Build SSL bundles directly from loaded stores --- .../ignite/ssl/AbstractSslContextFactory.java | 126 +++++++++++++--- .../org/apache/ignite/ssl/KeyMaterial.java | 137 ------------------ .../apache/ignite/ssl/SslContextFactory.java | 6 +- .../GridCommandHandlerWithSslFactoryTest.java | 3 +- .../tcp/internal/ClusterStateProvider.java | 8 + .../tcp/internal/GridNioServerWrapper.java | 10 +- .../ignite/ssl/SslContextMetadataTest.java | 44 +++--- .../ignite/ssl/SslContextReuseTest.java | 40 ----- 8 files changed, 147 insertions(+), 227 deletions(-) delete mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 772ac9c83351e..5eb682672676e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -17,16 +17,29 @@ package org.apache.ignite.ssl; +import java.security.GeneralSecurityException; import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; /** * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; @@ -111,28 +124,109 @@ public void setProtocols(String... protocols) { } /** - * Checks the factory parameters, loads its key material once, and builds a fresh context with the certificate chains from - * that material. The factory does not retain the result. - * - * Override this method to load a candidate from another source. It must not change the material already in use. - * Outside loading hooks, use {@link #sslBundle()} to inspect the accepted material. + * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before + * loading it. The result must not change material already in use. Outside loading hooks, use {@link #sslBundle()} to + * inspect the accepted material. * * @return Fresh context and the certificate metadata available to this factory. - * @throws IgniteException If the context could not be created. + * @throws SSLException If the context could not be created. + */ + protected abstract SslBundle createSslBundle() throws SSLException; + + /** + * Creates a context and certificate metadata from one loaded key store, using this factory's trust and TLS settings. + * Do not modify the store while the context is in use: key managers such as PKIX read it during handshakes. + * Load replacement material into a separate store. + * + * @param store Loaded key store, never {@code null}. For a context without private keys, use an empty store initialized + * with {@code KeyStore.load(null, null)}. + * @param keyPwd Password for the loaded store's private keys, or {@code null} if no password is required. + * @param algorithm Key manager algorithm. + * @return Context and nonempty X.509 chains of key entries, ordered by alias. An empty map means no such chains were found. + * @throws SSLException If the store cannot be read or the context cannot be created. */ - protected SslBundle createSslBundle() { + protected final SslBundle createSslBundle(KeyStore store, @Nullable char[] keyPwd, String algorithm) throws SSLException { + A.notNull(store, "store", algorithm, "algorithm"); + + KeyManager[] keyMgrs; + try { - checkParameters(); + KeyManagerFactory factory = KeyManagerFactory.getInstance(algorithm); - KeyMaterial material = loadKeyMaterial(); + factory.init(store, keyPwd); - return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); + keyMgrs = factory.getKeyManagers(); + } + catch (NoSuchAlgorithmException e) { + throw new SSLException("Unsupported key algorithm: " + algorithm, e); + } + catch (GeneralSecurityException e) { + throw new SSLException("Failed to initialize Key Manager (security exception occurred)", e); + } + + Map> chains = certificateChains(store); + + return new SslBundle(createSslContext(keyMgrs), chains); + } + + /** @return Fresh candidate with factory context added to checked SSL errors. */ + private SslBundle prepareSslBundle() { + try { + return createSslBundle(); } catch (SSLException e) { throw new IgniteException(e.getMessage() + " [factory=" + this + ']', e); } } + /** + * @param store Loaded key store. + * @return Nonempty X.509 chains of key entries; entries without such a chain are omitted. + * @throws SSLException If the store cannot be read. + */ + private static Map> certificateChains(KeyStore store) throws SSLException { + try { + Map> chains = new TreeMap<>(); + + for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { + String alias = aliases.nextElement(); + + if (!store.isKeyEntry(alias)) + continue; + + List chain = certificateChain(store.getCertificateChain(alias)); + + if (!chain.isEmpty()) + chains.put(alias, chain); + } + + return chains; + } + catch (KeyStoreException e) { + throw new SSLException("Failed to read certificate chains from key store", e); + } + } + + /** + * @param certs Chain from the store, if present. + * @return X.509 chain, or an empty list if it is absent or contains another certificate type. + */ + private static List certificateChain(@Nullable Certificate[] certs) { + if (certs == null) + return Collections.emptyList(); + + List chain = new ArrayList<>(certs.length); + + for (Certificate cert : certs) { + if (!(cert instanceof X509Certificate)) + return Collections.emptyList(); + + chain.add((X509Certificate)cert); + } + + return chain; + } + /** * @param keyMgrs Key managers for the loaded material. * @return Initialized context. @@ -185,16 +279,6 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ protected abstract void checkParameters() throws SSLException; - /** - * Loads the key store and initializes its key managers and certificate metadata. Called once per context, after the - * parameters are checked. - * For a context without private keys, return material over an empty store initialized with {@code KeyStore.load(null, null)}. - * - * @return Loaded key material, never {@code null}. - * @throws SSLException If the key material cannot be loaded. - */ - protected abstract KeyMaterial loadKeyMaterial() throws SSLException; - /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. @@ -211,7 +295,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * @throws IllegalArgumentException If a configured protocol or cipher suite is unsupported. */ public final synchronized SslBundle init() { - SslBundle next = createSslBundle(); + SslBundle next = prepareSslBundle(); next.context().createSSLEngine(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java deleted file mode 100644 index 55c888d72d304..0000000000000 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLException; -import org.apache.ignite.internal.util.typedef.internal.A; -import org.jetbrains.annotations.Nullable; - -/** - * Key managers and certificate metadata initialized from one loaded key store. Do not modify the store while any context - * created from it is in use: key managers such as PKIX read it during handshakes. Load replacement material into a separate - * store. - */ -public final class KeyMaterial { - /** */ - private final KeyManager[] keyMgrs; - - /** */ - private final Map> chains; - - /** - * @param store Loaded key store, never {@code null}. For a context without private keys, initialize an empty store with - * {@code KeyStore.load(null, null)}. - * @param keyPwd Password for its private keys, or {@code null} if the store requires no password. - * @param algorithm Key manager algorithm. - * @throws SSLException If the algorithm is unavailable or the store cannot be read. - */ - public KeyMaterial(KeyStore store, @Nullable char[] keyPwd, String algorithm) throws SSLException { - A.notNull(store, "store", algorithm, "algorithm"); - - try { - KeyManagerFactory factory = KeyManagerFactory.getInstance(algorithm); - - factory.init(store, keyPwd); - - keyMgrs = factory.getKeyManagers(); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + algorithm, e); - } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred)", e); - } - - chains = certificateChains(store); - } - - /** @return A copy of the array of initialized key managers. */ - public KeyManager[] keyManagers() { - return keyMgrs.clone(); - } - - /** - * @return Immutable, nonempty X.509 chains of key entries, ordered by alias and with each key's own certificate first. - * Entries without a chain or with a non-X.509 certificate are omitted. An empty map means no such chains were found. - */ - public Map> certificateChains() { - return chains; - } - - /** - * @param store Loaded key store. - * @return Immutable certificate metadata. - * @throws SSLException If the loaded store cannot be read. - */ - private static Map> certificateChains(KeyStore store) throws SSLException { - try { - Map> chains = new TreeMap<>(); - - for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { - String alias = aliases.nextElement(); - - if (!store.isKeyEntry(alias)) - continue; - - List chain = certificateChain(store.getCertificateChain(alias)); - - if (!chain.isEmpty()) - chains.put(alias, chain); - } - - return Collections.unmodifiableMap(chains); - } - catch (KeyStoreException e) { - throw new SSLException("Failed to read certificate chains from key store", e); - } - } - - /** - * @param certs Chain from the store, if present. - * @return Immutable X.509 chain, or an empty list if it is absent or contains another certificate type. - */ - private static List certificateChain(@Nullable Certificate[] certs) { - if (certs == null) - return Collections.emptyList(); - - List chain = new ArrayList<>(certs.length); - - for (Certificate cert : certs) { - if (!(cert instanceof X509Certificate)) - return Collections.emptyList(); - - chain.add((X509Certificate)cert); - } - - return Collections.unmodifiableList(chain); - } -} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 35e511dcde177..955a43f2480b1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -259,8 +259,10 @@ public static TrustManager getDisabledTrustManager() { } /** {@inheritDoc} */ - @Override protected KeyMaterial loadKeyMaterial() throws SSLException { - return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); + @Override protected SslBundle createSslBundle() throws SSLException { + checkParameters(); + + return createSslBundle(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } /** {@inheritDoc} */ diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java index 78d9b2be5edeb..751d91bf26482 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java @@ -20,6 +20,7 @@ import java.util.List; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; @@ -93,7 +94,7 @@ public static void tearDown() { */ public static class SslFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() { + @Override public SslBundle createSslBundle() throws SSLException { factoryUsed = true; return super.createSslBundle(); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java index 017ae837389d8..d4383c096cefb 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/ClusterStateProvider.java @@ -19,6 +19,7 @@ import java.util.UUID; import java.util.function.Supplier; +import javax.net.ssl.SSLEngine; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteLogger; import org.apache.ignite.cluster.ClusterNode; @@ -107,6 +108,13 @@ public boolean isSslEnabled() { return ignite.configuration().getSslContextFactory() != null; } + /** + * @return {@link SSLEngine} for ssl connections. + */ + public SSLEngine createSSLEngine() { + return ignite.configuration().getSslContextFactory().create().createSSLEngine(); + } + /** * @return {@code true} if {@link TcpCommunicationSpi} stopped. */ diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index 7ec7ea0b0a0d8..9368f9f8a83db 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -40,7 +40,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteCheckedException; @@ -218,9 +217,6 @@ public class GridNioServerWrapper { private final int msgQueueWarningSize = IgniteSystemProperties.getInteger( IgniteSystemProperties.IGNITE_TCP_COMM_MSG_QUEUE_WARN_SIZE, 0); - /** Context shared by incoming and outgoing connections until the SPI stops. */ - private SSLContext sslCtx; - /** NIO server. */ private GridNioServer nioSrv; @@ -480,7 +476,7 @@ public GridNioSession createNioSession(ClusterNode node, int connIdx) throws Ign if (stateProvider.isSslEnabled()) { meta.put(SSL_META.ordinal(), sslMeta = new GridSslMeta()); - SSLEngine sslEngine = sslCtx.createSSLEngine(); + SSLEngine sslEngine = stateProvider.createSSLEngine(); sslEngine.setUseClientMode(true); @@ -807,8 +803,6 @@ public GridNioServer resetNioServer() throws IgniteCheckedException { if (cfg.boundTcpPort() >= 0) throw new IgniteCheckedException("Tcp NIO server was already created on port " + cfg.boundTcpPort()); - sslCtx = stateProvider.isSslEnabled() ? igniteCfg.getSslContextFactory().create() : null; - IgniteCheckedException lastEx = null; // If configured TCP port is busy, find first available in range. @@ -925,7 +919,7 @@ private IgniteMessageFactory get() { if (stateProvider.isSslEnabled()) { GridNioSslFilter sslFilter = U.sslFilter( - sslCtx, + igniteCfg.getSslContextFactory().create(), true, ByteOrder.LITTLE_ENDIAN, log, diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 9f06c1c0dd104..c820ae06c5b57 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -22,7 +22,6 @@ import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -31,11 +30,9 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509KeyManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.testframework.GridTestUtils; @@ -52,8 +49,8 @@ /** * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous - * bundle. Reading the accepted bundle or context does not reload stores. {@link KeyMaterial} initializes key managers and - * reports nonempty X.509 chains. {@link SslBundle} keeps sorted immutable metadata, or {@code null}. + * bundle. Reading the accepted bundle or context does not reload stores. Metadata includes nonempty X.509 chains. + * {@link SslBundle} keeps sorted immutable metadata, or {@code null}. */ public class SslContextMetadataTest extends GridCommonAbstractTest { /** Authority that signs the test certificates. */ @@ -260,9 +257,11 @@ public void testOnlyKeyEntriesWithX509Chains() throws Exception { new Certificate[] {first.getCertificate(firstAlias), mock(Certificate.class)}); // PKIX defers key selection, allowing metadata filtering to inspect the entire mixed certificate chain. - KeyMaterial material = new KeyMaterial(store, PWD, "PKIX"); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); + + factory.setKeyAlgorithm("PKIX"); - assertEquals(Map.of(firstAlias, chain(first), secondAlias, chain(second)), material.certificateChains()); + assertEquals(Map.of(firstAlias, chain(first), secondAlias, chain(second)), factory.init().certificateChains()); } /** An empty key store yields a context without private keys and an empty map. */ @@ -277,22 +276,31 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { assertEquals(Map.of(), bundle.certificateChains()); } - /** Initialized managers remain usable after clearing the password or changing a returned manager array. */ + /** A source can supply a key password different from the factory configuration without loading its store twice. */ @Test - public void testLoadedMaterialKeepsInitializedKeyManagers() throws Exception { - String alias = "node"; - char[] pwd = PWD.clone(); - KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey(alias), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); + public void testBundleUsesPasswordSuppliedWithStore() throws Exception { + String alias = "source-key"; + KeyStore store = ca.keyStoreWithPrivateKey(alias); + char[] srcPwd = "source-key-password".toCharArray(); + + store.setKeyEntry(alias, store.getKey(alias, PWD), srcPwd, store.getCertificateChain(alias)); - Arrays.fill(pwd, '?'); + InMemoryStoreFactory factory = new InMemoryStoreFactory(store) { + /** {@inheritDoc} */ + @Override protected SslBundle createSslBundle() throws SSLException { + checkParameters(); - KeyManager[] mgrs = material.keyManagers(); - X509KeyManager mgr = (X509KeyManager)mgrs[0]; + KeyStore loaded = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - mgrs[0] = null; + return createSslBundle(loaded, srcPwd, keyAlgorithm); + } + }; - assertNotNull(mgr.getPrivateKey(alias)); - assertSame(mgr, material.keyManagers()[0]); + SslBundle bundle = factory.init(); + + assertEquals(1, factory.loads); + assertEquals(Map.of(alias, chain(store)), bundle.certificateChains()); + assertSame(bundle.context(), factory.create()); } /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index af0f024dc3616..ce6e85dd389c2 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -20,16 +20,10 @@ import java.io.File; import java.io.FileOutputStream; import java.security.KeyStore; -import java.util.concurrent.atomic.AtomicInteger; -import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; -import org.apache.ignite.internal.IgniteEx; -import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; -import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Rule; @@ -44,13 +38,6 @@ public class SslContextReuseTest extends GridCommonAbstractTest { /** Stores generated independently for each test. */ @Rule public final TemporaryFolder tmp = new TemporaryFolder(); - /** {@inheritDoc} */ - @Override protected void afterTest() throws Exception { - stopAllGrids(); - - super.afterTest(); - } - /** A new context uses initialized TLS settings; the previous context, engines and socket factories keep their settings. */ @Test public void testCreateReturnsInitializedContextSnapshot() throws Exception { @@ -121,33 +108,6 @@ public void testInitializedFactorySerializationKeepsConfiguration() throws Excep assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); } - /** Reopening a communication connection does not call the SSL context factory on either node. */ - @Test - public void testCommunicationReusesContextOnReconnect() throws Exception { - Factory sslFactory = GridTestUtils.sslFactory(); - AtomicInteger created = new AtomicInteger(); - Factory counting = () -> { - created.incrementAndGet(); - - return sslFactory.create(); - }; - - IgniteEx first = startGrid(0, cfg -> { - cfg.setSslContextFactory(counting); - }); - IgniteEx second = startGrid(1, cfg -> { - cfg.setSslContextFactory(counting); - }); - int createdAtStart = created.get(); - - ConnectionClientPool pool = U.field(first.configuration().getCommunicationSpi(), "clientPool"); - - pool.forceCloseConnection(second.localNode().id()); - first.compute(first.cluster().forNodeId(second.localNode().id())).run(() -> {}); - - assertEquals(createdAtStart, created.get()); - } - /** Creates a file-backed factory with a generated private key and currently valid certificate chain. */ private SslContextFactory fileFactory() throws Exception { KeyStore store = new TestCertificateAuthority("context-reuse-ca").keyStoreWithPrivateKey(); From d9fc2fcea5309921293c1885fdf5b12aeb02ed0e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:42:52 +0300 Subject: [PATCH 115/242] IGNITE-29210 Use a clear certificate alias name in CLI test --- .../apache/ignite/util/GridCommandHandlerSslReloadTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index cdea9be66c6a8..cdf96aa855a58 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -109,10 +109,10 @@ public void testReloadAndStatus() throws Exception { assertNotNull("The reload must ask for confirmation", new SslReloadCommand().confirmationPrompt(new NoArg())); TestCertificateAuthority ca = new TestCertificateAuthority("cli-ca"); - String initial = "initial-key"; + String oldAlias = "initial-key"; String replacement = "replacement-key"; - save(ca.keyStoreWithPrivateKey(initial), keyStore); + save(ca.keyStoreWithPrivateKey(oldAlias), keyStore); save(ca.keyStoreWithPrivateKey(), clientKeyStore); IgniteEx srv0 = startGrid(0); @@ -139,7 +139,7 @@ public void testReloadAndStatus() throws Exception { transports.forEach((g, t) -> assertContains(log, statusOutput, node(g) + ": " + t + '\n')); assertContains(log, statusOutput, loaded); - assertNotContains(log, statusOutput, "subject=CN=" + initial); + assertNotContains(log, statusOutput, "subject=CN=" + oldAlias); List succeeded = Pattern.compile("last reload succeeded at (\\S+)").matcher(statusOutput).results() .map(m -> Instant.parse(m.group(1))).collect(Collectors.toList()); From 756e613f719eb8a8d8cae44602c2b39f7848a4a6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 3 Nov 2026 21:45:23 +0300 Subject: [PATCH 116/242] IGNITE-29210 Add certificate generation dependency to CLI tests --- modules/control-utility/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/control-utility/pom.xml b/modules/control-utility/pom.xml index 53524e3a2069a..31961f470fcb6 100644 --- a/modules/control-utility/pom.xml +++ b/modules/control-utility/pom.xml @@ -131,6 +131,13 @@ test + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + test + + org.hamcrest hamcrest-library From f004af6a7b83a7a7f5ac5fcf2c11f5f9022dcec7 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 01:35:45 +0300 Subject: [PATCH 117/242] IGNITE-29209 Keep source parameter validation in the store factory --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 7 ------- .../java/org/apache/ignite/ssl/SslContextFactory.java | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 5eb682672676e..d3702fb06d9d2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -272,13 +272,6 @@ protected void checkNullParameter(Object param, String name) throws SSLException throw new SSLException("Failed to initialize SSL context (parameter cannot be null): " + name); } - /** - * Checks that all required parameters are set. - * - * @throws SSLException If any of required parameters is missing. - */ - protected abstract void checkParameters() throws SSLException; - /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 955a43f2480b1..14787f42c92f9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -308,8 +308,12 @@ private String parameters() { return buf.toString(); } - /** {@inheritDoc} */ - @Override protected void checkParameters() throws SSLException { + /** + * Checks that all required parameters are set. + * + * @throws SSLException If any of required parameters is missing. + */ + protected void checkParameters() throws SSLException { assert keyStoreType != null; assert proto != null; From 07f3f4becededa3a11b3cf0d6fa8cd0d5100fde8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 02:43:01 +0300 Subject: [PATCH 118/242] IGNITE-29210 Clarify custom factory validation migration --- docs/_docs/security/ssl-tls.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 0e0387ec9bbee..c0044f87bba4e 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -327,6 +327,7 @@ See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ign The protected `sslCtx` cache and the no-argument `createKeyManagers()` hook have been removed, and `create()` is now final. Update affected subclasses and recompile before upgrading: * Move custom store loading into `createSslBundle()`: check source parameters, load the store once and return `createSslBundle(store, keyPassword, algorithm)`. Declare `throws SSLException` when needed. If the old `createKeyManagers()` override customizes key managers, build the context in `createSslBundle()` and return matching certificate metadata with it. +* Direct subclasses of `AbstractSslContextFactory` that retain `checkParameters()` must remove its `@Override` and call it from `createSslBundle()`. Subclasses of `SslContextFactory` can keep that override. * Remove `create()` overrides. A subclass of `SslContextFactory` that customizes an already built context can do so in `createSslBundle()` after calling `super.createSslBundle()`. Retain its certificate chains in the returned bundle and propagate `SSLException` from the loading hook. * Remove custom context caching. The base factory retains the accepted bundle until a successful `init()` replaces it. Use `create()` to obtain the currently accepted context. From d58cd6a80cab2bf1b0858a3d7cfe615e06fa21b2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 16:44:55 +0300 Subject: [PATCH 119/242] IGNITE-29210 Reject SSL contexts that cannot supply TCP sockets --- .../ignite/ssl/AbstractSslContextFactory.java | 20 ++++++++++--------- .../ignite/ssl/SslContextMetadataTest.java | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index f0d53f297a77f..fa154ab9be3c8 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -174,10 +174,16 @@ protected final SslBundle createSslBundle(KeyStore store, @Nullable char[] keyPw return new SslBundle(createSslContext(keyMgrs), chains); } - /** @return Fresh candidate with factory context added to checked SSL errors. */ + /** @return Fresh candidate that can create engines and supply sockets for TCP discovery. */ private SslBundle prepareSslBundle() { try { - return createSslBundle(); + SslBundle next = createSslBundle(); + SSLContext ctx = next.context(); + + ctx.createSSLEngine(); + ctx.getSocketFactory(); + + return next; } catch (SSLException e) { throw new IgniteException(e.getMessage() + " [factory=" + this + ']', e); @@ -295,18 +301,16 @@ protected void checkNullParameter(Object param, String name) throws SSLException * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. * @throws IllegalArgumentException If a configured protocol or cipher suite is unsupported. + * @throws UnsupportedOperationException If the context does not support stream sockets. */ public final synchronized SslBundle init() { try { SslBundle next = prepareSslBundle(); + boolean reload = bundle != null; - if (bundle == null) - next.context().createSSLEngine(); - else + if (reload) checkReplacement(next); - boolean reload = bundle != null; - bundle = next; if (reload) @@ -338,8 +342,6 @@ private void checkReplacement(SslBundle next) { "so the node would present no certificate"); } - next.context().createSSLEngine(); - if (next.context() == bundle.context()) { throw new IgniteException("The SSL context factory returns the context in use, so the node gets no new " + "certificates from it"); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index c820ae06c5b57..76f0d5492ef8b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -220,6 +220,26 @@ public void testInvalidTlsParametersDoNotReplaceAcceptedBundle() throws Exceptio assertEquals(3, factory.loads); } + /** DTLS supports engines but cannot supply the TLS sockets used by discovery. */ + @Test + public void testDatagramContextRejectedAtStartupAndReload() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + String datagramProtocol = "DTLS"; + + factory.setProtocol(datagramProtocol); + + GridTestUtils.assertThrows(log, factory::init, UnsupportedOperationException.class, null); + + factory.setProtocol("TLS"); + + SslBundle accepted = factory.init(); + + factory.setProtocol(datagramProtocol); + + GridTestUtils.assertThrows(log, factory::init, UnsupportedOperationException.class, null); + assertSame(accepted, factory.sslBundle()); + } + /** Missing parameters are reported before the key store is read. */ @Test public void testInvalidParametersDoNotLoadStore() throws Exception { From 8e36bc904c2b80c05c8a355aca79055f63373970 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 16:47:06 +0300 Subject: [PATCH 120/242] IGNITE-29211 Validate the renewal window and discard attempts during shutdown --- .../ignite/ssl/AbstractSslContextFactory.java | 39 ++++++++++++----- .../internal/ssl/SslRenewalNodeTest.java | 5 +-- .../ignite/internal/ssl/SslRenewalTest.java | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 7377b2122690d..7e19fc7aa4434 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -188,11 +188,16 @@ public double getRenewBeforeFraction() { * @param renewBeforeFraction Share of the lifetime. */ public void setRenewBeforeFraction(double renewBeforeFraction) { - A.ensure(renewBeforeFraction > 0 && renewBeforeFraction < 1, "renewBeforeFraction must be greater than 0 and less than 1"); + A.ensure(validRenewBeforeFraction(renewBeforeFraction), "renewBeforeFraction must be greater than 0 and less than 1"); this.renewBeforeFraction = renewBeforeFraction; } + /** @return Whether the renewal window starts within the certificate lifetime. */ + private static boolean validRenewBeforeFraction(double fraction) { + return fraction > 0 && fraction < 1; + } + /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before * loading it. The result must not change material already in use. Outside loading hooks, use {@link #sslBundle()} to @@ -402,7 +407,7 @@ public final synchronized SslBundle init() { * @param exec Renewal executor owned and stopped by the node. * @param log Renewal logger. * @param transports Names of the transports currently using this factory, evaluated when logging. - * @throws IgniteException If renewal is already attached to another running node or certificate metadata is unavailable. + * @throws IgniteException If another running node owns renewal, the renewal window is invalid or certificate metadata is unavailable. */ public final synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { boolean enabled = isRenewalEnabled(); @@ -418,17 +423,26 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit renewal = null; renewalExec = null; - if (enabled && SslCertificates.renewalChain(sslBundle()) == null) { - throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + - "to renew automatically [transports=" + transports.get() + ']'); + if (enabled) { + double fraction = getRenewBeforeFraction(); + + if (!validRenewBeforeFraction(fraction)) { + throw new IgniteException("renewBeforeFraction must be greater than 0 and less than 1 " + + "[value=" + fraction + ']'); + } + + if (SslCertificates.renewalChain(sslBundle()) == null) { + throw new IgniteException("The SSL context factory must provide at least one X.509 certificate chain " + + "to renew automatically [transports=" + transports.get() + ']'); + } + + renewal = new SslRenewal(this, fraction, exec, log, transports); } renewalExec = exec; - if (enabled) { - renewal = new SslRenewal(this, getRenewBeforeFraction(), exec, log, transports); + if (renewal != null) renewal.replan(); - } } /** @@ -449,7 +463,7 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit try { SslBundle next = buildReplacement(); - if (Thread.currentThread().isInterrupted()) + if (renewalInterrupted()) throw new IgniteInterruptedCheckedException("TLS certificate renewal was interrupted"); Map> chains = next.certificateChains(); @@ -474,7 +488,7 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit putInUse(next); } catch (Throwable e) { - if (!Thread.currentThread().isInterrupted() && + if (!renewalInterrupted() && !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) { renewalFailures++; @@ -489,6 +503,11 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit return bundle; } + /** @return Whether the renewal thread was interrupted or its node stopped the executor during the load. */ + private boolean renewalInterrupted() { + return Thread.currentThread().isInterrupted() || (renewalExec != null && renewalExec.isShutdown()); + } + /** @return Failed automatic attempts since the earliest expiry across all chains was advanced. */ public final int renewalFailures() { return renewalFailures; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index 2cc99c1ee2346..ee4bf133d95c3 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -27,7 +27,6 @@ import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteException; -import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; @@ -187,8 +186,8 @@ public void testStopDuringRenewalLogsNoFailure() throws Exception { try { Thread.sleep(Long.MAX_VALUE); } - catch (InterruptedException e) { - throw new IgniteInterruptedException(e); + catch (InterruptedException ignored) { + throw new SSLException("Key store read cancelled"); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index c3928a79af302..af8742733f207 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -364,6 +364,30 @@ public void testInterruptedRenewalChangesNothing() throws Exception { assertEquals("New chains", new ReloadOutcome(0, 0, null, 0), f.reloadOutcome()); } + /** Material returned by the key source after the renewal executor stops must not be installed. */ + @Test + public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { + long now = System.currentTimeMillis(); + SslBundle inUse = bundle(ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR), ca.trustStore()); + SslBundle later = bundle(ca.keyStoreWithPrivateKey("later", now - HOUR, now + 2 * HOUR), ca.trustStore()); + TestSslContextFactory factory = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); + + doReturn(inUse).doAnswer(inv -> { + exec.shutdownNow(); + + return later; + }).when(factory).createSslBundle(); + + factory.startRenewal(exec, renewalLog, () -> ""); + exec.runNext(); // Plan renewal. + exec.runNext(); // Load the replacement. + + assertTrue(exec.isShutdown()); + assertSame(inUse, factory.sslBundle()); + assertEquals(new ReloadOutcome(0, 0, null, 0), factory.reloadOutcome()); + } + /** * With renewal attached, direct initialization refuses a bundle without certificate metadata, even after the factory has turned * renewal off: the renewal settings are fixed until the attached node stops. renew refuses such a bundle too. @@ -456,6 +480,25 @@ public void testRenewalWindowUsesOverriddenFraction() throws Exception { assertEquals(now + 2 * HOUR, factory.nextRenewalTime()); } + /** A subclass must supply a valid window before its renewal schedule can start. */ + @Test + public void testInvalidOverriddenFractionPreventsRenewal() throws Exception { + TestSslContextFactory factory = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); + + doReturn(bundle(ca.keyStoreWithPrivateKey(), ca.trustStore())).when(factory).createSslBundle(); + doReturn(Double.NaN).when(factory).getRenewBeforeFraction(); + + GridTestUtils.assertThrows(log, () -> factory.startRenewal(exec, renewalLog, () -> ""), + IgniteException.class, "renewBeforeFraction must be greater than 0 and less than 1"); + assertTrue(exec.pending().isEmpty()); + + doReturn(QUARTER).when(factory).getRenewBeforeFraction(); + + factory.startRenewal(exec, renewalLog, () -> ""); + assertEquals(1, exec.pending().size()); + } + /** A factory accepts one renewal executor, and attaching it again does not duplicate its schedule. */ @Test public void testRenewalAttachesOnce() throws Exception { From c20c22e5d7fa5b7c96288982761cbffcd7b04b8f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 17:19:32 +0300 Subject: [PATCH 121/242] IGNITE-29209 Limit explicit TLS initialization to factory subclasses --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 7 +++---- .../main/java/org/apache/ignite/ssl/SslContextFactory.java | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index d3702fb06d9d2..7184d71f675f1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -125,8 +125,7 @@ public void setProtocols(String... protocols) { /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. The result must not change material already in use. Outside loading hooks, use {@link #sslBundle()} to - * inspect the accepted material. + * loading it. The result must not change material already in use. * * @return Fresh context and the certificate metadata available to this factory. * @throws SSLException If the context could not be created. @@ -287,7 +286,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * @throws IgniteException If the material cannot be loaded or the context cannot be created. * @throws IllegalArgumentException If a configured protocol or cipher suite is unsupported. */ - public final synchronized SslBundle init() { + protected final synchronized SslBundle init() { SslBundle next = prepareSslBundle(); next.context().createSSLEngine(); @@ -303,7 +302,7 @@ public final synchronized SslBundle init() { * @return One snapshot of the material in use. * @throws IgniteException If the first initialization fails. */ - public final SslBundle sslBundle() { + final SslBundle sslBundle() { SslBundle cur = bundle; if (cur == null) { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 14787f42c92f9..ec14042e14d0b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -37,8 +37,8 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. Call {@link #init()} - * to read the stores again and publish the replacement material. + * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. A subclass can call {@link #init()} + * to reread the stores. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager From 3f915d61c65b8ac1fefe7a6e95c9666c60a699b8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 21:21:54 +0300 Subject: [PATCH 122/242] IGNITE-29210 Keep reload diagnostics inside the node and report provider failures --- docs/_docs/security/ssl-tls.adoc | 6 +- docs/_docs/tools/control-script.adoc | 6 +- .../ignite/internal/ssl/SslCertificates.java | 8 +- .../ignite/internal/ssl/SslFactoryAccess.java | 43 ++++---- .../ignite/ssl/AbstractSslContextFactory.java | 38 ++++--- .../util/GridCommandHandlerSslReloadTest.java | 2 - .../management/ssl/SslReloadTask.java | 5 +- .../management/ssl/SslStatusTask.java | 4 +- .../internal/ssl/SslContextProvider.java | 6 +- .../internal/ssl/SslContextProviderTest.java | 45 +------- .../ignite/ssl/SslContextReloadTest.java | 102 ++++-------------- 11 files changed, 81 insertions(+), 184 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index d49705827bd74..bf558346f3a60 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -185,6 +185,8 @@ A custom factory can return a context without certificate information. Status th With `SslContextFactory`, status lists only keys with a nonempty X.509 certificate chain. Keys without a chain, or whose chain contains a non-X.509 certificate, are omitted. After reload, check that every alias you expect appears in status. +A node can start with expired or not-yet-valid certificates. It logs a warning, and `--ssl status` reports which chain needs replacement. + Reload also rejects a replacement with no X.509 certificate chains. A node can still start with such a store, but it logs a warning. Follow the `no X.509 certificate chains` row in link:tools/control-script#checking-certificates[Checking Certificates] to correct the store before running reload. You must check that peers trust the replacement and that protocols, cipher suites and client authentication settings remain compatible. Reload does not test connections or undo a successful replacement if connections later fail. @@ -307,9 +309,9 @@ The factory owns the accepted bundle. Its final `create()` method initializes th If startup needs a fresh certificate, your subclass can call the protected `init()` method and take the chain from the returned bundle. The node's reload command also loads and validates a replacement. If loading or validation fails, the factory keeps its previous bundle. After a successful reload, call `create()` again to obtain the new context. -Node transports follow successful replacements through the node's shared SSL context. New engines and newly obtained socket factories use the accepted material. Previously obtained engines, socket factories and sockets keep their original material. +Node transports follow successful replacements through the context shared by transports using that factory. New engines and newly obtained socket factories use the accepted material. Previously obtained engines, socket factories and sockets keep their original material. -State belongs to the factory instance. Reusing an initialized factory, including across embedded node restarts, retains its material until `init()` is called. Create a separate factory instance for each node. +State belongs to the factory instance. Reusing an initialized factory, including across embedded node restarts, keeps its material until a replacement succeeds. Create a separate factory instance for each node. If the source provides a key store, override `createSslBundle()` to check source parameters, load the store once and return `createSslBundle(store, keyPassword, algorithm)`. This protected helper initializes the key managers and builds the context and metadata from that store, using the factory's trust and TLS settings. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 209f0d67f5eec..1a082857f733d 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1765,7 +1765,7 @@ After preparing replacement certificates or updating trust stores, run: control.sh|bat --ssl reload [--yes] ---- -The command asks for confirmation. Add `--yes` to skip the prompt when running from a script. It does not bypass certificate or permission checks. +The command asks for confirmation. In scripts, add `--yes` to confirm the operation. Before replacing certificates, follow link:security/ssl-tls#upgrading-certificates[Upgrading Certificates] to prepare trust stores and check the change on one node first. Reload does not test connections or roll back changes if new connections fail. @@ -1788,7 +1788,7 @@ If that update fails, those transports keep their previous context. Successful u The command lists failed nodes first and exits with an error if any update failed. Correct the reported cause and run reload again. -Each factory reload attempt is also recorded in the node log, including the user's login and network address when security is enabled. +The command logs successful updates at INFO and failures at WARN. When security is enabled, the log includes the user's login and network address. A successful result means the new SSL context is in use for new connections. Use `--ssl status` to check the loaded certificates, then test new connections as described in link:security/ssl-tls#upgrading-certificates[Upgrading Certificates]. @@ -1818,7 +1818,7 @@ Status does not test connections or verify that other nodes and clients trust th |Output |Meaning and next action |`last reload failed N time(s) in a row` |Check the reason and time on this line, correct the cause, and run reload again. -|`PROBLEM: the loaded certificate is not valid now` +|`PROBLEM: the loaded certificate chain is not valid now` |A certificate in the loaded chain has expired or is not yet valid. Install a valid chain and run reload. |`certificate metadata is unavailable` |The factory did not supply metadata, so status cannot check certificate dates. Check them directly in the key store or other certificate source. For a custom factory, see link:security/ssl-tls#custom-ssl-context-factories[Custom SSL Context Factories]. diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index fe07a91eade4f..6c9af2196cc9f 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -59,11 +59,11 @@ public static long chainNotAfter(Iterable chain) { } /** - * @param cert Certificate, {@code null} if unknown. - * @return Subject, issuer, serial and validity of the certificate; an empty string if it is unknown. + * @param cert Certificate to describe. + * @return Subject, issuer, serial number and validity period. */ - public static String describe(@Nullable X509Certificate cert) { - return cert == null ? "" : "subject=" + cert.getSubjectX500Principal() + ", issuer=" + cert.getIssuerX500Principal() + + public static String describe(X509Certificate cert) { + return "subject=" + cert.getSubjectX500Principal() + ", issuer=" + cert.getIssuerX500Principal() + ", serial=" + cert.getSerialNumber().toString(16) + ", notBefore=" + cert.getNotBefore().toInstant() + ", notAfter=" + cert.getNotAfter().toInstant(); } diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java index 47d9d6e9162cb..98d8a383df415 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java @@ -17,37 +17,30 @@ package org.apache.ignite.internal.ssl; -import java.util.function.Function; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslBundle; +import org.jetbrains.annotations.Nullable; -/** Access to the factory's key material for node status and reload. */ -public final class SslFactoryAccess { - /** */ - private final Function current; +/** Operations the node uses to report and replace a factory's TLS material. */ +public abstract class SslFactoryAccess { + /** @return Material in use, initialized on first use. */ + protected abstract SslBundle current(AbstractSslContextFactory factory); - /** */ - private final Function reload; + /** @return Fresh material accepted by the factory. */ + protected abstract SslBundle reload(AbstractSslContextFactory factory); + + /** @return Last successful reload and any failures since then. */ + protected abstract ReloadOutcome reloadOutcome(AbstractSslContextFactory factory); /** - * @param current Reads the accepted material. - * @param reload Loads and publishes replacement material. + * Snapshot of the last successful reload and subsequent failures. + * + * @param successTime Time of the last successful reload, {@code 0} if there was none. + * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. + * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. + * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. */ - public SslFactoryAccess( - Function current, - Function reload - ) { - this.current = current; - this.reload = reload; - } - - /** @return The factory's accepted material, initialized on first use. */ - SslBundle current(AbstractSslContextFactory factory) { - return current.apply(factory); - } - - /** @return Fresh material accepted by the factory. */ - SslBundle reload(AbstractSslContextFactory factory) { - return reload.apply(factory); + public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { + // No-op. } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 24f65a09cb04d..d6dd2ce8a448a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -41,6 +41,7 @@ import org.apache.ignite.IgniteException; import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.ssl.SslFactoryAccess; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.util.typedef.internal.A; import org.jetbrains.annotations.Nullable; @@ -62,8 +63,22 @@ public abstract class AbstractSslContextFactory implements Factory { * Gives the node access to the accepted material and the same reload path used by subclasses. * @hidden */ - public static final SslFactoryAccess NODE_ACCESS = - new SslFactoryAccess(AbstractSslContextFactory::sslBundle, AbstractSslContextFactory::init); + public static final SslFactoryAccess NODE_ACCESS = new SslFactoryAccess() { + /** {@inheritDoc} */ + @Override protected SslBundle current(AbstractSslContextFactory factory) { + return factory.sslBundle(); + } + + /** {@inheritDoc} */ + @Override protected SslBundle reload(AbstractSslContextFactory factory) { + return factory.init(); + } + + /** {@inheritDoc} */ + @Override protected ReloadOutcome reloadOutcome(AbstractSslContextFactory factory) { + return factory.reloadOutcome(); + } + }; /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ private transient volatile SslBundle bundle; @@ -301,6 +316,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * leaves the previous material in use. Call {@link #create()} again to obtain the replacement; previously obtained * contexts and their connections keep their original material. * + *

* The first initialization permits material without certificate chains and certificates outside their validity period, * matching node startup. Later calls reject a context already in use, known empty certificate metadata and chains with * any expired or not-yet-valid certificate. Peers must trust the replacement; this method does not test connections. @@ -350,8 +366,8 @@ private void checkReplacement(SslBundle next) { } if (next.context() == bundle.context()) { - throw new IgniteException("The SSL context factory returns the context in use, so the node gets no new " + - "certificates from it"); + throw new IgniteException("The SSL context factory returned the context already in use. " + + "Build a fresh SSLContext on each load to apply new certificates."); } if (chains == null) @@ -370,24 +386,12 @@ private void checkReplacement(SslBundle next) { } /** @return Snapshot of the last successful reload and subsequent failures; zero times before any reload. */ - public final ReloadOutcome reloadOutcome() { + private ReloadOutcome reloadOutcome() { ReloadOutcome res = outcome; return res == null ? new ReloadOutcome(0, 0, null, 0) : res; } - /** - * Snapshot of the last successful reload and subsequent failures. - * - * @param successTime Time of the last successful reload, {@code 0} if there was none. - * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. - * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. - * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. - */ - public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { - // No-op. - } - /** * Returns the accepted context and its metadata, initializing the factory on first use. * diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index cdf96aa855a58..9cc90733b5473 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -116,13 +116,11 @@ public void testReloadAndStatus() throws Exception { save(ca.keyStoreWithPrivateKey(), clientKeyStore); IgniteEx srv0 = startGrid(0); - IgniteEx srv1 = startGrid(1); IgniteEx cli = startClientGrid(CLIENT_NODE_NAME_PREFIX); // Transports are listed alphabetically; a client node runs no binary REST. Map transports = Map.of( srv0, "binary REST, client connector, communication, discovery", - srv1, "binary REST, client connector, communication, discovery", cli, "client connector, communication, discovery"); String loaded = "loaded alias=" + replacement + ", subject=CN=" + replacement; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index 1be0f01692a68..5ca635cc546c5 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -93,7 +93,10 @@ private SslReloadJob(NoArg arg, boolean debug, UUID originNodeId) { lines.add(id + ": reloaded " + transports + (desc.isEmpty() ? "" : "; loaded " + desc)); } - catch (Exception | LinkageError e) { + catch (Throwable e) { + if (e instanceof VirtualMachineError || e instanceof ThreadDeath) + throw e; + failed = true; String reason = reason(e); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index 114c06018ce0c..5e9831ba31c1f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -28,9 +28,9 @@ import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.processors.task.GridInternal; import org.apache.ignite.internal.ssl.SslContextProvider; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.visor.VisorJob; import org.apache.ignite.lang.IgniteBiTuple; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; @@ -96,7 +96,7 @@ else if (chains.isEmpty()) if (invalidAt(chain, System.currentTimeMillis()) != null) { invalid = true; - lines.add(DOUBLE_INDENT + "PROBLEM: the loaded certificate is not valid now [" + alias + ']'); + lines.add(DOUBLE_INDENT + "PROBLEM: the loaded certificate chain is not valid now [" + alias + ']'); } } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 9c300b2ad9817..cf36b68cc96d9 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -24,15 +24,15 @@ import java.util.concurrent.ConcurrentSkipListSet; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; import static org.apache.ignite.ssl.AbstractSslContextFactory.NODE_ACCESS; -/** Registers the transports using a factory; the factory owns their current context and reload results. */ +/** Shares one TLS context among the transports configured with the same factory. */ public class SslContextProvider { /** Factory configured by the user, retained for registration by identity. */ private final Factory factory; @@ -97,6 +97,6 @@ public SslBundle reload() { /** @return Snapshot of the last successful reload and subsequent failures. */ public ReloadOutcome reloadOutcome() { - return sslFactory.reloadOutcome(); + return NODE_ACCESS.reloadOutcome(sslFactory); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 29ab63b9fbe5a..c4919aead2573 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -32,13 +32,11 @@ import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; -import org.apache.ignite.testframework.ListeningTestLogger; -import org.apache.ignite.testframework.LogListener; import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Assert; @@ -154,7 +152,6 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { assertArrayEquals(nextParams.getProtocols(), current.getEnabledProtocols()); } - assertSame(f.reloadOutcome(), p.reloadOutcome()); assertTrue(p.reloadOutcome().successTime() > 0); } @@ -214,24 +211,6 @@ public void testMaterialWithoutCertificateChainsNotApplied() throws Exception { "The new key material has no X.509 certificate chains, so the node would present no certificate"); } - /** A context that cannot create an engine, here with a protocol the JVM does not support, is not applied. */ - @Test - public void testUnsupportedProtocolNotApplied() throws Exception { - SslBundle supported = bundle(ca.keyStoreWithPrivateKey("next"), ca.trustStore()); - SSLParameters params = new SSLParameters(); - - params.setProtocols(new String[] {"SSLvDoesNotExist"}); - - TestSslContextFactory f = mock(TestSslContextFactory.class, CALLS_REAL_METHODS); - - doReturn( - bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - new SslBundle(new SSLContextWrapper(supported.context(), params), supported.certificateChains())) - .when(f).createSslBundle(); - - assertReloadRejected(new SslContextProvider(f), "SSLvDoesNotExist"); - } - /** A factory that returns the context in use, as a caching custom factory does, gives the node no new certificates. */ @Test public void testContextInUseNotReloaded() throws Exception { @@ -248,7 +227,7 @@ public void testContextInUseNotReloaded() throws Exception { assertEquals(1, loads.get()); - assertReloadRejected(p, "The SSL context factory returns the context in use"); + assertReloadRejected(p, "The SSL context factory returned the context already in use"); p.sharedContext().createSSLEngine(); @@ -302,26 +281,6 @@ record Fixed(SSLContext ctx) implements Factory { assertEquals(2, registry.providers().size()); } - /** Registration warns when the key material has no certificate chains, here a trust store put in place of the key store. */ - @Test - public void testRegistryWarnsAboutMissingCertificateChains() throws Exception { - TestSslContextFactory f = mock(TestSslContextFactory.class, CALLS_REAL_METHODS); - - doReturn(bundle(ca.trustStore(), ca.trustStore())).when(f).createSslBundle(); - - ListeningTestLogger registryLog = new ListeningTestLogger(log); - LogListener warned = LogListener.matches("The key material has no X.509 certificate chains, " + - "so the node cannot present a certificate; reload rejects such key material").times(1).build(); - - registryLog.registerListener(warned); - - SslContextRegistry registry = new SslContextRegistry(new StandaloneGridKernalContext(registryLog, null)); - - registry.register(f, CLIENT_CONNECTOR); - - assertTrue(warned.check()); - } - /** * Checks that reload fails with the reason in its message and in the recorded outcome, keeps the context and metadata in use * and counts one more failure. diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 6fe8e9d25e161..db20527840e41 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -23,6 +23,7 @@ import java.security.cert.X509Certificate; import java.time.Instant; import java.util.List; +import java.util.ServiceConfigurationError; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -75,24 +76,16 @@ import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; import static org.apache.ignite.testframework.GridTestUtils.assertContains; -import static org.apache.ignite.testframework.GridTestUtils.assertNotContains; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; -import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; import static org.apache.ignite.testframework.TestCertificateAuthority.save; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * {@code --ssl reload} and {@code --ssl status} on running nodes: new connections of every transport get the reloaded certificate, - * every node logs who reloaded it, a factory is loaded once per reload whatever transports share it, separate factories get - * providers of their own, and a failing factory does not stop the others. The report, the node log and status show refused - * reloads; the report shows unavailable nodes and lists failures first; both commands name each reported chain; a node on an invalid - * certificate warns and fails status; without SSL both commands say that no factory is configured. The last test replaces the - * authority of a cluster. A node starts on old-key from old-ca and trusts both authorities unless a test says otherwise; its - * client connector shares the node factory and asks for no client certificate. Each node reads its own key and trust store - * files, which tests replace on disk. + * Tests TLS reload and status on running nodes. Each node reads its own stores. By default its client connector shares + * the node factory, and the node trusts both certificate authorities used during rotation. */ public class SslContextReloadTest extends GridCommonAbstractTest { /** Transports of a node whose client connector shares the node factory. */ @@ -280,7 +273,7 @@ public void testSeparateFactoriesReloadedSeparately() throws Exception { } /** - * A factory that fails, even with a linkage error, is logged and does not prevent reloading the other factories of the node. + * A broken certificate provider is reported without skipping other factories on the node. * The node factory is registered first, so the client connector line shows that reload goes on after a failure. */ @Test @@ -291,7 +284,7 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { IgniteEx g = startGrid(0, cfg -> { cfg.setSslContextFactory(() -> { if (fail.get()) - throw new NoClassDefFoundError("Certificate provider class is unavailable"); + throw new ServiceConfigurationError("Certificate provider class is unavailable"); return nodeFactory.create(); }); @@ -312,70 +305,12 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { " (Certificate provider class is unavailable)\n" + node(g) + ": reloaded " + CLIENT_CONNECTOR + "; loaded alias=" + NEW_KEY + ", "); assertTrue(logged.check()); - } - - /** A reload the provider refuses shows its reason in the command report, the node log and status, with the failures in a row. */ - @Test - public void testRejectedReloadReported() throws Exception { - long now = System.currentTimeMillis(); - String reason = "The new certificate chain is not valid now [alias=expired-key, subject=CN=expired-key, "; - LogListener logged = LogListener.matches(msg -> msg.startsWith("Failed to reload TLS certificates") && msg.contains(reason)) - .build(); - IgniteEx g = startGrid(0); - - nodeLog.registerListener(logged); - placeKeys(oldCa.keyStoreWithPrivateKey("expired-key", now - 2 * HOUR, now - HOUR)); - reloadFailure(g); - - String res = reloadFailure(g); String status = status(g); - assertContains(log, res, node(g) + ": failed on " + ALL_TRANSPORTS + " (" + reason); - assertTrue(logged.check()); - assertTrue(status, Pattern.compile(Pattern.quote(DOUBLE_INDENT + "last reload failed 2 time(s) in a row, the last at ") + - "\\S+: " + Pattern.quote(reason)).matcher(status).find()); - } - - /** Reload refuses a PKCS12 store with no certificate chains, but ignores an entry without a chain when another has one. */ - @Test - public void testReloadRequiresAtLeastOneCertificateChain() throws Exception { - String name = getTestIgniteInstanceName(0); - String alias = "key-without-chain"; - String storeType = "PKCS12"; - KeyStore startupKeys = KeyStore.getInstance(storeType); - - startupKeys.load(null, null); - startupKeys.setKeyEntry(OLD_KEY, oldKeys.getKey(OLD_KEY, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - oldKeys.getCertificateChain(OLD_KEY)); - - save(startupKeys, keyFile(name)); - - IgniteEx g = startGrid(0, cfg -> { - ((SslContextFactory)cfg.getSslContextFactory()).setKeyStoreType(storeType); - }); - KeyStore replacement = keyStoreWithoutCertificateChain(storeType, alias); - - assertTrue(replacement.isKeyEntry(alias)); - assertNull(replacement.getCertificateChain(alias)); - - save(replacement, keyFile(name)); - - assertContains(log, reloadFailure(g), - "The new key material has no X.509 certificate chains, so the node would present no certificate"); - assertEquals(oldCert, servedCertificate(context(oldKeys, trustBoth), discoveryPort(g))); - - replacement.setKeyEntry(NEW_KEY, newKeys.getKey(NEW_KEY, TestCertificateAuthority.PWD), TestCertificateAuthority.PWD, - newKeys.getCertificateChain(NEW_KEY)); - - save(replacement, keyFile(name)); - reload(g); - - String report = status(g); - - assertContains(log, report, "loaded alias=" + NEW_KEY + ", subject=CN=" + NEW_KEY); - assertNotContains(log, report, "alias=" + alias); - assertEquals(newCert, servedCertificate(context(oldKeys, trustBoth), discoveryPort(g))); + assertContains(log, status, "last reload failed 1 time(s) in a row"); + assertContains(log, status, "Certificate provider class is unavailable"); + assertContains(log, status, "last reload succeeded at"); } /** Both commands report a node that is unavailable when jobs are mapped and still run on the other nodes. */ @@ -448,28 +383,31 @@ public void testReportsNameEveryChain() throws Exception { assertContains(log, status, ", chainNotAfter=" + Instant.ofEpochMilli(caExpiry)); } - /** A node that starts on an expired certificate warns about it for each provider, and status fails with a problem for each. */ + /** Status reports the expired node chain and the client connector with no key, and startup logs both problems. */ @Test - public void testNodeOnInvalidCertificateReported() throws Exception { + public void testNodeOnUnusableMaterialReported() throws Exception { long now = System.currentTimeMillis(); String name = getTestIgniteInstanceName(0); - LogListener warned = LogListener.matches("The TLS certificate chain in use is not valid now; put a valid certificate in place " + - "and run control.sh --ssl reload [alias=expired-key, subject=CN=expired-key, ").times(2).build(); + LogListener expired = LogListener.matches("The TLS certificate chain in use is not valid now; put a valid certificate in place " + + "and run control.sh --ssl reload [alias=expired-key, subject=CN=expired-key, ").build(); + LogListener noChains = LogListener.matches("The key material has no X.509 certificate chains").build(); save(oldCa.keyStoreWithPrivateKey("expired-key", now - 2 * HOUR, now - HOUR), keyFile(name)); - nodeLog.registerListener(warned); + nodeLog.registerListener(expired); + nodeLog.registerListener(noChains); IgniteEx g = startGrid(0, cfg -> { cfg.getClientConnectorConfiguration().setUseIgniteSslContextFactory(false) - .setSslContextFactory(storeFactory(keyFile(name), trustFile(name))); + .setSslContextFactory(storeFactory(trustFile(name), trustFile(name))); }); String res = X.getFullStackTrace(GridTestUtils.assertThrows(log, () -> status(g), Exception.class, null)); String problem = "\n" + DOUBLE_INDENT + "loaded alias=expired-key, subject=CN=expired-key, [^\\n]+\n" + DOUBLE_INDENT + - "PROBLEM: the loaded certificate is not valid now \\[alias=expired-key]"; + "PROBLEM: the loaded certificate chain is not valid now \\[alias=expired-key]"; - assertTrue(warned.check()); - assertTrue(res, Pattern.compile(Pattern.quote(node(g) + ": " + CLIENT_CONNECTOR) + problem).matcher(res).find()); + assertTrue(expired.check()); + assertTrue(noChains.check()); + assertContains(log, res, node(g) + ": " + CLIENT_CONNECTOR + '\n' + DOUBLE_INDENT + "no X.509 certificate chains"); assertTrue(res, Pattern.compile(Pattern.quote(node(g) + ": " + COMMUNICATION + ", " + DISCOVERY) + problem).matcher(res).find()); } From 6bca80990320e6df8b238f247b983620f48ef68e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 21:23:07 +0300 Subject: [PATCH 123/242] IGNITE-29209 Preserve keyless client factories and document snapshot use --- docs/_docs/security/ssl-tls.adoc | 21 ++ .../ignite/ssl/AbstractSslContextFactory.java | 54 ++-- .../java/org/apache/ignite/ssl/SslBundle.java | 2 +- .../apache/ignite/ssl/SslContextFactory.java | 2 +- .../GridCommandHandlerWithSslFactoryTest.java | 2 +- modules/core/pom.xml | 7 - .../internal/jdbc/thin/JdbcThinSSLUtil.java | 3 +- .../tcp/TcpDiscoverySslParametersTest.java | 8 - .../ignite/ssl/SslContextMetadataTest.java | 306 ++++++++---------- .../ignite/ssl/SslContextReuseTest.java | 131 -------- .../TestCertificateAuthority.java | 201 ------------ .../ignite/testsuites/SecurityTestSuite.java | 2 - .../Ssl/SslConfigurationTest.cs | 4 +- parent/pom.xml | 1 - 14 files changed, 189 insertions(+), 555 deletions(-) delete mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java delete mode 100644 modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b56b2094d586c..4a4bfa358e655 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -179,6 +179,27 @@ Now all nodes trust the new certificates. //Otherwise, first you have to push the trust store to all nodes one by one. It will contain trusts for both new and old certificates while you transition. +== Custom SSL Context Factories + +A subclass of `AbstractSslContextFactory` implements `createSslBundle()` to build an initialized context and report the certificate chains loaded with it. +For a key store, load it once and pass it to `createSslBundle(store, keyPassword, algorithm)`. For a custom key manager, return an `SslBundle` with the chains for its private keys, indexed by alias. +Use a null chain map if the source cannot report certificates. An empty map means the store has no X.509 key chains. + +Call `create()` to obtain the accepted context. If a subclass needs a fresh certificate when a node restarts with the same factory, it can call the protected `init()` method and take the chain from the returned bundle. +A failed initialization leaves the previous context in use. + +=== Migrating an Existing Factory + +Update and recompile subclasses that use the old `createKeyManagers()` hook, override `create()` or access the protected `sslCtx` field. + +* Move key loading and custom context configuration into `createSslBundle()`. A subclass of `SslContextFactory` can use `super.createSslBundle()` and retain its certificate chains when customizing the context. The hook can throw `SSLException`. +* Remove custom context caching. The base factory retains the accepted bundle. After a replacement, call the final `create()` method again to obtain the new context. +* Direct subclasses of `AbstractSslContextFactory` that implement `checkParameters()` must remove its `@Override` and call it before loading in `createSslBundle()`. Subclasses of `SslContextFactory` can keep that override. +* Rename any existing no-argument `init()` in your subclass. The base factory defines it as final. + +A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override. A client that authenticates only the server can return a null key store. +Implementations of `Factory` can continue to supply a context directly. + == SslContextFactory Properties `SslContextFactory` supports the following properties: diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 7184d71f675f1..a7c1285a175c3 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -27,9 +27,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.TreeMap; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; @@ -42,11 +42,8 @@ import org.jetbrains.annotations.Nullable; /** - * Owns the SSL context and its certificate metadata. {@link #init()} loads replacement material before publishing it; - * {@link #create()} returns the accepted context. Obtain it again after initialization to use the replacement; - * previously obtained contexts, engines, socket factories and sockets retain their original material. - * Configure the factory before its first use. Reusing an initialized factory, including after a node restart, retains - * its material until {@code init()} succeeds. + * Owns the SSL context and the certificate chains loaded with it. An initialized factory retains its material across + * node restarts. A subclass can call {@link #init()} to replace it. Configure the factory before use. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -55,7 +52,7 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default SSL protocol. */ public static final String DFLT_SSL_PROTOCOL = "TLS"; - /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ + /** Accepted context and the metadata loaded with it. */ private transient volatile SslBundle bundle; /** SSL protocol. */ @@ -125,7 +122,7 @@ public void setProtocols(String... protocols) { /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. The result must not change material already in use. + * loading it. Keep the material in use intact. Calling {@link #create()} from this hook would recurse on first use. * * @return Fresh context and the certificate metadata available to this factory. * @throws SSLException If the context could not be created. @@ -137,15 +134,15 @@ public void setProtocols(String... protocols) { * Do not modify the store while the context is in use: key managers such as PKIX read it during handshakes. * Load replacement material into a separate store. * - * @param store Loaded key store, never {@code null}. For a context without private keys, use an empty store initialized - * with {@code KeyStore.load(null, null)}. + * @param store Loaded key store, or {@code null} for a client without private keys. * @param keyPwd Password for the loaded store's private keys, or {@code null} if no password is required. * @param algorithm Key manager algorithm. * @return Context and nonempty X.509 chains of key entries, ordered by alias. An empty map means no such chains were found. * @throws SSLException If the store cannot be read or the context cannot be created. */ - protected final SslBundle createSslBundle(KeyStore store, @Nullable char[] keyPwd, String algorithm) throws SSLException { - A.notNull(store, "store", algorithm, "algorithm"); + protected final SslBundle createSslBundle(@Nullable KeyStore store, @Nullable char[] keyPwd, String algorithm) + throws SSLException { + A.notNull(algorithm, "algorithm"); KeyManager[] keyMgrs; @@ -157,24 +154,24 @@ protected final SslBundle createSslBundle(KeyStore store, @Nullable char[] keyPw keyMgrs = factory.getKeyManagers(); } catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + algorithm, e); + throw new SSLException("Unsupported key algorithm: " + algorithm + " [factory=" + this + ']', e); } catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred)", e); + throw new SSLException("Failed to initialize Key Manager (security exception occurred) [factory=" + this + ']', e); } - Map> chains = certificateChains(store); + Map> chains = store == null ? Collections.emptyMap() : certificateChains(store); return new SslBundle(createSslContext(keyMgrs), chains); } - /** @return Fresh candidate with factory context added to checked SSL errors. */ + /** @return Fresh context and certificate chains. */ private SslBundle prepareSslBundle() { try { return createSslBundle(); } catch (SSLException e) { - throw new IgniteException(e.getMessage() + " [factory=" + this + ']', e); + throw new IgniteException(e); } } @@ -185,7 +182,7 @@ private SslBundle prepareSslBundle() { */ private static Map> certificateChains(KeyStore store) throws SSLException { try { - Map> chains = new TreeMap<>(); + Map> chains = new HashMap<>(); for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { String alias = aliases.nextElement(); @@ -278,13 +275,12 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads keys and trust settings and atomically replaces the accepted context and its metadata. A failed initialization - * leaves the previous material in use. Call {@link #create()} again to obtain the replacement; previously obtained - * contexts and their connections keep their original material. + * Loads and validates fresh material before replacing the accepted context and its metadata. Calls run one at a time. + * A failed initialization leaves the previous material in use. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded or the context cannot be created. - * @throws IllegalArgumentException If a configured protocol or cipher suite is unsupported. + * @throws IllegalArgumentException If the JVM does not recognize a name in {@link #setProtocols} or {@link #setCipherSuites}. */ protected final synchronized SslBundle init() { SslBundle next = prepareSslBundle(); @@ -299,8 +295,9 @@ protected final synchronized SslBundle init() { /** * Returns the accepted context and its metadata, initializing the factory on first use. * - * @return One snapshot of the material in use. - * @throws IgniteException If the first initialization fails. + * @return Accepted bundle. + * @throws IgniteException If the first initialization fails to load the material or create the context. + * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. */ final SslBundle sslBundle() { SslBundle cur = bundle; @@ -318,12 +315,13 @@ final SslBundle sslBundle() { } /** - * Returns the accepted context, initializing it on first use. Repeated calls do not read the key or trust stores. - * After a successful {@link #init()}, call this method again to obtain the replacement. Previously returned contexts - * retain their original material. Do not reinitialize the returned context directly. + * Initializes the context on first use. Once a context is available, callers can use it while {@link #init()} + * loads a replacement. Obtain the context again after a successful replacement. Previously returned contexts, + * engines and sockets retain their original material. Do not reinitialize the returned context directly. * * @return Currently accepted context. - * @throws IgniteException If the first initialization fails. + * @throws IgniteException If the first initialization fails to load the material or create the context. + * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. */ @Override public final SSLContext create() { return sslBundle().context(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java index f15f4ccfb61ed..3d08dfb44d0ee 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -39,7 +39,7 @@ public final class SslBundle { /** * @param ctx Initialized SSL context. - * @param chains Nonempty X.509 chains of key entries, by alias, own certificate first. {@code null} means certificate + * @param chains Nonempty X.509 chains of key entries, by alias, the key's certificate first. {@code null} means certificate * metadata is unavailable; an empty map means there are no chains. Trusted certificate entries and secret keys * must not be included. * @throws IllegalArgumentException If a chain is empty. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index ec14042e14d0b..f7d7007f55750 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -347,7 +347,7 @@ protected InputStream openFileInputStream(String filePath) throws IOException { * @param keyStoreType Type of key store. * @param storeFilePath Path to key store file. * @param keyStorePwd Store password. - * @return Initialized key store, never {@code null}. Return an empty loaded store for a context without private keys. + * @return Initialized store. An override may return {@code null} for a client without a private key or to use the JVM trust store. * @throws SSLException If key store could not be initialized. */ protected KeyStore loadKeyStore(String keyStoreType, String storeFilePath, char[] keyStorePwd) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java index 751d91bf26482..a497083501359 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java @@ -94,7 +94,7 @@ public static void tearDown() { */ public static class SslFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() throws SSLException { + @Override protected SslBundle createSslBundle() throws SSLException { factoryUsed = true; return super.createSslBundle(); diff --git a/modules/core/pom.xml b/modules/core/pom.xml index f2b464d91bd6e..bbdf058dea191 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -158,13 +158,6 @@ test - - org.bouncycastle - bcpkix-jdk18on - ${bouncycastle.version} - test - - org.eclipse.jetty.ee11 jetty-ee11-servlets diff --git a/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java b/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java index 1024d30a81a67..8c7ca94ab53b2 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java @@ -188,8 +188,9 @@ private static SSLSocketFactory getSSLSocketFactory(ConnectionProperties connPro catch (IgniteException e) { final Throwable cause = e.getCause(); + // Unwrap. if (cause instanceof SSLException) - throw new SQLException(e.getMessage(), SqlStateCode.CLIENT_CONNECTION_FAILED, e); + throw new SQLException(cause.getMessage(), SqlStateCode.CLIENT_CONNECTION_FAILED, e); else throw new SQLException("Unknown error.", SqlStateCode.CLIENT_CONNECTION_FAILED, e); } diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java index 399ab0e62372a..70a4312aa92e0 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslParametersTest.java @@ -181,14 +181,6 @@ public void testNonExistentProtocol() throws Exception { ); } - /** A node with a protocol the JVM does not know must not start, even alone. */ - @Test - public void testNonExistentProtocolRefusedAtStart() { - protocols = new String[] {"SSLvDoesNotExist"}; - - GridTestUtils.assertThrows(log, () -> startGrid(0), IgniteCheckedException.class, "SSLvDoesNotExist"); - } - /** * @throws Exception If failed. */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index c820ae06c5b57..006f00fe5257c 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -17,11 +17,16 @@ package org.apache.ignite.ssl; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; import java.security.KeyStore; +import java.security.KeyStoreSpi; import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -36,198 +41,160 @@ import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.testframework.GridTestUtils; -import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; -import static org.apache.ignite.testframework.TestCertificateAuthority.chain; -import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.junit.Assert.assertArrayEquals; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; -/** - * Factory initialization publishes a context and metadata from one store read; failed initialization preserves the previous - * bundle. Reading the accepted bundle or context does not reload stores. Metadata includes nonempty X.509 chains. - * {@link SslBundle} keeps sorted immutable metadata, or {@code null}. - */ +/** Tests how the factory loads, publishes and retains its context and certificate metadata. */ public class SslContextMetadataTest extends GridCommonAbstractTest { - /** Authority that signs the test certificates. */ - private TestCertificateAuthority ca; - - /** {@inheritDoc} */ - @Override protected void beforeTest() throws Exception { - ca = new TestCertificateAuthority("metadata-ca"); - } - - /** Each initialization publishes a fresh context with metadata from the same, single key store read. */ + /** Each accepted context and its certificate chains come from the same store read. */ @Test public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { - KeyStore first = ca.keyStoreWithPrivateKey("first"); + KeyStore first = nodeStore("node01"); + KeyStore second = nodeStore("node02"); InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - SslBundle firstBundle = factory.init(); - - assertEquals(1, factory.loads); - assertSame(firstBundle, factory.sslBundle()); - assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); - - assertSame(firstBundle.context(), factory.create()); - - KeyStore second = ca.keyStoreWithPrivateKey("second"); + SslBundle original = factory.init(); factory.store = second; - SslBundle secondBundle = factory.init(); + SslBundle replacement = factory.init(); assertEquals(2, factory.loads); - assertSame(secondBundle, factory.sslBundle()); - assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); - assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); - assertNotSame(firstBundle.context(), secondBundle.context()); - assertSame(secondBundle.context(), factory.create()); + assertEquals(Map.of("node01", chain(first, "node01")), original.certificateChains()); + assertEquals(Map.of("node02", chain(second, "node02")), replacement.certificateChains()); + assertNotSame(original.context(), replacement.context()); + assertSame(replacement.context(), factory.create()); } - /** Concurrent first calls initialize once; obtaining the context and accepted snapshot never reloads the store. */ + /** Concurrent requests share the context produced by the first store read. */ @Test - public void testCreateAndSnapshotDoNotReloadStore() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); - int threadCnt = 4; - CyclicBarrier start = new CyclicBarrier(threadCnt); - AtomicReference sharedRef = new AtomicReference<>(); + public void testConcurrentFirstCreateLoadsOnce() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); + int threads = 4; + CyclicBarrier start = new CyclicBarrier(threads); + AtomicReference shared = new AtomicReference<>(); GridTestUtils.runMultiThreaded(() -> { start.await(); SSLContext ctx = factory.create(); - sharedRef.compareAndSet(null, ctx); - assertSame(sharedRef.get(), ctx); + shared.compareAndSet(null, ctx); + assertSame(shared.get(), ctx); return null; - }, threadCnt, "ssl-context-create"); - - SslBundle accepted = factory.sslBundle(); - SSLContext shared = sharedRef.get(); + }, threads, "ssl-create"); - factory.loadFailure = "Store must not be read again"; - - assertSame(shared, factory.create()); - assertSame(accepted, factory.sslBundle()); assertEquals(1, factory.loads); } - /** Readers retain the accepted material while initialization fails; a later attempt can replace it. */ + /** A slow store read does not block a caller that can already use the accepted context. */ @Test - public void testFailedInitRetainsAcceptedBundle() throws Exception { - InMemoryStoreFactory factory = spy(new InMemoryStoreFactory(ca.keyStoreWithPrivateKey())); - SslBundle accepted = factory.init(); + public void testCreateDoesNotWaitForInit() throws Exception { + InMemoryStoreFactory factory = spy(new InMemoryStoreFactory(nodeStore("node01"))); + SSLContext accepted = factory.create(); CountDownLatch loading = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); long timeout = 10_000; - factory.loadFailure = "Key store is unavailable"; - doAnswer(inv -> { loading.countDown(); release.await(); return inv.callRealMethod(); - }).doCallRealMethod().when(factory).createSslBundle(); + }).when(factory).createSslBundle(); - IgniteInternalFuture initialization = GridTestUtils.runAsync(() -> { - return factory.init(); - }); - IgniteInternalFuture reading = null; + IgniteInternalFuture init = GridTestUtils.runAsync(() -> factory.init()); try { - assertTrue(loading.await(timeout, TimeUnit.MILLISECONDS)); - - reading = GridTestUtils.runAsync(() -> { - assertSame(accepted.context(), factory.create()); - - return factory.sslBundle(); - }); - - assertSame(accepted, reading.get(timeout)); + assertTrue("Replacement load started", loading.await(timeout, TimeUnit.MILLISECONDS)); + assertSame(accepted, GridTestUtils.runAsync(() -> factory.create()).get(timeout)); } finally { release.countDown(); - - if (reading != null) - reading.cancel(); - - GridTestUtils.assertThrowsWithCause(() -> initialization.get(timeout), SSLException.class); + init.get(timeout); } + } - assertSame(accepted, factory.sslBundle()); - assertSame(accepted.context(), factory.create()); - assertNotNull(accepted.context().createSSLEngine()); - assertEquals(2, factory.loads); + /** Invalid settings can be corrected after first use fails. A later failure leaves the last accepted bundle usable. */ + @Test + public void testFailedInitKeepsAcceptedBundle() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); + String invalidProtocol = "unsupported-protocol"; - factory.loadFailure = null; + factory.setProtocols(invalidProtocol); - SslBundle replacement = factory.init(); + GridTestUtils.assertThrows(log, factory::create, IllegalArgumentException.class, invalidProtocol); - assertNotSame(accepted, replacement); - assertSame(replacement, factory.sslBundle()); - assertSame(replacement.context(), factory.create()); - assertEquals(3, factory.loads); - } + factory.setProtocols((String[])null); - /** A failed first initialization leaves the factory able to initialize on the next context request. */ - @Test - public void testFirstInitializationFailureCanBeRetried() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + SslBundle accepted = factory.sslBundle(); factory.loadFailure = "Key store is unavailable"; - GridTestUtils.assertThrows(log, factory::create, IgniteException.class, factory.loadFailure); + GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); + assertSame(accepted, factory.sslBundle()); factory.loadFailure = null; + factory.setProtocols(invalidProtocol); - SSLContext shared = factory.create(); - - assertSame(shared, factory.create()); - assertNotNull(factory.sslBundle().context().createSSLEngine()); - assertEquals(2, factory.loads); + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, invalidProtocol); + assertSame(accepted, factory.sslBundle()); } - /** Unsupported TLS parameters are rejected before a replacement becomes visible. */ + /** A context already handed to a consumer retains its TLS settings after the factory reloads. */ @Test - public void testInvalidTlsParametersDoNotReplaceAcceptedBundle() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); - SslBundle accepted = factory.init(); - SSLContext shared = factory.create(); + public void testPreviousContextKeepsItsSettings() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); + String[] originalProtocols = {"TLSv1.2"}; + String[] replacementProtocols = {"TLSv1.3"}; - factory.setProtocols("unsupported-protocol"); + factory.setProtocols(originalProtocols); - GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + SSLContext original = factory.create(); - assertSame(accepted, factory.sslBundle()); - assertSame(shared, factory.create()); - assertNotNull(shared.createSSLEngine()); + factory.setProtocols(replacementProtocols); + factory.init(); - factory.setProtocols((String[])null); - factory.setCipherSuites("unsupported-cipher-suite"); + assertArrayEquals(originalProtocols, original.createSSLEngine().getEnabledProtocols()); + assertArrayEquals(replacementProtocols, factory.create().createSSLEngine().getEnabledProtocols()); + } + + /** Deserializing a configured factory loads fresh material at first use. */ + @Test + public void testSerializationKeepsConfiguration() throws Exception { + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + SslBundle original = factory.init(); + SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), + getClass().getClassLoader()); + + assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); + assertNotSame(original.context(), restored.create()); + assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); + } - GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, null); + /** A client factory can return no key store when it authenticates only the server. */ + @Test + public void testClientWithoutKeyStore() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(null); + SslBundle client = factory.init(); - assertSame(accepted, factory.sslBundle()); - assertSame(shared, factory.create()); - assertNotNull(shared.createSSLEngine()); - assertEquals(3, factory.loads); + assertEquals(Map.of(), client.certificateChains()); + assertNotNull(client.context().createSSLEngine()); } /** Missing parameters are reported before the key store is read. */ @Test public void testInvalidParametersDoNotLoadStore() throws Exception { - KeyStore empty = KeyStore.getInstance("JKS"); - - empty.load(null, null); - - InMemoryStoreFactory factory = new InMemoryStoreFactory(empty); + InMemoryStoreFactory factory = new InMemoryStoreFactory(null); factory.setTrustManagers((TrustManager[])null); @@ -239,29 +206,38 @@ public void testInvalidParametersDoNotLoadStore() throws Exception { /** Metadata includes only nonempty X.509 chains of key entries. */ @Test public void testOnlyKeyEntriesWithX509Chains() throws Exception { - String firstAlias = "first-key"; - String secondAlias = "second-key"; - KeyStore first = ca.keyStoreWithPrivateKey(firstAlias); - KeyStore second = ca.keyStoreWithPrivateKey(secondAlias); - KeyStore.PasswordProtection prot = new KeyStore.PasswordProtection(PWD); - - // JCEKS can hold both private keys and secret keys. - KeyStore store = keyStoreWithoutCertificateChain("JCEKS", "key-without-chain"); - - store.setEntry(firstAlias, first.getEntry(firstAlias, prot), prot); - store.setEntry(secondAlias, second.getEntry(secondAlias, prot), prot); - store.setCertificateEntry("trusted", first.getCertificate(firstAlias)); - store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), - prot); - store.setKeyEntry("non-x509-chain", first.getKey(firstAlias, PWD), PWD, - new Certificate[] {first.getCertificate(firstAlias), mock(Certificate.class)}); - - // PKIX defers key selection, allowing metadata filtering to inspect the entire mixed certificate chain. + KeyStore keys = nodeStore("node01"); + KeyStore otherKeys = nodeStore("node02"); + String firstAlias = "node01"; + String secondAlias = "node02"; + char[] pwd = keyStorePassword().toCharArray(); + KeyStore.PasswordProtection protection = new KeyStore.PasswordProtection(pwd); + + // The provider can store a key without a chain, although KeyStore.setKeyEntry rejects it. + KeyStore store = KeyStore.getInstance("JCEKS"); + KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", store.getType()).newInstance(null); + + spi.engineLoad(null, pwd); + spi.engineSetKeyEntry("key-without-chain", keys.getKey(firstAlias, pwd), pwd, null); + + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + + spi.engineStore(encoded, pwd); + store.load(new ByteArrayInputStream(encoded.toByteArray()), pwd); + store.setEntry(firstAlias, keys.getEntry(firstAlias, protection), protection); + store.setEntry(secondAlias, otherKeys.getEntry(secondAlias, protection), protection); + store.setCertificateEntry("trusted", keys.getCertificate(firstAlias)); + store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), protection); + store.setKeyEntry("mixed-chain", keys.getKey(firstAlias, pwd), pwd, + new Certificate[] {keys.getCertificate(firstAlias), mock(Certificate.class)}); + + // SunX509 requires X.509 entries at initialization. PKIX defers key selection until a handshake. InMemoryStoreFactory factory = new InMemoryStoreFactory(store); factory.setKeyAlgorithm("PKIX"); - assertEquals(Map.of(firstAlias, chain(first), secondAlias, chain(second)), factory.init().certificateChains()); + assertEquals(Map.of(firstAlias, chain(keys, firstAlias), secondAlias, chain(otherKeys, secondAlias)), + factory.init().certificateChains()); } /** An empty key store yields a context without private keys and an empty map. */ @@ -276,33 +252,6 @@ public void testEmptyStoreCreatesKeylessContext() throws Exception { assertEquals(Map.of(), bundle.certificateChains()); } - /** A source can supply a key password different from the factory configuration without loading its store twice. */ - @Test - public void testBundleUsesPasswordSuppliedWithStore() throws Exception { - String alias = "source-key"; - KeyStore store = ca.keyStoreWithPrivateKey(alias); - char[] srcPwd = "source-key-password".toCharArray(); - - store.setKeyEntry(alias, store.getKey(alias, PWD), srcPwd, store.getCertificateChain(alias)); - - InMemoryStoreFactory factory = new InMemoryStoreFactory(store) { - /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() throws SSLException { - checkParameters(); - - KeyStore loaded = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - return createSslBundle(loaded, srcPwd, keyAlgorithm); - } - }; - - SslBundle bundle = factory.init(); - - assertEquals(1, factory.loads); - assertEquals(Map.of(alias, chain(store)), bundle.certificateChains()); - assertSame(bundle.context(), factory.create()); - } - /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ @Test public void testBundleKeepsSortedImmutableCopy() throws Exception { @@ -336,19 +285,20 @@ public void testBundleRejectsEmptyChain() throws Exception { IllegalArgumentException.class, "Certificate chain must not be empty [alias=" + alias + ']'); } - /** Unknown metadata stays {@code null}, so it is not mistaken for a store without certificate chains. */ + /** Unavailable metadata stays {@code null}, so it is not mistaken for a store without certificate chains. */ @Test - public void testBundleKeepsUnknownMetadata() throws Exception { + public void testBundleKeepsUnavailableMetadata() throws Exception { assertNull(new SslBundle(SSLContext.getDefault(), null).certificateChains()); } /** A key the store password cannot open is reported with the store type and path and keeps the original cause. */ @Test public void testKeyPasswordFailureNamesStore() throws Exception { - KeyStore store = ca.keyStoreWithPrivateKey("private-key"); + String alias = "node01"; + KeyStore store = nodeStore(alias); - store.setKeyEntry("private-key", store.getKey("private-key", PWD), "other-key-password".toCharArray(), - store.getCertificateChain("private-key")); + store.setKeyEntry(alias, store.getKey(alias, keyStorePassword().toCharArray()), "other-key-password".toCharArray(), + store.getCertificateChain(alias)); InMemoryStoreFactory factory = new InMemoryStoreFactory(store); @@ -364,6 +314,18 @@ public void testKeyPasswordFailureNamesStore() throws Exception { assertTrue(err.getCause().getCause() instanceof UnrecoverableKeyException); } + /** Loads one of the node key stores shipped with the test suite. */ + private static KeyStore nodeStore(String name) throws Exception { + return KeyStore.getInstance(new File(keyStorePath(name)), keyStorePassword().toCharArray()); + } + + /** Returns the certificate chain saved in the source store. */ + private static List chain(KeyStore store, String alias) throws Exception { + Certificate[] chain = store.getCertificateChain(alias); + + return List.of(Arrays.copyOf(chain, chain.length, X509Certificate[].class)); + } + /** * File factory that reads its key store from memory and counts the reads. Trust is disabled, so the trust store is never * read and every read is the key store's. @@ -382,9 +344,11 @@ private static class InMemoryStoreFactory extends SslContextFactory { InMemoryStoreFactory(KeyStore store) { this.store = store; - setKeyStoreType(store.getType()); + if (store != null) + setKeyStoreType(store.getType()); + setKeyStoreFilePath("in-memory"); - setKeyStorePassword(PWD); + setKeyStorePassword(keyStorePassword().toCharArray()); setTrustManagers(getDisabledTrustManager()); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java deleted file mode 100644 index ce6e85dd389c2..0000000000000 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.io.File; -import java.io.FileOutputStream; -import java.security.KeyStore; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; -import org.apache.ignite.testframework.TestCertificateAuthority; -import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; -import static org.junit.Assert.assertArrayEquals; - -/** Context snapshots keep their material; consumers obtain a replacement from the factory after initialization. */ -public class SslContextReuseTest extends GridCommonAbstractTest { - /** Stores generated independently for each test. */ - @Rule public final TemporaryFolder tmp = new TemporaryFolder(); - - /** A new context uses initialized TLS settings; the previous context, engines and socket factories keep their settings. */ - @Test - public void testCreateReturnsInitializedContextSnapshot() throws Exception { - SslContextFactory factory = fileFactory(); - String[] firstProtocols = {"TLSv1.2"}; - String[] firstCiphers = {"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"}; - - factory.setProtocols(firstProtocols); - factory.setCipherSuites(firstCiphers); - - SSLContext previous = factory.create(); - SSLEngine existing = previous.createSSLEngine(); - SSLSocketFactory capturedFactory = previous.getSocketFactory(); - String[] replacementProtocols = {"TLSv1.3"}; - String[] replacementCiphers = {"TLS_AES_128_GCM_SHA256"}; - - factory.setProtocols(replacementProtocols); - factory.setCipherSuites(replacementCiphers); - - SslBundle replacement = factory.init(); - SSLContext cur = factory.create(); - String host = "peer.example"; - int port = 10800; - SSLEngine addressed = cur.createSSLEngine(host, port); - SSLEngine fromPrevious = previous.createSSLEngine(); - - assertSame(replacement.context(), cur); - assertNotSame(previous, cur); - assertEquals(host, addressed.getPeerHost()); - assertEquals(port, addressed.getPeerPort()); - assertArrayEquals(replacementProtocols, addressed.getEnabledProtocols()); - assertArrayEquals(replacementCiphers, addressed.getEnabledCipherSuites()); - assertArrayEquals(firstProtocols, existing.getEnabledProtocols()); - assertArrayEquals(firstCiphers, existing.getEnabledCipherSuites()); - assertArrayEquals(firstProtocols, fromPrevious.getEnabledProtocols()); - assertArrayEquals(firstCiphers, fromPrevious.getEnabledCipherSuites()); - - try (SSLSocket captured = (SSLSocket)capturedFactory.createSocket(); - SSLSocket replacementSocket = (SSLSocket)cur.getSocketFactory().createSocket()) { - assertArrayEquals(firstProtocols, captured.getEnabledProtocols()); - assertArrayEquals(firstCiphers, captured.getEnabledCipherSuites()); - assertArrayEquals(replacementProtocols, replacementSocket.getEnabledProtocols()); - assertArrayEquals(replacementCiphers, replacementSocket.getEnabledCipherSuites()); - } - } - - /** An initialized factory serializes its configuration and initializes fresh runtime state after deserialization. */ - @Test - public void testInitializedFactorySerializationKeepsConfiguration() throws Exception { - SslContextFactory factory = fileFactory(); - String[] protocols = {"TLSv1.2"}; - - factory.setProtocols(protocols); - - SslBundle original = factory.init(); - SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), - getClass().getClassLoader()); - - assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); - assertEquals(factory.getTrustStoreFilePath(), restored.getTrustStoreFilePath()); - assertArrayEquals(protocols, restored.getProtocols()); - - SSLContext shared = restored.create(); - - assertNotSame(factory.create(), shared); - assertSame(shared, restored.create()); - assertArrayEquals(protocols, shared.createSSLEngine().getEnabledProtocols()); - assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); - } - - /** Creates a file-backed factory with a generated private key and currently valid certificate chain. */ - private SslContextFactory fileFactory() throws Exception { - KeyStore store = new TestCertificateAuthority("context-reuse-ca").keyStoreWithPrivateKey(); - File storeFile = tmp.newFile("context.jks"); - - try (FileOutputStream out = new FileOutputStream(storeFile)) { - store.store(out, PWD); - } - - SslContextFactory factory = new SslContextFactory(); - - factory.setKeyStoreType(store.getType()); - factory.setKeyStoreFilePath(storeFile.getAbsolutePath()); - factory.setKeyStorePassword(PWD); - factory.setTrustStoreType(store.getType()); - factory.setTrustStoreFilePath(storeFile.getAbsolutePath()); - factory.setTrustStorePassword(PWD); - - return factory; - } -} diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java deleted file mode 100644 index 42c09382bd0c6..0000000000000 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ /dev/null @@ -1,201 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.testframework; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.math.BigInteger; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.KeyStoreSpi; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.security.spec.ECGenParameterSpec; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import javax.security.auth.x500.X500Principal; -import org.bouncycastle.asn1.x509.BasicConstraints; -import org.bouncycastle.asn1.x509.ExtendedKeyUsage; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.KeyPurposeId; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; - -/** - * Creates test stores protected by {@link #PWD}. Private keys use EC P-256. - * Certificates use SHA256withECDSA signatures. Issued leaf certificates allow both server and client authentication, - * so the same material works for node-to-node TLS. - */ -public class TestCertificateAuthority { - /** Password of every store made here. */ - public static final char[] PWD = "123456".toCharArray(); - - /** Name used when certificate identity is not part of the test. */ - private static final String DEFAULT_NAME = "test-certificate"; - - /** Certificate serial numbers must be positive and unique within this authority. */ - private final AtomicLong serial = new AtomicLong(); - - /** */ - private final X500Principal name; - - /** */ - private final KeyPair keys; - - /** */ - private final X509Certificate cert; - - /** - * Creates an authority valid from yesterday until a year from now. - * - * @param cn Common name of the authority. - */ - public TestCertificateAuthority(String cn) throws Exception { - long now = System.currentTimeMillis(); - - name = new X500Principal("CN=" + cn); - keys = keyPair(); - - cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); - } - - /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ - public KeyStore keyStoreWithPrivateKey() throws Exception { - return keyStoreWithPrivateKey(DEFAULT_NAME); - } - - /** - * Issues a certificate valid from an hour ago until a day from now, for tests that do not check expiry. - * - * @param cn Common name, also used as the key alias. - * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. - */ - public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { - long now = System.currentTimeMillis(); - KeyPair pair = keyPair(); - - X509Certificate leaf = sign(new X500Principal("CN=" + cn), pair, now - TimeUnit.HOURS.toMillis(1), - now + TimeUnit.DAYS.toMillis(1), false); - - KeyStore store = emptyStore(); - - store.setKeyEntry(cn, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); - - return store; - } - - /** - * @param type Store type. - * @param alias Private-key alias. - * @return Saved and reloaded store with a private key but no certificate chain, protected by {@link #PWD}. - */ - public static KeyStore keyStoreWithoutCertificateChain(String type, String alias) throws Exception { - KeyStore store = KeyStore.getInstance(type); - - // KeyStore rejects a missing chain on insertion; its provider can still store and load such a key. - KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", type).newInstance(null); - - spi.engineLoad(null, PWD); - spi.engineSetKeyEntry(alias, keyPair().getPrivate(), PWD, null); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - spi.engineStore(out, PWD); - store.load(new ByteArrayInputStream(out.toByteArray()), PWD); - - return store; - } - - /** - * @param keyStore Store with exactly one private key. - * @return Its certificate chain in the form certificate metadata uses. - */ - public static List chain(KeyStore keyStore) throws Exception { - List chain = null; - - for (String alias : Collections.list(keyStore.aliases())) { - if (!keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) - continue; - - if (chain != null) - throw new IllegalArgumentException("The test key store must contain exactly one private key"); - - Certificate[] certs = keyStore.getCertificateChain(alias); - - chain = List.of(Arrays.copyOf(certs, certs.length, X509Certificate[].class)); - } - - if (chain == null) - throw new IllegalArgumentException("The test key store must contain exactly one private key"); - - return chain; - } - - /** - * @param subj Subject. - * @param subjKeys Key pair of the subject. - * @param notBefore Time the certificate becomes valid. - * @param notAfter Time it expires. - * @param ca Whether the certificate is the authority's own. - * @return Certificate signed by this authority. - */ - private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { - JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( - name, - BigInteger.valueOf(serial.incrementAndGet()), - new Date(notBefore), - new Date(notAfter), - subj, - subjKeys.getPublic()); - - if (ca) - builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); - else - builder.addExtension(Extension.extendedKeyUsage, false, - new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})); - - ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()); - - return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); - } - - /** */ - private static KeyPair keyPair() throws Exception { - KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); - - gen.initialize(new ECGenParameterSpec("secp256r1")); - - return gen.generateKeyPair(); - } - - /** */ - private static KeyStore emptyStore() throws Exception { - KeyStore store = KeyStore.getInstance("JKS"); - - store.load(null, PWD); - - return store; - } -} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index eb9e1015b0d44..13e9204fd95f8 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -81,7 +81,6 @@ import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; import org.apache.ignite.ssl.SslContextMetadataTest; -import org.apache.ignite.ssl.SslContextReuseTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -146,7 +145,6 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextMetadataTest.class, - SslContextReuseTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, diff --git a/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs b/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs index c82230ec08062..8b7a253a09b96 100644 --- a/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs +++ b/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs @@ -112,14 +112,14 @@ public void TestConfigurationExceptions() TrustStoreFilePath, Password); ex = Assert.Throws(() => Ignition.Start(cfg)); - StringAssert.StartsWith(@"Failed to initialize key store (I/O error occurred): " + KeyStoreFilePath, + Assert.AreEqual(@"Failed to initialize key store (I/O error occurred): Config/KeyStore/server.jks", ex.Message); cfg.SslContextFactory = new SslContextFactory(KeyStoreFilePath, Password, TrustStoreFilePath, "654321"); ex = Assert.Throws(() => Ignition.Start(cfg)); - StringAssert.StartsWith(@"Failed to initialize key store (I/O error occurred): " + TrustStoreFilePath, + Assert.AreEqual(@"Failed to initialize key store (I/O error occurred): Config/KeyStore/trust.jks", ex.Message); } diff --git a/parent/pom.xml b/parent/pom.xml index 48624ec8905de..17318b8d34efb 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -68,7 +68,6 @@ false - 1.86 1.23.0 3.2.2 2.22.0 From a9e61a834c2528288c64957ce39dfbfd46c5496b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 21:23:11 +0300 Subject: [PATCH 124/242] IGNITE-29210 Remove the obsolete connector import --- .../org/apache/ignite/internal/ssl/SslContextProviderTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index c4919aead2573..6bee0ed308e65 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -43,7 +43,6 @@ import org.junit.Test; import static org.apache.ignite.internal.ssl.SslContextRegistry.BINARY_REST; -import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; import static org.apache.ignite.testframework.GridTestUtils.assertContains; From 048e40efdf11b8e03aa7b0502b5bb36251110b8e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 21:24:58 +0300 Subject: [PATCH 125/242] IGNITE-29210 Keep reconnect test independent of certificate generation --- .../test/java/org/apache/ignite/ssl/SslContextReuseTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java index b86b8eebfbd6c..6d1060074d51f 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -20,12 +20,10 @@ import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; import org.apache.ignite.testframework.GridTestUtils; -import org.apache.ignite.testframework.TestCertificateAuthority; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; From f4e1d285a679195c758f771490087e9f06346018 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 4 Nov 2026 21:25:51 +0300 Subject: [PATCH 126/242] IGNITE-29211 Keep renewal inside the node and verify its lifecycle --- .../_docs/monitoring-metrics/new-metrics.adoc | 4 +- docs/_docs/security/ssl-tls.adoc | 23 +- docs/_docs/tools/control-script.adoc | 2 +- .../ignite/internal/ssl/SslFactoryAccess.java | 65 ++-- .../ignite/internal/ssl/SslRenewal.java | 39 +- .../ignite/ssl/AbstractSslContextFactory.java | 91 +++-- .../management/ssl/SslStatusTask.java | 2 +- .../internal/ssl/SslContextProvider.java | 10 +- .../internal/ssl/SslContextProviderTest.java | 5 +- .../internal/ssl/SslRenewalNodeTest.java | 131 +++---- .../ignite/internal/ssl/SslRenewalTest.java | 346 ++++++++---------- 11 files changed, 335 insertions(+), 383 deletions(-) diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 75152fe6c9ca4..e93f9a03f015c 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -523,9 +523,9 @@ Register name: `ssl` |ConsecutiveRenewalFailures | integer | Sum of consecutive unsuccessful automatic renewal attempts across the node's SSL context factories. An unchanged certificate chain, a loading error or a rejected certificate counts as an unsuccessful attempt. A shared factory is counted once. |=== -When automatic renewal or manual reload moves the earliest expiry across all of a factory's private-key chains forward, that factory's count resets to zero. CA certificates also count toward that earliest expiry. Other factories' counts stay unchanged. Failed manual reloads do not increase the metric. +When automatic renewal or manual reload moves the earliest expiry across all of a factory's reported certificate chains forward, that factory's count resets to zero. CA certificates also count toward that earliest expiry. Failed manual reloads do not increase the metric. -Nodes without automatic renewal report zero. +A factory that has never attempted renewal contributes zero. Reusing a factory after a node restart retains its count, even if renewal is disabled for the new node. == Discovery IO diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 70c171a167fc2..c985d5568c18a 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -260,10 +260,11 @@ For manual file replacement, use <> and run reload after === Prepare Certificate Delivery -Renewal follows the first certificate to expire across the TLS certificate chains loaded by the factory, including CA certificates. -For that chain, the renewal window is the last share of its lifetime set by the factory property `renewBeforeFraction`. The lifetime runs from the leaf certificate's start date to the earliest expiry in the chain. Ignite starts trying to load replacement certificates when the renewal window opens. If several chains expire at exactly the same time, renewal starts when the first of their windows opens. +Renewal follows the first certificate to expire in the TLS chains of all key entries, including their CA certificates. Certificates found only in the trust store do not determine the renewal time. +For that chain, the renewal window is the last part of its lifetime (15% by default), set with `renewBeforeFraction`. The lifetime runs from the leaf certificate's start date to the earliest expiry in the chain. Ignite starts trying to load replacement certificates when the renewal window opens. If several chains expire at exactly the same time, renewal starts when the first of their windows opens. -The delivery system must make a valid replacement available before the window opens. Ignite reads the files at each scheduled attempt, not when they change. +Deliver the replacement before the window opens. A file-based factory picks up a later delivery on the next scheduled attempt. +With `SslContextFactory`, protect each private key with the key store password. A PKIX key manager may first try to open a key during a handshake, after renewal has already accepted the store. The replacement must move that earliest expiry forward. Renewing only one key is insufficient if another chain still expires at the original time. If a CA certificate expires first, replace it too. Provide at least one private key with its X.509 certificate chain. Entries whose chains are missing, empty or contain a non-X.509 certificate are omitted from certificate status and renewal scheduling. A custom factory must supply the TLS certificate chains used by its context. @@ -291,7 +292,7 @@ Configure the factory used by each connection type: |The node's factory, or `ConnectorConfiguration.sslFactory` |=== -Connection types that share one factory renew together. +Connection types on one node that share a factory renew together. Use a separate factory instance for each node with renewal enabled. To change how early renewal starts, set `renewBeforeFraction` as described in the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#setRenewBeforeFraction(double)[AbstractSslContextFactory Javadoc]. Changes to these settings take effect after a node restart. @@ -299,7 +300,7 @@ To change how early renewal starts, set `renewBeforeFraction` as described in th Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and the next attempt time. -If the source still returns the current certificate, or loading the replacement fails, the node keeps its current context and retries. +If the source still returns the current certificates or the replacement fails validation or loading, the node keeps its current context. It retries after 60 to 90 seconds, then at increasing intervals of up to 90 minutes. Retries continue after expiry. The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] metric counts these unsuccessful attempts. Check the node log for the cause, fix certificate delivery or configuration, and use reload if you need to apply the correction immediately. == SslContextFactory Properties @@ -314,6 +315,14 @@ The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] me |The key manager algorithm that will be used to create a key manager. |`SunX509` +|`renewalEnabled` +|Whether the node loads replacement certificates automatically. See <>. +|`false` + +|`renewBeforeFraction` +|Share of the certificate lifetime left when renewal starts. Must be greater than 0 and less than 1. +|`0.15` + |`keyStoreFilePath` |The path to the key store file. This is a mandatory parameter since the SSL context can not be initialized without a key manager. |`N/A` @@ -373,9 +382,9 @@ The bundle lists nonempty X.509 certificate chains associated with key entries, An empty map means no such chains were found, and node reload rejects it. Use `null` when certificate metadata is unknown. Every list in a supplied map must be nonempty. With automatic renewal enabled, the bundle must contain at least one X.509 chain at startup and on each replacement. Without renewal, metadata is optional. -The factory serializes initialization and renewal and owns their outcome and failure counters. A direct call to `init()` also replans automatic renewal. Bound remote requests and respond to thread interruption so that stopping the node can stop a renewal attempt. +Initialization and automatic renewal call `createSslBundle()` one at a time on the same factory. A successful reload schedules renewal from the new material. +One thread renews all factories on a node. Stopping the node interrupts a running attempt and waits for it to finish. Set timeouts on remote requests and respond to interruption so a slow source does not delay other factories or shutdown. -Automatic renewal uses the node's executor. Once that executor stops, the same factory can attach to the restarted node. See the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#init()[initialization contract] for the validation applied at startup and on subsequent calls. === Migrating an Existing Factory diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index afb96bf7e59b3..aa4fed9973ad9 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1801,7 +1801,7 @@ Use this command after a replacement or when investigating a reload or automatic control.sh|bat --ssl status ---- -For each factory, the output lists its transports and last reload result. Each reported certificate chain has a `loaded` line identified by its key alias. +For each factory, the output lists its transports and last reload result, including automatic renewals. Each reported certificate chain has a `loaded` line identified by its key alias. If automatic renewal is scheduled, it also shows the next attempt time: [source,text] diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java index 47d9d6e9162cb..3808283d449d0 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java @@ -17,37 +17,52 @@ package org.apache.ignite.internal.ssl; -import java.util.function.Function; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Supplier; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteLogger; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslBundle; +import org.jetbrains.annotations.Nullable; -/** Access to the factory's key material for node status and reload. */ -public final class SslFactoryAccess { - /** */ - private final Function current; +/** Access to a factory's accepted material and renewal state for node transports and management. */ +public abstract class SslFactoryAccess { + /** Reads the current material, initializing it on first use. */ + protected abstract SslBundle current(AbstractSslContextFactory factory); - /** */ - private final Function reload; + /** Loads and publishes a replacement. */ + protected abstract SslBundle reload(AbstractSslContextFactory factory); - /** - * @param current Reads the accepted material. - * @param reload Loads and publishes replacement material. - */ - public SslFactoryAccess( - Function current, - Function reload - ) { - this.current = current; - this.reload = reload; - } + /** Reads the outcome of the last reload. */ + protected abstract ReloadOutcome reloadOutcome(AbstractSslContextFactory factory); - /** @return The factory's accepted material, initialized on first use. */ - SslBundle current(AbstractSslContextFactory factory) { - return current.apply(factory); - } + /** Attaches this node's renewal executor. */ + protected abstract void startRenewal( + AbstractSslContextFactory factory, + ScheduledExecutorService exec, + IgniteLogger log, + Supplier transports + ); + + /** Renews the material if the scheduled context is still current. */ + protected abstract @Nullable SslBundle renew(AbstractSslContextFactory factory, SSLContext expected) throws IgniteCheckedException; - /** @return Fresh material accepted by the factory. */ - SslBundle reload(AbstractSslContextFactory factory) { - return reload.apply(factory); + /** Reads the unsuccessful attempt count. */ + protected abstract int renewalFailures(AbstractSslContextFactory factory); + + /** Reads the time of the next planned attempt. */ + protected abstract long nextRenewalTime(AbstractSslContextFactory factory); + + /** + * Snapshot of the last successful reload and subsequent failures. + * + * @param successTime Time of the last successful reload, {@code 0} if there was none. + * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. + * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. + * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. + */ + public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { + // No-op. } } diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java index e7d74d419b979..c20ef449a4c17 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -46,16 +46,11 @@ public class SslRenewal { /** How the node log names an automatic renewal in {@code initiator=}. */ private static final String INITIATOR = "automatic renewal"; - /** - * Interval between warnings that the factory has no newer certificates. A change in expiry status bypasses this interval. - */ - private static final long WAIT_LOG_INTERVAL = TimeUnit.DAYS.toMillis(1); - /** Initial retry pause and minimum interval between renewal attempts, in milliseconds. */ - private static long minPause = 60_000L; + private static final long MIN_PAUSE = TimeUnit.MINUTES.toMillis(1); /** Maximum retry pause before adding jitter, in milliseconds. */ - private static long maxPause = 3_600_000L; + private static final long MAX_PAUSE = TimeUnit.HOURS.toMillis(1); /** */ private final AbstractSslContextFactory factory; @@ -90,12 +85,6 @@ public class SslRenewal { /** Current retry pause before adding jitter; {@link #plan()} resets it to {@code 0}. */ private long pause; - /** When the node last logged that the factory has no newer certificates; {@link #plan()} resets it to {@code 0}. */ - private long waitLogTime; - - /** How late the certificates in use were when that was logged: {@code 0} in time, {@code 1} expiring soon, {@code 2} expired. */ - private int waitLoggedLateness; - /** * @param factory Factory that owns the accepted material. * @param renewBeforeFraction Share of the certificate lifetime left when the renewal window opens. @@ -149,7 +138,7 @@ public void replan() { /** Plans renewal from the first chain to expire, with the earliest window breaking ties. */ private void plan() { - pause = waitLogTime = 0; + pause = 0; SslBundle planned = NODE_ACCESS.current(factory); List chain = SslCertificates.renewalChain(planned); @@ -161,7 +150,7 @@ private void plan() { window = (long)(lifetime * renewBeforeFraction); long at = Math.max(chainNotAfter - window, - Math.max(factory.reloadOutcome().successTime() + minPause, currentTimeMillis.getAsLong())); + Math.max(NODE_ACCESS.reloadOutcome(factory).successTime() + MIN_PAUSE, currentTimeMillis.getAsLong())); schedule(at, planned.context()); @@ -176,7 +165,7 @@ private void plan() { */ private void attempt(SSLContext planned) { try { - SslBundle renewed = factory.renew(planned); + SslBundle renewed = NODE_ACCESS.renew(factory, planned); if (renewed != null) { CommonUtils.log(log, "TLS certificates reloaded [transports=" + transports.get() + ", " + @@ -191,8 +180,7 @@ else if (factory.create() == planned) } /** - * Schedules a retry with exponential backoff and logs why renewal did not succeed. Exceptions are logged on every attempt. - * Unchanged certificates are logged once a day, or sooner if an attempt finds they are close to expiry or expired. + * Schedules a retry with exponential backoff and logs why renewal did not succeed. * Does nothing during shutdown, since stopping the node interrupts the current attempt. * * @param planned Context the attempt was planned for. @@ -204,26 +192,17 @@ private void retry(SSLContext planned, @Nullable Throwable e) { long now = currentTimeMillis.getAsLong(); - pause = pause == 0 ? minPause : Math.min(pause * 2, maxPause); + pause = pause == 0 ? MIN_PAUSE : Math.min(pause * 2, MAX_PAUSE); long at = now + pause + ThreadLocalRandom.current().nextLong(pause / 2 + 1); schedule(at, planned); boolean soon = now >= chainNotAfter - window / 2 || at >= chainNotAfter; - int lateness = now >= chainNotAfter ? 2 : soon ? 1 : 0; - - if (e == null) { - if (now - waitLogTime < WAIT_LOG_INTERVAL && lateness <= waitLoggedLateness) - return; - - waitLogTime = now; - waitLoggedLateness = lateness; - } String msg = (e == null ? "The SSL context factory has no newer TLS certificates yet, the ones in use stay" : "Failed to reload TLS certificates, the ones in use stay") + - (lateness == 2 ? ", though they have expired" : lateness == 1 ? " and expire soon" : "") + + (now >= chainNotAfter ? ", though they have expired" : soon ? " and expire soon" : "") + " [transports=" + transports.get() + (e == null ? "" : ", initiator=" + INITIATOR + ", reason=" + SslCertificates.reason(e)) + ", chainNotAfter=" + Instant.ofEpochMilli(chainNotAfter) + ", nextRenewal=" + Instant.ofEpochMilli(at) + ']'; @@ -251,7 +230,7 @@ private void schedule(long at, SSLContext planned) { } /** - * Runs the task in the renewal thread under the node's operation context. This prevents automatic renewal from inheriting + * Runs the renewal task with an empty operation context. This prevents automatic renewal from inheriting * the security subject of an operator who ran {@code --ssl reload}. * * @param task Task. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index d30a00af54071..399e7a57a1222 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -47,6 +47,7 @@ import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.ssl.SslFactoryAccess; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.ssl.SslRenewal; import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.A; @@ -73,8 +74,50 @@ public abstract class AbstractSslContextFactory implements Factory { * Gives the node access to the accepted material and the same reload path used by subclasses. * @hidden */ - public static final SslFactoryAccess NODE_ACCESS = - new SslFactoryAccess(AbstractSslContextFactory::sslBundle, AbstractSslContextFactory::init); + public static final SslFactoryAccess NODE_ACCESS = new SslFactoryAccess() { + /** {@inheritDoc} */ + @Override protected SslBundle current(AbstractSslContextFactory factory) { + return factory.sslBundle(); + } + + /** {@inheritDoc} */ + @Override protected SslBundle reload(AbstractSslContextFactory factory) { + return factory.init(); + } + + /** {@inheritDoc} */ + @Override protected ReloadOutcome reloadOutcome(AbstractSslContextFactory factory) { + return factory.reloadOutcome(); + } + + /** {@inheritDoc} */ + @Override protected void startRenewal( + AbstractSslContextFactory factory, + ScheduledExecutorService exec, + IgniteLogger log, + Supplier transports + ) { + factory.startRenewal(exec, log, transports); + } + + /** {@inheritDoc} */ + @Override protected @Nullable SslBundle renew( + AbstractSslContextFactory factory, + SSLContext expected + ) throws IgniteCheckedException { + return factory.renew(expected); + } + + /** {@inheritDoc} */ + @Override protected int renewalFailures(AbstractSslContextFactory factory) { + return factory.renewalFailures(); + } + + /** {@inheritDoc} */ + @Override protected long nextRenewalTime(AbstractSslContextFactory factory) { + return factory.nextRenewalTime(); + } + }; /** Accepted context and the metadata loaded with it. Runtime state is not serialized with configuration. */ private transient volatile SslBundle bundle; @@ -88,7 +131,7 @@ public abstract class AbstractSslContextFactory implements Factory { /** Executor whose attachment fixes the renewal settings until that node stops. */ private transient ScheduledExecutorService renewalExec; - /** Failed automatic attempts since the earliest expiry across all chains was advanced. */ + /** Unsuccessful automatic attempts since the earliest expiry across all chains was advanced. */ private transient volatile int renewalFailures; /** SSL protocol. */ @@ -171,7 +214,7 @@ public boolean isRenewalEnabled() { * Sets whether a node calls {@link #createSslBundle()} before the certificates expire to pick up replacements. * A file-based factory rereads its files. It does not replace them. * When enabled, the factory must report at least one X.509 certificate chain, or the node does not start. - * Renewal considers all reported chains. Disabled by default. + * With renewal enabled, use a separate factory instance for each running node. Disabled by default. * Set this before starting the node; changing it on a running node takes effect after a restart. * * @param renewalEnabled Whether renewal is enabled. @@ -374,8 +417,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException * The first initialization permits material without certificate chains and certificates outside their validity period, * matching node startup. Later calls reject a context already in use, known empty certificate metadata and chains with * any expired or not-yet-valid certificate. Peers must trust the replacement; this method does not test connections. - * While automatic renewal is attached, replacement metadata must contain at least one chain. A successful replacement - * replans renewal and clears its unsuccessful-attempt count only when the earliest chain expiry advances. + * While automatic renewal is attached, replacement metadata must contain at least one chain. Every successful replacement + * replans renewal. The failure count clears only when the earliest chain expiry advances. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. @@ -388,7 +431,7 @@ protected final synchronized SslBundle init() { SslBundle next = reload ? buildReplacement() : prepareSslBundle(); if (reload) - checkReplacement(next, renewal != null); + checkReplacement(next, renewal != null && !renewalExec.isShutdown()); putInUse(next); @@ -416,12 +459,12 @@ protected final synchronized SslBundle init() { * @param transports Names of the transports currently using this factory, evaluated when logging. * @throws IgniteException If another running node owns renewal, the renewal window is invalid or certificate metadata is unavailable. */ - public final synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { + private synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { boolean enabled = isRenewalEnabled(); if (renewalExec != null && !renewalExec.isShutdown()) { if (renewalExec != exec && (renewal != null || enabled)) - throw new IgniteException("The SSL context factory already renews certificates for another running node; " + + throw new IgniteException("The SSL context factory is already used by another running node; " + "configure a separate factory instance on each node"); return; @@ -455,7 +498,7 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit /** * Installs replacement material only if its earliest chain expiry is later than the current earliest expiry. * Unchanged chains count as an unsuccessful renewal without recording a reload failure. An interrupted attempt retains - * the current material and does not count as unsuccessful. The node's renewal scheduler calls this method. + * the current material and does not count as unsuccessful. * * @param expected Context in use when the renewal was planned. * @return Installed bundle, or {@code null} if its chains are unchanged or the expected context has already been replaced. @@ -463,7 +506,7 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit * the earliest expiry across all chains. * @throws IgniteCheckedException If renewal is interrupted; the interrupt flag stays set when the factory sets it. */ - public final synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { + private synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { if (bundle == null || bundle.context() != expected) return null; @@ -487,8 +530,8 @@ public final synchronized void startRenewal(ScheduledExecutorService exec, Ignit long nextExpiry = SslCertificates.chainNotAfter(SslCertificates.renewalChain(next)); if (cur != null && nextExpiry <= SslCertificates.chainNotAfter(cur)) { - throw new IgniteException("The new certificate chains expire no later than the ones in use; if a certificate " + - "authority expires first, renew it [chainNotAfter=" + Instant.ofEpochMilli(nextExpiry) + + throw new IgniteException("The new certificate chains expire no later than the ones in use. " + + "Every replacement chain must outlast the current earliest expiry [chainNotAfter=" + Instant.ofEpochMilli(nextExpiry) + ", inUseChainNotAfter=" + Instant.ofEpochMilli(SslCertificates.chainNotAfter(cur)) + ']'); } @@ -515,13 +558,13 @@ private boolean renewalInterrupted() { return Thread.currentThread().isInterrupted() || (renewalExec != null && renewalExec.isShutdown()); } - /** @return Failed automatic attempts since the earliest expiry across all chains was advanced. */ - public final int renewalFailures() { + /** @return Unsuccessful automatic attempts since the earliest expiry across all chains was advanced. */ + private int renewalFailures() { return renewalFailures; } /** @return Time of the next automatic renewal, {@code 0} if none is planned or the node has stopped. */ - public final long nextRenewalTime() { + private long nextRenewalTime() { SslRenewal cur = renewal; return cur == null ? 0 : cur.nextRenewalTime(); @@ -554,7 +597,7 @@ private void checkReplacement(SslBundle next, boolean requireChains) { Map> chains = next.certificateChains(); if (requireChains && chains == null) { - throw new IgniteException("The new SSL context must provide at least one X.509 certificate chain " + + throw new IgniteException("The replacement must provide at least one X.509 certificate chain " + "to renew automatically"); } @@ -602,24 +645,12 @@ private void onFailure(Throwable e) { } /** @return Snapshot of the last successful reload and subsequent failures; zero times before any reload. */ - public final ReloadOutcome reloadOutcome() { + private ReloadOutcome reloadOutcome() { ReloadOutcome res = outcome; return res == null ? new ReloadOutcome(0, 0, null, 0) : res; } - /** - * Snapshot of the last successful reload and subsequent failures. - * - * @param successTime Time of the last successful reload, {@code 0} if there was none. - * @param failureTime Time of the last failed reload since the last successful one, {@code 0} if there was none. - * @param failureReason Reason of the last failed reload since the last successful one, {@code null} if there was none. - * @param failures Failed reloads in a row since the last successful one, {@code 0} if there were none. - */ - public record ReloadOutcome(long successTime, long failureTime, @Nullable String failureReason, int failures) { - // No-op. - } - /** * Returns the accepted context and its metadata, initializing the factory on first use. * diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java index b6b92f002d18a..a4813627914c7 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslStatusTask.java @@ -28,9 +28,9 @@ import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.processors.task.GridInternal; import org.apache.ignite.internal.ssl.SslContextProvider; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.visor.VisorJob; import org.apache.ignite.lang.IgniteBiTuple; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import static org.apache.ignite.internal.management.api.CommandUtils.DOUBLE_INDENT; import static org.apache.ignite.internal.ssl.SslCertificates.chainNotAfter; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 42ad12965dfcd..1e08cc2745f64 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -26,8 +26,8 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteLogger; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.jetbrains.annotations.Nullable; @@ -97,17 +97,17 @@ public String transports() { * @param log Renewal logger. */ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { - sslFactory.startRenewal(exec, log, this::transports); + NODE_ACCESS.startRenewal(sslFactory, exec, log, this::transports); } /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ public long nextRenewalTime() { - return sslFactory.nextRenewalTime(); + return NODE_ACCESS.nextRenewalTime(sslFactory); } /** @return Unsuccessful automatic attempts since the earliest chain expiry advanced. */ int renewalFailures() { - return sslFactory.renewalFailures(); + return NODE_ACCESS.renewalFailures(sslFactory); } /** @return Context and certificate metadata accepted by the factory's initialization. */ @@ -117,6 +117,6 @@ public SslBundle reload() { /** @return Snapshot of the last successful reload and subsequent failures. */ public ReloadOutcome reloadOutcome() { - return sslFactory.reloadOutcome(); + return NODE_ACCESS.reloadOutcome(sslFactory); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 29ab63b9fbe5a..ea3dabbe6510a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -32,7 +32,7 @@ import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; @@ -48,6 +48,7 @@ import static org.apache.ignite.internal.ssl.SslContextRegistry.CLIENT_CONNECTOR; import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.AbstractSslContextFactory.NODE_ACCESS; import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.TestCertificateAuthority.bundle; import static org.apache.ignite.testframework.TestCertificateAuthority.chain; @@ -154,7 +155,7 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { assertArrayEquals(nextParams.getProtocols(), current.getEnabledProtocols()); } - assertSame(f.reloadOutcome(), p.reloadOutcome()); + assertSame(NODE_ACCESS.reloadOutcome(f), p.reloadOutcome()); assertTrue(p.reloadOutcome().successTime() > 0); } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index f627b98fd7003..8940c06561f07 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -21,7 +21,6 @@ import java.nio.file.Path; import java.security.KeyStore; import java.time.Instant; -import java.util.Iterator; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -29,11 +28,9 @@ import org.apache.ignite.IgniteException; import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.metric.IntMetric; -import org.apache.ignite.spi.metric.Metric; -import org.apache.ignite.spi.metric.ReadOnlyMetricRegistry; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; @@ -45,6 +42,7 @@ import static org.apache.ignite.internal.ssl.SslContextRegistry.COMMUNICATION; import static org.apache.ignite.internal.ssl.SslContextRegistry.DISCOVERY; +import static org.apache.ignite.ssl.AbstractSslContextFactory.NODE_ACCESS; import static org.apache.ignite.ssl.SslTestUtils.discoveryPort; import static org.apache.ignite.ssl.SslTestUtils.servedCertificate; import static org.apache.ignite.ssl.SslTestUtils.status; @@ -55,11 +53,7 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.save; /** - * Renewal on a node: a factory without certificate metadata stops the node from starting with renewal; the node exposes the sum of - * unsuccessful renewals of its factories as the only SSL metric; stopping the node interrupts an attempt without a failure and - * stops the renewal thread; the file-based factory puts a replaced key store in use with no command. Nodes run - * {@link SslContextFactory} on key store files of the test and trust the test authority. Nodes that run renewal start on - * a certificate with 10 of its 120 minutes left, inside the default window of 18 minutes, so the first attempt comes at once. + * Tests renewal with running nodes, including certificate delivery over discovery, restart, shutdown and node metrics. */ public class SslRenewalNodeTest extends GridCommonAbstractTest { /** */ @@ -71,9 +65,6 @@ public class SslRenewalNodeTest extends GridCommonAbstractTest { /** */ private static final long HOUR = 60 * MINUTE; - /** How renewal logs an attempt that got the chains in use, before the expiry state. */ - private static final String WAITING = "The SSL context factory has no newer TLS certificates yet, the ones in use stay"; - /** Authority of the test certificates. */ private TestCertificateAuthority ca; @@ -105,18 +96,13 @@ public class SslRenewalNodeTest extends GridCommonAbstractTest { } } - /** A node whose factory enables renewal but gives no certificate metadata does not start. */ + /** A node configured to renew certificates must have a chain whose expiry can be monitored. */ @Test - public void testUnknownCertificateRefusedAtStart() throws Exception { + public void testNodeWithoutChainsRefusesRenewal() throws Exception { Path keys = dir.resolve("node.jks"); - SslContextFactory factory = readingKeys(new SslContextFactory() { - /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() throws SSLException { - return new SslBundle(super.createSslBundle().context(), null); - } - }, keys); + SslContextFactory factory = readingKeys(new SslContextFactory(), keys); - save(ca.keyStoreWithPrivateKey("node"), keys); + save(ca.trustStore(), keys); factory.setRenewalEnabled(true); GridTestUtils.assertThrowsAnyCause(log, () -> startGrid(0, cfg -> { @@ -124,48 +110,33 @@ public void testUnknownCertificateRefusedAtStart() throws Exception { }), IgniteException.class, "The SSL context factory must provide at least one X.509 certificate chain"); } - /** - * The node exposes the unsuccessful renewals of all its factories as {@code ssl.ConsecutiveRenewalFailures}, the only metric - * of the {@code ssl} registry; a renewal clears the count of its factory only. Renewal is off, so that renewal attempts of the - * node do not change the counts while the test renews by hand; the node then plans no renewal and runs no renewal thread. - */ + /** The node counts automatic attempts across distinct factories. Advancing one factory's expiry clears only its count. */ @Test public void testRenewalFailuresMetric() throws Exception { long now = System.currentTimeMillis(); Path nodeKeys = dir.resolve("node.jks"); Path clientKeys = dir.resolve("client-connector.jks"); - KeyStore inUse = ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR); + KeyStore inWindow = ca.keyStoreWithPrivateKey("in-window", now - 110 * MINUTE, now + 10 * MINUTE); SslContextFactory nodeFactory = readingKeys(new SslContextFactory(), nodeKeys); SslContextFactory clientFactory = readingKeys(new SslContextFactory(), clientKeys); - save(inUse, nodeKeys); - save(inUse, clientKeys); + nodeFactory.setRenewalEnabled(true); + clientFactory.setRenewalEnabled(true); + save(inWindow, nodeKeys); + save(inWindow, clientKeys); IgniteEx g = startGrid(0, cfg -> { cfg.setSslContextFactory(nodeFactory).setClientConnectorConfiguration(new ClientConnectorConfiguration() .setSslEnabled(true).setSslClientAuth(false).setUseIgniteSslContextFactory(false).setSslContextFactory(clientFactory)); }); - SslContextProvider node = provider(g, COMMUNICATION); - ReadOnlyMetricRegistry ssl = g.context().metric().registry("ssl"); - Iterator metrics = ssl.iterator(); - IntMetric failures = ssl.findMetric("ConsecutiveRenewalFailures"); + IntMetric failures = g.context().metric().registry("ssl").findMetric("ConsecutiveRenewalFailures"); - assertEquals("ssl.ConsecutiveRenewalFailures", metrics.next().name()); - assertFalse(metrics.hasNext()); - - nodeFactory.renew(nodeFactory.create()); - clientFactory.renew(clientFactory.create()); - clientFactory.renew(clientFactory.create()); - - assertEquals("Both factories", 3, failures.value()); + assertTrue("One automatic attempt per factory", waitForCondition(() -> failures.value() == 2, 10_000)); save(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), nodeKeys); + provider(g, COMMUNICATION).reload(); - nodeFactory.renew(nodeFactory.create()); - - assertEquals("Node factory renewed", 2, failures.value()); - assertEquals(0, node.nextRenewalTime()); - assertFalse(renewalThreadAlive(g.name())); + assertEquals("Only the client connector is still waiting", 1, failures.value()); } /** @@ -231,11 +202,11 @@ public void testSameFactoryRenewsAfterNodeRestart() throws Exception { cfg.setSslContextFactory(factory); }); - assertTrue(waitForCondition(() -> factory.renewalFailures() > 0, 10_000)); + assertTrue(waitForCondition(() -> NODE_ACCESS.renewalFailures(factory) > 0, 10_000)); stopGrid(0); - assertEquals(0, factory.nextRenewalTime()); + assertEquals(0, NODE_ACCESS.nextRenewalTime(factory)); save(replacement, keys); @@ -246,60 +217,64 @@ public void testSameFactoryRenewsAfterNodeRestart() throws Exception { SslContextProvider discovery = provider(restarted, DISCOVERY); assertTrue(waitForCondition(() -> discovery.certificateChains().containsKey("replacement"), 10_000)); - assertEquals(0, factory.renewalFailures()); + assertEquals(0, NODE_ACCESS.renewalFailures(factory)); assertEquals(chain(replacement).get(0), servedCertificate(context(ca.keyStoreWithPrivateKey("probe"), ca.trustStore()), discoveryPort(restarted))); assertTrue(renewalThreadAlive(restarted.name())); } - /** - * The file-based factory with renewal enabled waits while its key store file holds the certificate in use and puts the file - * replaced on disk in use with no command: the node logs the renewal, new discovery connections get the new certificate, and - * status shows the next renewal at the default window of the new certificate, 18 minutes before its expiry. - */ + /** The first renewal reads the replacement file. Discovery serves its certificate and status shows its next renewal. */ @Test public void testReplacedKeyStoreFileRenewed() throws Exception { - // X.509 keeps whole seconds. long now = System.currentTimeMillis() / SECOND * SECOND; Path keys = dir.resolve("node.jks"); + CountDownLatch transportsReady = new CountDownLatch(1); KeyStore renewed = ca.keyStoreWithPrivateKey("renewed", now, now + 2 * HOUR); long nextRenewal = now + 2 * HOUR - 18 * MINUTE; - SslContextFactory factory = readingKeys(new SslContextFactory(), keys); - long minPause = GridTestUtils.getFieldValue(SslRenewal.class, "minPause"); - long maxPause = GridTestUtils.getFieldValue(SslRenewal.class, "maxPause"); + SslContextFactory factory = readingKeys(new SslContextFactory() { + /** {@inheritDoc} */ + @Override protected SslBundle createSslBundle() throws SSLException { + if (Thread.currentThread().getName().startsWith("ssl-renewal")) { + try { + assertTrue(transportsReady.await(10, TimeUnit.SECONDS)); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + + throw new SSLException(e); + } + } + + return super.createSslBundle(); + } + }, keys); save(ca.keyStoreWithPrivateKey("in-window", now - 110 * MINUTE, now + 10 * MINUTE), keys); factory.setRenewalEnabled(true); - // Retries at least a minute apart would make the test wait that long for the replaced file. - GridTestUtils.setFieldValue(SslRenewal.class, "minPause", 100L); - GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", 1_000L); + IgniteEx g; try { - IgniteEx g = startGrid(0, cfg -> { + g = startGrid(0, cfg -> { cfg.setSslContextFactory(factory); }); - SslContextProvider p = provider(g, COMMUNICATION); - - assertTrue(waitForCondition(() -> logged(Level.WARN).stream().anyMatch(msg -> msg.startsWith(WAITING)), 10_000)); save(renewed, keys); - - assertTrue(waitForCondition(() -> logged(Level.INFO).stream().anyMatch(msg -> msg.startsWith("TLS certificates reloaded " + - "[transports=" + COMMUNICATION + ", " + DISCOVERY + ", alias=renewed, subject=CN=renewed, ") && - msg.endsWith(", initiator=automatic renewal]")), 10_000)); - - assertEquals(chain(renewed).get(0), servedCertificate(context(ca.keyStoreWithPrivateKey("probe"), ca.trustStore()), - discoveryPort(g))); - - // The renewal thread plans the next attempt after it logs the renewal. - assertTrue(waitForCondition(() -> p.nextRenewalTime() == nextRenewal, 10_000)); - assertContains(log, status(g), "next automatic renewal at " + Instant.ofEpochMilli(nextRenewal)); } finally { - GridTestUtils.setFieldValue(SslRenewal.class, "minPause", minPause); - GridTestUtils.setFieldValue(SslRenewal.class, "maxPause", maxPause); + transportsReady.countDown(); } + + assertTrue(waitForCondition(() -> logged(Level.INFO).stream().anyMatch(msg -> msg.startsWith("TLS certificates reloaded " + + "[transports=" + COMMUNICATION + ", " + DISCOVERY + ", alias=renewed, subject=CN=renewed, ") && + msg.endsWith(", initiator=automatic renewal]")), 10_000)); + assertEquals(chain(renewed).get(0), servedCertificate(context(ca.keyStoreWithPrivateKey("probe"), ca.trustStore()), + discoveryPort(g))); + + SslContextProvider p = provider(g, COMMUNICATION); + + assertTrue(waitForCondition(() -> p.nextRenewalTime() == nextRenewal, 10_000)); + assertContains(log, status(g), "next automatic renewal at " + Instant.ofEpochMilli(nextRenewal)); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index f13801fb4ccd9..725f4032141fa 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -28,21 +28,21 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Delayed; import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; import org.apache.ignite.internal.thread.context.Scope; import org.apache.ignite.ssl.AbstractSslContextFactory; -import org.apache.ignite.ssl.AbstractSslContextFactory.ReloadOutcome; import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; @@ -63,7 +63,6 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.chain; import static org.apache.ignite.testframework.TestCertificateAuthority.context; import static org.apache.ignite.testframework.TestCertificateAuthority.merged; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -73,19 +72,8 @@ import static org.mockito.Mockito.verifyNoInteractions; /** - * Renewal without nodes: its settings, {@link AbstractSslContextFactory#renew(SSLContext)}, the count of unsuccessful renewals and the - * scheduler. Renewal is off by default. renew installs all new chains, even in place of an expired certificate. Its refusals - * follow the order of the checks in the code: it applies the checks of reload, waits while the factory returns the chains in - * use, refuses a cached context with new metadata and refuses chains that do not advance the earliest expiry. It ignores a - * replaced context and changes nothing when interrupted. Reload keeps the metadata renewal needs, renewal needs chains - * to start, and unsuccessful renewals count until the earliest expiry advances, by renewal or reload. The scheduler tries first - * at the window of the earliest chain, then no sooner than a minute after a success, backs off on failures and logs each one, - * replans on reload, drops an outdated attempt and runs without the operator context. The log names the installed certificate, - * sets the level of a failure by the time left and reports a wait once per lateness, again on expiry. Tests enable renewal on - * a factory and script its fresh bundles; pauses are the production ones, a minute to an hour. Renewal plans and attempts in - * tasks of an executor that records them with their delays and runs one only when a test calls {@code runNext()}. The operator - * context test runs on the kind of executor a node uses, and a concurrent initialization test coordinates two threads. The expiry test - * advances the scheduler's clock explicitly. An appender on the logger of {@link SslRenewal} captures what renewal logs. + * Tests certificate replacement and retries on a factory. The recording executor runs scheduled attempts only when the + * test advances it, so certificate validity and retry timing can be exercised without waiting for expiry. */ public class SslRenewalTest extends GridCommonAbstractTest { /** */ @@ -159,7 +147,7 @@ public void testRenewInstallsAllNewChains() throws Exception { doReturn(bundle(merged(old1, old2), ca.trustStore()), renewed).when(f).createSslBundle(); - f.renew(f.create()); + NODE_ACCESS.renew(f, f.create()); assertSame(renewed.context(), f.create()); assertEquals(Map.of("new-1", chain(new1), "new-2", chain(new2)), NODE_ACCESS.current(f).certificateChains()); @@ -176,7 +164,7 @@ public void testRenewalCanRemoveEarliestChain() throws Exception { doReturn(bundle(merged(removed, remaining), ca.trustStore()), replacement).when(f).createSslBundle(); - assertSame(replacement, f.renew(f.create())); + assertSame(replacement, NODE_ACCESS.renew(f, f.create())); assertEquals(Map.of("remaining", chain(remaining)), NODE_ACCESS.current(f).certificateChains()); } @@ -191,7 +179,7 @@ public void testExpiredCertificateRenewed() throws Exception { bundle(ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), ca.trustStore()), bundle(valid, ca.trustStore())).when(f).createSslBundle(); - f.renew(f.create()); + NODE_ACCESS.renew(f, f.create()); assertEquals(Map.of("valid", chain(valid)), NODE_ACCESS.current(f).certificateChains()); } @@ -231,13 +219,13 @@ public void testUnchangedCertificatesAwaited() throws Exception { doReturn(cached).when(caching).createSslBundle(); doReturn(bundle(expired, ca.trustStore()), bundle(expired, ca.trustStore())).when(expiredAgain).createSslBundle(); - assertNull("Cached bundle", caching.renew(caching.create())); - assertEquals("Cached bundle", 1, caching.renewalFailures()); - assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), caching.reloadOutcome()); + assertNull("Cached bundle", NODE_ACCESS.renew(caching, caching.create())); + assertEquals("Cached bundle", 1, NODE_ACCESS.renewalFailures(caching)); + assertEquals("Cached bundle", new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(caching)); - assertNull("Expired chains", expiredAgain.renew(expiredAgain.create())); - assertEquals("Expired chains", 1, expiredAgain.renewalFailures()); - assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), expiredAgain.reloadOutcome()); + assertNull("Expired chains", NODE_ACCESS.renew(expiredAgain, expiredAgain.create())); + assertEquals("Expired chains", 1, NODE_ACCESS.renewalFailures(expiredAgain)); + assertEquals("Expired chains", new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(expiredAgain)); } /** New metadata cannot make the context in use, which a caching factory returns again, serve a renewed certificate. */ @@ -282,26 +270,8 @@ public void testRenewalMustAdvanceEarliestExpiry() throws Exception { assertRenewalRejected("A later leaf from an authority that expires no later", f, reason); } - /** renew planned for a context that a reload has replaced does not call the factory and changes nothing. */ - @Test - public void testRenewForReplacedContextDoesNothing() throws Exception { - TestSslContextFactory f = renewingFactory(QUARTER); - - doReturn( - bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), - bundle(ca.keyStoreWithPrivateKey("reloaded"), ca.trustStore())).when(f).createSslBundle(); - - SSLContext planned = f.create(); - SslBundle reloaded = NODE_ACCESS.reload(f); - - assertNull(f.renew(planned)); - assertSame(reloaded, NODE_ACCESS.current(f)); - assertEquals(0, f.renewalFailures()); - verify(f, times(2)).createSslBundle(); - } - /** - * renew interrupted while the factory loads changes nothing and keeps the interrupt flag, whether the factory returns the chains + * Renewal interrupted while the factory loads changes nothing and keeps the interrupt flag, whether the factory returns the chains * in use or new ones: the interrupt is checked before both the comparison with the chains in use and the installation. */ @Test @@ -313,40 +283,40 @@ public void testInterruptedRenewalChangesNothing() throws Exception { TestSslContextFactory f = renewingFactory(QUARTER); doReturn(bundle(startup, ca.trustStore())) + .doAnswer(inv -> interrupted(same)) + .doAnswer(inv -> interrupted(renewed)) .doAnswer(inv -> { Thread.currentThread().interrupt(); - return same; - }) - .doAnswer(inv -> { - Thread.currentThread().interrupt(); - - return renewed; + throw new SSLException("Key read interrupted"); }).when(f).createSslBundle(); SslBundle inUse = NODE_ACCESS.current(f); - try { - GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); - } - finally { - assertTrue("Same chains: the interrupt flag stays set", Thread.interrupted()); - } + assertInterruptedRenewal(f, inUse, IgniteInterruptedCheckedException.class); + assertInterruptedRenewal(f, inUse, IgniteInterruptedCheckedException.class); + assertInterruptedRenewal(f, inUse, IgniteException.class); + } - Assert.assertSame("Same chains", inUse, NODE_ACCESS.current(f)); - assertEquals("Same chains", 0, f.renewalFailures()); - assertEquals("Same chains", new ReloadOutcome(0, 0, null, 0), f.reloadOutcome()); + /** Returns the loader's result with the cancellation flag still set. */ + private static SslBundle interrupted(SslBundle result) { + Thread.currentThread().interrupt(); + return result; + } + + /** The last accepted material and both failure counters survive an interrupted source read. */ + private void assertInterruptedRenewal(TestSslContextFactory f, SslBundle inUse, Class error) { try { - GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteInterruptedCheckedException.class, null); + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.renew(f, inUse.context()), error, null); } finally { - assertTrue("New chains: the interrupt flag stays set", Thread.interrupted()); + assertTrue("Loader's interrupt flag", Thread.interrupted()); } - Assert.assertSame("New chains", inUse, NODE_ACCESS.current(f)); - assertEquals("New chains", 0, f.renewalFailures()); - assertEquals("New chains", new ReloadOutcome(0, 0, null, 0), f.reloadOutcome()); + assertSame(inUse, NODE_ACCESS.current(f)); + assertEquals(0, NODE_ACCESS.renewalFailures(f)); + assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(f)); } /** Material returned by the key source after the renewal executor stops must not be installed. */ @@ -364,13 +334,13 @@ public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { return later; }).when(factory).createSslBundle(); - factory.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); exec.runNext(); // Plan renewal. exec.runNext(); // Load the replacement. assertTrue(exec.isShutdown()); assertSame(inUse, NODE_ACCESS.current(factory)); - assertEquals(new ReloadOutcome(0, 0, null, 0), factory.reloadOutcome()); + assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(factory)); } /** @@ -379,7 +349,7 @@ public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { */ @Test public void testReloadCannotLoseMetadataWithRenewal() throws Exception { - String reason = "The new SSL context must provide at least one X.509 certificate chain to renew automatically"; + String reason = "The replacement must provide at least one X.509 certificate chain to renew automatically"; SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()); SslBundle unknown = new SslBundle(context(ca.keyStoreWithPrivateKey("unknown"), ca.trustStore()), null); TestSslContextFactory f = renewingFactory(QUARTER); @@ -390,7 +360,7 @@ public void testReloadCannotLoseMetadataWithRenewal() throws Exception { RecordingExecutor exec = new RecordingExecutor(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); f.setRenewalEnabled(false); GridTestUtils.assertThrows(log, () -> NODE_ACCESS.reload(f), IgniteException.class, reason); @@ -414,8 +384,10 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { unknown.create(); noChains.create(); - GridTestUtils.assertThrows(log, () -> unknown.startRenewal(exec, renewalLog, () -> ""), IgniteException.class, reason); - GridTestUtils.assertThrows(log, () -> noChains.startRenewal(exec, renewalLog, () -> ""), IgniteException.class, reason); + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.startRenewal(unknown, exec, renewalLog, () -> ""), + IgniteException.class, reason); + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.startRenewal(noChains, exec, renewalLog, () -> ""), + IgniteException.class, reason); } /** A subclass can disable renewal even when configured, without requiring chains or using the executor. */ @@ -435,10 +407,10 @@ public void testRenewalCanBeDisabledBySubclass() throws Exception { factory.setRenewalEnabled(true); factory.create(); - factory.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); assertSame(noChains, NODE_ACCESS.current(factory)); - assertEquals(0, factory.nextRenewalTime()); + assertEquals(0, NODE_ACCESS.nextRenewalTime(factory)); verifyNoInteractions(exec); } @@ -459,10 +431,10 @@ public void testRenewalWindowUsesOverriddenFraction() throws Exception { RecordingExecutor exec = new RecordingExecutor(); factory.setRenewalEnabled(true); - factory.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); exec.runNext(); - assertEquals(now + 2 * HOUR, factory.nextRenewalTime()); + assertEquals(now + 2 * HOUR, NODE_ACCESS.nextRenewalTime(factory)); } /** A subclass must supply a valid window before its renewal schedule can start. */ @@ -474,13 +446,13 @@ public void testInvalidOverriddenFractionPreventsRenewal() throws Exception { doReturn(bundle(ca.keyStoreWithPrivateKey(), ca.trustStore())).when(factory).createSslBundle(); doReturn(Double.NaN).when(factory).getRenewBeforeFraction(); - GridTestUtils.assertThrows(log, () -> factory.startRenewal(exec, renewalLog, () -> ""), + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""), IgniteException.class, "renewBeforeFraction must be greater than 0 and less than 1"); assertTrue(exec.pending().isEmpty()); doReturn(QUARTER).when(factory).getRenewBeforeFraction(); - factory.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); assertEquals(1, exec.pending().size()); } @@ -494,19 +466,45 @@ public void testRenewalAttachesOnce() throws Exception { doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())).when(f).createSslBundle(); f.create(); - f.startRenewal(exec, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> "communication"); Task first = exec.last(); - f.startRenewal(exec, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> "communication"); assertEquals(List.of(first), exec.pending()); - GridTestUtils.assertThrows(log, () -> f.startRenewal(other, renewalLog, () -> "discovery"), + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.startRenewal(f, other, renewalLog, () -> "discovery"), IgniteException.class, "another running node"); assertTrue(other.pending().isEmpty()); assertEquals(List.of(first), exec.pending()); } + /** When renewal is disabled, the same factory may serve two running nodes. */ + @Test + public void testFactoryWithoutRenewalCanServeTwoNodes() { + SslContextFactory shared = new SslContextFactory(); + + NODE_ACCESS.startRenewal(shared, new RecordingExecutor(), renewalLog, () -> "first"); + NODE_ACCESS.startRenewal(shared, new RecordingExecutor(), renewalLog, () -> "second"); + } + + /** A stopped node cannot schedule another attempt or constrain a later reload. */ + @Test + public void testReloadAfterExecutorStops() throws Exception { + TestSslContextFactory factory = renewingFactory(QUARTER); + RecordingExecutor stopped = new RecordingExecutor(); + SslBundle inUse = bundle(ca.keyStoreWithPrivateKey(), ca.trustStore()); + SslBundle afterStop = new SslBundle(context(ca.keyStoreWithPrivateKey(), ca.trustStore()), null); + + doReturn(inUse, afterStop).when(factory).createSslBundle(); + NODE_ACCESS.startRenewal(factory, stopped, renewalLog, () -> "stopping"); + stopped.shutdownNow(); + + assertSame(afterStop, NODE_ACCESS.reload(factory)); + assertEquals(0, NODE_ACCESS.nextRenewalTime(factory)); + assertEquals(0, NODE_ACCESS.reloadOutcome(factory).failures()); + } + /** Settings apply at attachment and stay fixed until the node stops, including when a factory was already initialized. */ @Test public void testRenewalSettingsAppliedAtAttachmentAndRestart() throws Exception { @@ -522,27 +520,27 @@ public void testRenewalSettingsAppliedAtAttachmentAndRestart() throws Exception f.setRenewalEnabled(false); f.create(); f.setRenewalEnabled(true); - f.startRenewal(first, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, first, renewalLog, () -> "communication"); first.runNext(); - assertEquals(now + 2 * HOUR, f.nextRenewalTime()); + assertEquals(now + 2 * HOUR, NODE_ACCESS.nextRenewalTime(f)); first.shutdownNow(); - assertEquals(0, f.nextRenewalTime()); + assertEquals(0, NODE_ACCESS.nextRenewalTime(f)); f.setRenewBeforeFraction(0.5); - f.startRenewal(restarted, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, restarted, renewalLog, () -> "communication"); restarted.runNext(); - assertEquals(now + HOUR, f.nextRenewalTime()); + assertEquals(now + HOUR, NODE_ACCESS.nextRenewalTime(f)); f.setRenewBeforeFraction(0.75); - f.startRenewal(restarted, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, restarted, renewalLog, () -> "communication"); NODE_ACCESS.reload(f); restarted.runNext(); - assertEquals("The active node keeps its configured fraction", now + 2 * HOUR, f.nextRenewalTime()); + assertEquals("The active node keeps its configured fraction", now + 2 * HOUR, NODE_ACCESS.nextRenewalTime(f)); assertSame(replacement, NODE_ACCESS.current(f)); } @@ -556,21 +554,21 @@ public void testRenewalCanBeDisabledOnRestart() throws Exception { doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()), unknown).when(f).createSslBundle(); - f.startRenewal(first, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, first, renewalLog, () -> "communication"); first.runNext(); first.shutdownNow(); f.setRenewalEnabled(false); - f.startRenewal(restarted, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, restarted, renewalLog, () -> "communication"); NODE_ACCESS.reload(f); assertSame(unknown, NODE_ACCESS.current(f)); - assertEquals(0, f.nextRenewalTime()); + assertEquals(0, NODE_ACCESS.nextRenewalTime(f)); assertTrue(restarted.pending().isEmpty()); f.setRenewalEnabled(true); - f.startRenewal(restarted, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, restarted, renewalLog, () -> "communication"); - assertEquals("Enabling renewal waits for another restart", 0, f.nextRenewalTime()); + assertEquals("Enabling renewal waits for another restart", 0, NODE_ACCESS.nextRenewalTime(f)); assertTrue(restarted.pending().isEmpty()); } @@ -592,10 +590,10 @@ public void testSubclassInitSerializesWithRenewal() throws Exception { return startup; }).doReturn(replacement).when(f).createSslBundle(); - f.startRenewal(exec, renewalLog, () -> "communication"); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> "communication"); exec.runNext(); - FutureTask renewal = new FutureTask<>(() -> f.renew(startup.context())); + FutureTask renewal = new FutureTask<>(() -> NODE_ACCESS.renew(f, startup.context())); FutureTask init = new FutureTask<>(f::reloadFromSubclass); Thread renewalThread = new Thread(renewal, "test-factory-renewal"); Thread initThread = new Thread(init, "test-factory-init"); @@ -619,12 +617,12 @@ public void testSubclassInitSerializesWithRenewal() throws Exception { assertNull(renewal.get(10, TimeUnit.SECONDS)); assertSame(replacement, init.get(10, TimeUnit.SECONDS)); assertSame(replacement, NODE_ACCESS.current(f)); - assertEquals(0, f.renewalFailures()); - assertEquals(0, f.reloadOutcome().failures()); + assertEquals(0, NODE_ACCESS.renewalFailures(f)); + assertEquals(0, NODE_ACCESS.reloadOutcome(f).failures()); exec.runNext(); - assertEquals(now + 2 * HOUR - 30 * MINUTE, f.nextRenewalTime()); + assertEquals(now + 2 * HOUR - 30 * MINUTE, NODE_ACCESS.nextRenewalTime(f)); } /** Both the chains in use and a factory failure count as unsuccessful renewals, and a renewal clears the count. */ @@ -638,15 +636,15 @@ public void testUnsuccessfulRenewalsCounted() throws Exception { .doThrow(new IgniteException(ISSUER_DOWN)) .doReturn(bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); - assertNull(f.renew(f.create())); - assertEquals("Chains in use", 1, f.renewalFailures()); + assertNull(NODE_ACCESS.renew(f, f.create())); + assertEquals("Chains in use", 1, NODE_ACCESS.renewalFailures(f)); - GridTestUtils.assertThrows(log, () -> f.renew(f.create()), IgniteException.class, ISSUER_DOWN); + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.renew(f, f.create()), IgniteException.class, ISSUER_DOWN); - assertEquals("Factory failure", 2, f.renewalFailures()); + assertEquals("Factory failure", 2, NODE_ACCESS.renewalFailures(f)); - assertNotNull(f.renew(f.create())); - assertEquals("Renewed", 0, f.renewalFailures()); + assertNotNull(NODE_ACCESS.renew(f, f.create())); + assertEquals("Renewed", 0, NODE_ACCESS.renewalFailures(f)); } /** @@ -667,23 +665,23 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio bundle(shortCa.keyStoreWithPrivateKey("later-leaf", now - HOUR, now + 2 * HOUR), ca.trustStore()), bundle(ca.keyStoreWithPrivateKey("later-chain", now - HOUR, now + 2 * HOUR), ca.trustStore())).when(f).createSslBundle(); - f.renew(f.create()); + NODE_ACCESS.renew(f, f.create()); GridTestUtils.assertThrows(log, () -> NODE_ACCESS.reload(f), IgniteException.class, ISSUER_DOWN); - assertEquals("Failed reload", 1, f.renewalFailures()); + assertEquals("Failed reload", 1, NODE_ACCESS.renewalFailures(f)); NODE_ACCESS.reload(f); - assertEquals("Same expiry", 1, f.renewalFailures()); + assertEquals("Same expiry", 1, NODE_ACCESS.renewalFailures(f)); NODE_ACCESS.reload(f); - assertEquals("Later leaf, authority expires at the same time", 1, f.renewalFailures()); + assertEquals("Later leaf, authority expires at the same time", 1, NODE_ACCESS.renewalFailures(f)); NODE_ACCESS.reload(f); - assertEquals("Later chain expiry", 0, f.renewalFailures()); + assertEquals("Later chain expiry", 0, NODE_ACCESS.renewalFailures(f)); } /** @@ -728,57 +726,54 @@ public void testNextAttemptNotSoonerThanMinPause() throws Exception { f.create(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); exec.runNext(); - assertEquals("Renewal", f.reloadOutcome().successTime() + MINUTE, f.nextRenewalTime()); + assertEquals("Renewal", NODE_ACCESS.reloadOutcome(f).successTime() + MINUTE, NODE_ACCESS.nextRenewalTime(f)); NODE_ACCESS.reload(f); exec.runNext(); - assertEquals("Reload", f.reloadOutcome().successTime() + MINUTE, f.nextRenewalTime()); + assertEquals("Reload", NODE_ACCESS.reloadOutcome(f).successTime() + MINUTE, NODE_ACCESS.nextRenewalTime(f)); } - /** - * Failed attempts are retried after 1, 2, 4 and so on up to 60 minutes, then every 60 minutes, each plus up to half of it; the - * executor gets the delay to the retry time, and every failure is logged, unlike a wait. The clock does not stop between the - * attempts, so each retry time and delay is checked against the clock before and after the attempt. - */ + /** A certificate in its renewal window is tried immediately. Retries back off with jitter and stop growing after an hour. */ @Test public void testFailedAttemptsBackOff() throws Exception { + long now = System.currentTimeMillis(); TestSslContextFactory f = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); + SslRenewal renewal = new SslRenewal(f, QUARTER, exec, renewalLog, () -> "", () -> now); + boolean jittered = false; - doReturn(bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore())) + doReturn(bundle(ca.keyStoreWithPrivateKey("in-window", now - 32 * HOUR, now + 8 * HOUR), ca.trustStore())) .doThrow(new IgniteException(ISSUER_DOWN)).when(f).createSslBundle(); - f.create(); - - f.startRenewal(exec, renewalLog, () -> ""); + renewal.replan(); exec.runNext(); - for (long pause : new long[] {1, 2, 4, 8, 16, 32, 60, 60}) { - long before = System.currentTimeMillis(); + assertEquals("The first attempt is due at once", now, renewal.nextRenewalTime()); + for (long pause : new long[] {1, 2, 4, 8, 16, 32, 60, 60}) { exec.runNext(); - long after = System.currentTimeMillis(); - long next = f.nextRenewalTime(); - long delay = exec.last().getDelay(TimeUnit.MILLISECONDS); + long jitter = renewal.nextRenewalTime() - now - pause * MINUTE; + + assertTrue("Jitter of the " + pause + "-minute pause: " + jitter, jitter >= 0 && jitter <= pause * MINUTE / 2); + assertEquals(renewal.nextRenewalTime() - now, exec.last().getDelay(TimeUnit.MILLISECONDS)); - assertTrue("Pause of " + pause + " min: " + (next - before) + " ms", - next >= before + pause * MINUTE && next <= after + pause * MINUTE * 3 / 2); - assertTrue("Delay of the " + pause + "-minute retry: " + delay + " ms", delay >= next - after && delay <= next - before); + jittered |= jitter > 0; } + assertTrue(jittered); assertEquals(Collections.nCopies(8, "WARN " + FAILED), problems()); } /** * Reload replans renewal: it cancels the planned retry, plans the next attempt at the window of the new certificate, resets - * the retry pause and lets renewal warn again that the factory has nothing newer. + * the retry pause and checks the new certificate. */ @Test public void testReloadReplans() throws Exception { @@ -793,7 +788,7 @@ public void testReloadReplans() throws Exception { f.create(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); @@ -803,14 +798,14 @@ public void testReloadReplans() throws Exception { exec.runNext(); assertTrue(retry.isCancelled()); - assertEquals(now + 2 * HOUR - 30 * MINUTE, f.nextRenewalTime()); + assertEquals(now + 2 * HOUR - 30 * MINUTE, NODE_ACCESS.nextRenewalTime(f)); long before = System.currentTimeMillis(); exec.runNext(); long after = System.currentTimeMillis(); - long next = f.nextRenewalTime(); + long next = NODE_ACCESS.nextRenewalTime(f); assertTrue("Retry after the reload: " + (next - before) + " ms", next >= before + MINUTE && next <= after + MINUTE * 3 / 2); assertEquals(List.of("WARN " + WAITING, "WARN " + WAITING), problems()); @@ -831,7 +826,7 @@ public void testStaleAttemptNotRetried() throws Exception { f.create(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); exec.runNext(); Task stale = exec.last(); @@ -868,7 +863,7 @@ public void testRenewalRunsWithoutOperatorContext() throws Exception { try { try (Scope ignored = OperationContext.set(OPERATOR, "operator")) { f.create(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); } assertTrue(waitForCondition(() -> operators.size() == 2, 10_000)); @@ -895,15 +890,13 @@ public void testRenewalLogsAppliedCertificate() throws Exception { f.create(); - doAnswer(inv -> { - SslBundle renewed = (SslBundle)inv.callRealMethod(); - - NODE_ACCESS.reload(f); - - return renewed; - }).when(f).renew(any(SSLContext.class)); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> { + // The logger obtains transport names after installation. Simulate a manual reload at that point. + if (NODE_ACCESS.current(f).certificateChains().containsKey("renewed")) + NODE_ACCESS.reload(f); - f.startRenewal(exec, renewalLog, () -> ""); + return "discovery"; + }); exec.runNext(); exec.runNext(); @@ -937,60 +930,6 @@ public void testFailureLogLevelFollowsTimeLeft() throws Exception { ca.keyStoreWithPrivateKey("expired", now - 2 * HOUR, now - HOUR), QUARTER, "ERROR " + FAILED + ", though they have expired"); } - /** - * While the factory returns the chains in use, renewal logs that once, as a warning without a stack trace, and again only when - * the lateness grows: the fifth retry pause of 16 minutes takes the next attempt past the expiry 16 minutes away, which is an - * error. The attempts run one after another, so the time left does not change. - */ - @Test - public void testWaitLoggedOncePerLateness() throws Exception { - long now = System.currentTimeMillis(); - SslBundle inUse = bundle(ca.keyStoreWithPrivateKey("in-use", now - 104 * MINUTE, now + 16 * MINUTE), ca.trustStore()); - TestSslContextFactory f = renewingFactory(QUARTER); - RecordingExecutor exec = new RecordingExecutor(); - - doReturn(inUse).when(f).createSslBundle(); - - f.create(); - - f.startRenewal(exec, renewalLog, () -> ""); - - for (int i = 0; i < 8; i++) - exec.runNext(); - - assertEquals(List.of("WARN " + WAITING, "ERROR " + WAITING + " and expire soon"), problems()); - assertTrue(appender.events().stream().allMatch(e -> e.getThrown() == null)); - } - - /** - * A wait logged as expiring soon is logged again once the certificates have expired, and not after that. - */ - @Test - public void testWaitLoggedAgainOnExpiry() throws Exception { - KeyStore inUse = ca.keyStoreWithPrivateKey(); - long notAfter = chain(inUse).get(0).getNotAfter().getTime(); - - // Hold the scheduler's clock before expiry until the test advances it. - AtomicLong now = new AtomicLong(notAfter - SECOND); - TestSslContextFactory f = renewingFactory(QUARTER); - RecordingExecutor exec = new RecordingExecutor(); - - doReturn(bundle(inUse, ca.trustStore())).when(f).createSslBundle(); - - f.create(); - - new SslRenewal(f, QUARTER, exec, renewalLog, () -> "", now::get).replan(); - exec.runNext(); - exec.runNext(); - - now.set(notAfter); - - exec.runNext(); - exec.runNext(); - - assertEquals(List.of("ERROR " + WAITING + " and expire soon", "ERROR " + WAITING + ", though they have expired"), problems()); - } - /** * @param fraction Share of the lifetime left when renewal starts. * @return Factory with renewal enabled; tests script its fresh bundles. @@ -1017,10 +956,10 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { f.create(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); exec.runNext(); - return f.nextRenewalTime(); + return NODE_ACCESS.nextRenewalTime(f); } /** @@ -1033,13 +972,13 @@ private long firstAttempt(double fraction, KeyStore keys) throws Exception { */ private void assertRenewalRejected(String desc, AbstractSslContextFactory f, String reason) { SslBundle inUse = NODE_ACCESS.current(f); - int failures = f.renewalFailures(); + int failures = NODE_ACCESS.renewalFailures(f); - GridTestUtils.assertThrows(log, () -> f.renew(inUse.context()), IgniteException.class, reason, desc + ": not refused"); + GridTestUtils.assertThrows(log, () -> NODE_ACCESS.renew(f, inUse.context()), IgniteException.class, reason, desc + ": not refused"); Assert.assertSame(desc, inUse, NODE_ACCESS.current(f)); - assertEquals(desc, failures + 1, f.renewalFailures()); - assertTrue(desc, f.reloadOutcome().failureReason().contains(reason)); + assertEquals(desc, failures + 1, NODE_ACCESS.renewalFailures(f)); + assertTrue(desc, NODE_ACCESS.reloadOutcome(f).failureReason().contains(reason)); } /** @@ -1060,7 +999,7 @@ private void assertFailureLogged(String desc, KeyStore inUse, double fraction, S int before = problems().size(); - f.startRenewal(exec, renewalLog, () -> ""); + NODE_ACCESS.startRenewal(f, exec, renewalLog, () -> ""); exec.runNext(); exec.runNext(); @@ -1104,6 +1043,9 @@ private RecordingExecutor() { /** {@inheritDoc} */ @Override public ScheduledFuture schedule(Runnable cmd, long delay, TimeUnit unit) { + if (isShutdown()) + throw new RejectedExecutionException(); + Task task = new Task(cmd, unit.toMillis(delay)); tasks.add(task); From 500089e5a6376e5db20feaaf9d907c0db26a3046 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:11:17 +0300 Subject: [PATCH 127/242] IGNITE-29209 Clarify custom TLS configuration and context reads --- docs/_docs/security/ssl-tls.adoc | 7 +++++-- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 4a4bfa358e655..0aab504a39402 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -30,7 +30,7 @@ This certificate must be trusted by all other server nodes. To enable SSL/TLS for cluster nodes, configure an `SSLContext` factory in the node configuration. You can use the `org.apache.ignite.ssl.SslContextFactory`, which is the default factory that uses a configurable keystore to initialize the SSL context. -//You can also implement your own `SSLContext` factory. +To implement your own `SSLContext` factory, see <>. [CAUTION] ==== @@ -65,6 +65,8 @@ tab:C++[unsupported] The keystore must contain the node's certificate, including its private key. The trust store must contain the trusted certificates for all other cluster nodes. +If the JVM does not recognize a configured protocol or cipher suite name, the node fails to start. + You can define other properties, such as key algorithm, key store type, or trust manager. See the description of the properties in the <> section. After starting the node, you should see the following messages in the logs: @@ -182,7 +184,8 @@ Now all nodes trust the new certificates. == Custom SSL Context Factories A subclass of `AbstractSslContextFactory` implements `createSslBundle()` to build an initialized context and report the certificate chains loaded with it. -For a key store, load it once and pass it to `createSslBundle(store, keyPassword, algorithm)`. For a custom key manager, return an `SslBundle` with the chains for its private keys, indexed by alias. +For a key store, load it once and pass it to `createSslBundle(store, keyPassword, algorithm)`. +For custom key managers, initialize the context with those managers and the intended trust managers. Apply the configured protocols and cipher suites, then return the context and its private-key chains indexed by alias. Use a null chain map if the source cannot report certificates. An empty map means the store has no X.509 key chains. Call `create()` to obtain the accepted context. If a subclass needs a fresh certificate when a node restarts with the same factory, it can call the protected `init()` method and take the chain from the returned bundle. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index a7c1285a175c3..753a29215218e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -315,9 +315,9 @@ final SslBundle sslBundle() { } /** - * Initializes the context on first use. Once a context is available, callers can use it while {@link #init()} - * loads a replacement. Obtain the context again after a successful replacement. Previously returned contexts, - * engines and sockets retain their original material. Do not reinitialize the returned context directly. + * Returns the accepted context, initializing the factory on first use. While a replacement loads, callers receive + * the current context without waiting. Call this method again after a successful replacement to obtain the new context. + * Previously returned contexts, engines and sockets retain their original material. Do not reinitialize the context directly. * * @return Currently accepted context. * @throws IgniteException If the first initialization fails to load the material or create the context. From eee3041c4cffee8c21097138a3754005334b5831 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:13:37 +0300 Subject: [PATCH 128/242] IGNITE-29211 Count certificate source failures while renewal is running --- .../_docs/monitoring-metrics/new-metrics.adoc | 2 +- .../ignite/internal/ssl/SslFactoryAccess.java | 3 +- .../ignite/ssl/AbstractSslContextFactory.java | 35 +++---- .../ignite/internal/ssl/SslRenewalTest.java | 92 +++++++------------ 4 files changed, 46 insertions(+), 86 deletions(-) diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index e93f9a03f015c..1e9d1eeff3ae4 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -525,7 +525,7 @@ Register name: `ssl` When automatic renewal or manual reload moves the earliest expiry across all of a factory's reported certificate chains forward, that factory's count resets to zero. CA certificates also count toward that earliest expiry. Failed manual reloads do not increase the metric. -A factory that has never attempted renewal contributes zero. Reusing a factory after a node restart retains its count, even if renewal is disabled for the new node. +On a node without SSL, the metric is zero. A factory that has never attempted renewal also contributes zero. Reusing a factory after a node restart retains its count, even if renewal is disabled for the new node. == Discovery IO diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java index e6b34785a86b2..8239e14ca1169 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java @@ -20,7 +20,6 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslBundle; @@ -46,7 +45,7 @@ protected abstract void startRenewal( ); /** Renews the material if the scheduled context is still current. */ - protected abstract @Nullable SslBundle renew(AbstractSslContextFactory factory, SSLContext expected) throws IgniteCheckedException; + protected abstract @Nullable SslBundle renew(AbstractSslContextFactory factory, SSLContext expected); /** Reads the unsuccessful attempt count. */ protected abstract int renewalFailures(AbstractSslContextFactory factory); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 44e9db361f57f..df7eb93bd41bf 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -40,16 +40,12 @@ import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; -import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.IgniteLogger; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.ssl.SslCertificates; import org.apache.ignite.internal.ssl.SslFactoryAccess; import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.ssl.SslRenewal; -import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.A; import org.jetbrains.annotations.Nullable; @@ -98,10 +94,7 @@ public abstract class AbstractSslContextFactory implements Factory { } /** {@inheritDoc} */ - @Override protected @Nullable SslBundle renew( - AbstractSslContextFactory factory, - SSLContext expected - ) throws IgniteCheckedException { + @Override protected @Nullable SslBundle renew(AbstractSslContextFactory factory, SSLContext expected) { return factory.renew(expected); } @@ -494,24 +487,23 @@ private synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogg /** * Installs replacement material only if its earliest chain expiry is later than the current earliest expiry. - * Unchanged chains count as an unsuccessful renewal without recording a reload failure. An interrupted attempt retains - * the current material and does not count as unsuccessful. + * Unchanged chains count as an unsuccessful renewal without recording a reload failure. If the node stops the executor + * during the load, the factory retains its material and failure counts. * * @param expected Context in use when the renewal was planned. - * @return Installed bundle, or {@code null} if its chains are unchanged or the expected context has already been replaced. - * @throws IgniteException If the replacement fails initialization checks, has no certificate metadata or does not advance - * the earliest expiry across all chains. - * @throws IgniteCheckedException If renewal is interrupted; the interrupt flag stays set when the factory sets it. + * @return Installed bundle, or {@code null} if the attempt was superseded, the executor stopped or the chains are unchanged. + * @throws IgniteException If the replacement cannot be loaded, fails initialization checks, has no certificate metadata + * or does not advance the earliest expiry across all chains. */ - private synchronized @Nullable SslBundle renew(SSLContext expected) throws IgniteCheckedException { + private synchronized @Nullable SslBundle renew(SSLContext expected) { if (bundle == null || bundle.context() != expected) return null; try { SslBundle next = buildReplacement(); - if (renewalInterrupted()) - throw new IgniteInterruptedCheckedException("TLS certificate renewal was interrupted"); + if (renewalStopped()) + return null; Map> chains = next.certificateChains(); @@ -535,8 +527,7 @@ private synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogg putInUse(next); } catch (Throwable e) { - if (!renewalInterrupted() && - !X.hasCause(e, InterruptedException.class, IgniteInterruptedException.class, IgniteInterruptedCheckedException.class)) { + if (!renewalStopped()) { renewalFailures++; onFailure(e); @@ -550,9 +541,9 @@ private synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogg return bundle; } - /** @return Whether the renewal thread was interrupted or its node stopped the executor during the load. */ - private boolean renewalInterrupted() { - return Thread.currentThread().isInterrupted() || (renewalExec != null && renewalExec.isShutdown()); + /** @return Whether the node stopped its renewal executor. */ + private boolean renewalStopped() { + return renewalExec != null && renewalExec.isShutdown(); } /** @return Unsuccessful automatic attempts since the earliest expiry across all chains was advanced. */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index ee2ec178aa995..2488276555bca 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -37,7 +37,6 @@ import javax.net.ssl.SSLException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.ssl.SslFactoryAccess.ReloadOutcome; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; @@ -270,77 +269,50 @@ public void testRenewalMustAdvanceEarliestExpiry() throws Exception { assertRenewalRejected("A later leaf from an authority that expires no later", f, reason); } - /** - * Renewal interrupted while the factory loads changes nothing and keeps the interrupt flag, whether the factory returns the chains - * in use or new ones: the interrupt is checked before both the comparison with the chains in use and the installation. - */ + /** An interrupted key source counts as a failed attempt while the node is still running. */ @Test - public void testInterruptedRenewalChangesNothing() throws Exception { - long now = System.currentTimeMillis(); - KeyStore startup = ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR); - SslBundle same = bundle(startup, ca.trustStore()); - SslBundle renewed = bundle(ca.keyStoreWithPrivateKey("renewed", now - HOUR, now + 2 * HOUR), ca.trustStore()); - TestSslContextFactory f = renewingFactory(QUARTER); - - doReturn(bundle(startup, ca.trustStore())) - .doAnswer(inv -> interrupted(same)) - .doAnswer(inv -> interrupted(renewed)) - .doAnswer(inv -> { - Thread.currentThread().interrupt(); - - throw new SSLException("Key read interrupted"); - }).when(f).createSslBundle(); - - SslBundle inUse = NODE_ACCESS.current(f); - - assertInterruptedRenewal(f, inUse, IgniteInterruptedCheckedException.class); - assertInterruptedRenewal(f, inUse, IgniteInterruptedCheckedException.class); - assertInterruptedRenewal(f, inUse, IgniteException.class); - } + public void testInterruptedSourceCountsAsRenewalFailure() throws Exception { + SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup"), ca.trustStore()); + TestSslContextFactory factory = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - /** Returns the loader's result with the cancellation flag still set. */ - private static SslBundle interrupted(SslBundle result) { - Thread.currentThread().interrupt(); + doReturn(startup).doThrow(new SSLException(ISSUER_DOWN, new InterruptedException())).when(factory).createSslBundle(); - return result; - } - - /** The last accepted material and both failure counters survive an interrupted source read. */ - private void assertInterruptedRenewal(TestSslContextFactory f, SslBundle inUse, Class error) { - try { - GridTestUtils.assertThrows(log, () -> NODE_ACCESS.renew(f, inUse.context()), error, null); - } - finally { - assertTrue("Loader's interrupt flag", Thread.interrupted()); - } + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); + exec.runNext(); // Plan renewal. + exec.runNext(); // Load the replacement. - assertSame(inUse, NODE_ACCESS.current(f)); - assertEquals(0, NODE_ACCESS.renewalFailures(f)); - assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(f)); + assertSame(startup, NODE_ACCESS.current(factory)); + assertEquals(1, NODE_ACCESS.renewalFailures(factory)); + assertEquals(1, NODE_ACCESS.reloadOutcome(factory).failures()); + assertEquals(1, exec.pending().size()); } - /** Material returned by the key source after the renewal executor stops must not be installed. */ + /** If shutdown stops the executor during a source read, neither unchanged nor newer material affects the factory. */ @Test public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { long now = System.currentTimeMillis(); SslBundle inUse = bundle(ca.keyStoreWithPrivateKey("in-use", now - HOUR, now + HOUR), ca.trustStore()); SslBundle later = bundle(ca.keyStoreWithPrivateKey("later", now - HOUR, now + 2 * HOUR), ca.trustStore()); - TestSslContextFactory factory = renewingFactory(QUARTER); - RecordingExecutor exec = new RecordingExecutor(); - doReturn(inUse).doAnswer(inv -> { - exec.shutdownNow(); + for (SslBundle loaded : List.of(inUse, later)) { + TestSslContextFactory factory = renewingFactory(QUARTER); + RecordingExecutor exec = new RecordingExecutor(); - return later; - }).when(factory).createSslBundle(); + doReturn(inUse).doAnswer(inv -> { + exec.shutdownNow(); - NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); - exec.runNext(); // Plan renewal. - exec.runNext(); // Load the replacement. + return loaded; + }).when(factory).createSslBundle(); + + NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); + exec.runNext(); // Plan renewal. + exec.runNext(); // Load the replacement. - assertTrue(exec.isShutdown()); - assertSame(inUse, NODE_ACCESS.current(factory)); - assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(factory)); + assertSame(inUse, NODE_ACCESS.current(factory)); + assertEquals(0, NODE_ACCESS.renewalFailures(factory)); + assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(factory)); + } } /** @@ -811,10 +783,7 @@ public void testReloadReplans() throws Exception { assertEquals(List.of("WARN " + WAITING, "WARN " + WAITING), problems()); } - /** - * An attempt planned for a context that a reload has replaced, here run before the plan of the reload, does not call the - * factory, log or schedule a retry: only the plan of the reload stays pending. - */ + /** A successful reload supersedes the old attempt without recording a failure or scheduling another retry. */ @Test public void testStaleAttemptNotRetried() throws Exception { TestSslContextFactory f = renewingFactory(QUARTER); @@ -838,6 +807,7 @@ public void testStaleAttemptNotRetried() throws Exception { stale.run(); verify(f, times(2)).createSslBundle(); + assertEquals(0, NODE_ACCESS.renewalFailures(f)); assertEquals(List.of(), problems()); assertEquals(List.of(plan), exec.pending()); } From 0983f4c0e13b9cf869c6274f04bdf0cdebec0db1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:26:38 +0300 Subject: [PATCH 129/242] IGNITE-29209 Verify that concurrent SSL reloads publish in order --- .../jdbc/thin/JdbcThinConnectionSSLTest.java | 2 +- .../ignite/ssl/AbstractSslContextFactory.java | 6 ++-- .../apache/ignite/ssl/SslContextFactory.java | 2 +- .../ignite/ssl/SslContextMetadataTest.java | 31 ++++++++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index 2b8667c42850a..355a91380d310 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -664,7 +664,7 @@ public void testInvalidKeyAlgorithm() throws Exception { } }, SQLException.class, "Unsupported key algorithm: INVALID"); - assertTrue(err.getMessage().contains("keyStoreFile=" + CLI_KEY_STORE_PATH)); + GridTestUtils.assertContains(log, err.getMessage(), "keyStoreFile=" + CLI_KEY_STORE_PATH); } finally { stopAllGrids(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 753a29215218e..f09e8079e3bc7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -122,7 +122,7 @@ public void setProtocols(String... protocols) { /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. Keep the material in use intact. Calling {@link #create()} from this hook would recurse on first use. + * loading it. Leave the accepted context and its key managers intact. Calling {@link #create()} from this hook would recurse on first use. * * @return Fresh context and the certificate metadata available to this factory. * @throws SSLException If the context could not be created. @@ -275,8 +275,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads and validates fresh material before replacing the accepted context and its metadata. Calls run one at a time. - * A failed initialization leaves the previous material in use. + * Loads fresh material and checks that the configured protocols and cipher suites can be applied before publishing + * the context and its metadata. Calls run one at a time. A failed initialization leaves the previous material in use. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded or the context cannot be created. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index f7d7007f55750..7f635793b1aa3 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -347,7 +347,7 @@ protected InputStream openFileInputStream(String filePath) throws IOException { * @param keyStoreType Type of key store. * @param storeFilePath Path to key store file. * @param keyStorePwd Store password. - * @return Initialized store. An override may return {@code null} for a client without a private key or to use the JVM trust store. + * @return Initialized key store. * @throws SSLException If key store could not be initialized. */ protected KeyStore loadKeyStore(String keyStoreType, String storeFilePath, char[] keyStorePwd) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java index 006f00fe5257c..a1f29b059949b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import javax.crypto.KeyGenerator; @@ -40,6 +41,7 @@ import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -96,9 +98,9 @@ public void testConcurrentFirstCreateLoadsOnce() throws Exception { assertEquals(1, factory.loads); } - /** A slow store read does not block a caller that can already use the accepted context. */ + /** During a reload, readers get the accepted context while another reload waits to install its own context. */ @Test - public void testCreateDoesNotWaitForInit() throws Exception { + public void testReadersProceedWhileReloadsRunInOrder() throws Exception { InMemoryStoreFactory factory = spy(new InMemoryStoreFactory(nodeStore("node01"))); SSLContext accepted = factory.create(); CountDownLatch loading = new CountDownLatch(1); @@ -110,18 +112,27 @@ public void testCreateDoesNotWaitForInit() throws Exception { release.await(); return inv.callRealMethod(); - }).when(factory).createSslBundle(); + }).doCallRealMethod().when(factory).createSslBundle(); - IgniteInternalFuture init = GridTestUtils.runAsync(() -> factory.init()); + IgniteInternalFuture first = GridTestUtils.runAsync(() -> factory.init()); + FutureTask second = new FutureTask<>(factory::init); + Thread secondThread = new Thread(second, "second-init"); try { assertTrue("Replacement load started", loading.await(timeout, TimeUnit.MILLISECONDS)); assertSame(accepted, GridTestUtils.runAsync(() -> factory.create()).get(timeout)); + + secondThread.start(); + + assertTrue(GridTestUtils.waitForCondition(() -> secondThread.getState() == Thread.State.BLOCKED || second.isDone(), timeout)); + assertFalse("The second reload must wait for the first", second.isDone()); } finally { release.countDown(); - init.get(timeout); + first.get(timeout); } + + assertSame(second.get(timeout, TimeUnit.MILLISECONDS), factory.sslBundle()); } /** Invalid settings can be corrected after first use fails. A later failure leaves the last accepted bundle usable. */ @@ -176,7 +187,6 @@ public void testSerializationKeepsConfiguration() throws Exception { SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), getClass().getClassLoader()); - assertEquals(factory.getKeyStoreFilePath(), restored.getKeyStoreFilePath()); assertNotSame(original.context(), restored.create()); assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); } @@ -188,7 +198,6 @@ public void testClientWithoutKeyStore() throws Exception { SslBundle client = factory.init(); assertEquals(Map.of(), client.certificateChains()); - assertNotNull(client.context().createSSLEngine()); } /** Missing parameters are reported before the key store is read. */ @@ -309,9 +318,8 @@ public void testKeyPasswordFailureNamesStore() throws Exception { String storeDetails = "keyStoreFile=" + storePath; Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); - assertTrue(err.getCause() instanceof SSLException); - assertTrue(err.getMessage().contains("keyStoreType=" + store.getType())); - assertTrue(err.getCause().getCause() instanceof UnrecoverableKeyException); + GridTestUtils.assertContains(log, err.getMessage(), "keyStoreType=" + store.getType()); + assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); } /** Loads one of the node key stores shipped with the test suite. */ @@ -327,8 +335,7 @@ private static List chain(KeyStore store, String alias) throws } /** - * File factory that reads its key store from memory and counts the reads. Trust is disabled, so the trust store is never - * read and every read is the key store's. + * Reads the key store from memory and counts the reads. A trust manager is supplied directly. */ private static class InMemoryStoreFactory extends SslContextFactory { /** Store returned by the next read. */ From e57a63a98be4b81b5abe0189dcd109620c3c001f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:28:00 +0300 Subject: [PATCH 130/242] IGNITE-29210 Cover preinitialized factories at transport registration --- .../ignite/internal/ssl/SslCertificates.java | 2 +- .../ignite/ssl/AbstractSslContextFactory.java | 8 +++--- .../internal/ssl/SslContextProvider.java | 6 ++--- .../internal/ssl/SslContextRegistry.java | 5 ++-- .../tcp/internal/GridNioServerWrapper.java | 2 +- .../spi/discovery/tcp/TcpDiscoverySpi.java | 1 - .../internal/ssl/SslContextProviderTest.java | 23 ++++++++++++++--- .../ignite/ssl/SslContextReloadTest.java | 9 +++++-- .../TestCertificateAuthority.java | 25 ------------------- 9 files changed, 39 insertions(+), 42 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 6c9af2196cc9f..0fad04c9cc3bf 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -24,7 +24,7 @@ import org.apache.ignite.internal.util.typedef.X; import org.jetbrains.annotations.Nullable; -/** Certificate dates and descriptions used by SSL reload and status. */ +/** Validity dates and descriptions of X.509 certificate chains. */ public class SslCertificates { /** */ private SslCertificates() { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 101fefe896dbf..611114a2fc428 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -313,9 +313,9 @@ protected void checkNullParameter(Object param, String name) throws SSLException * the context and its metadata. Calls run one at a time. A failed initialization leaves the previous material in use. * *

- * The first initialization permits material without certificate chains and certificates outside their validity period, - * matching node startup. Later calls reject a context already in use, known empty certificate metadata and chains with - * any expired or not-yet-valid certificate. Peers must trust the replacement; this method does not test connections. + * Until initialization succeeds, the node can start with no certificate chains or with certificates outside their + * validity period. Later calls reject the context already in use, an empty chain map, or a chain containing an expired + * or not-yet-valid certificate. Check peer trust with a new connection. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. @@ -394,6 +394,7 @@ private ReloadOutcome reloadOutcome() { * @return Accepted bundle. * @throws IgniteException If the first initialization fails to load the material or create the context. * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. + * @throws UnsupportedOperationException If the context does not support stream sockets. */ final SslBundle sslBundle() { SslBundle cur = bundle; @@ -418,6 +419,7 @@ final SslBundle sslBundle() { * @return Currently accepted context. * @throws IgniteException If the first initialization fails to load the material or create the context. * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. + * @throws UnsupportedOperationException If the context does not support stream sockets. */ @Override public final SSLContext create() { return sslBundle().context(); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index cf36b68cc96d9..ca50b1aa22c7a 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -40,7 +40,7 @@ public class SslContextProvider { /** Factory that owns the context, adapting a plain factory when necessary. */ private final AbstractSslContextFactory sslFactory; - /** Stable context that forwards new engines and socket factories to the factory's accepted material. */ + /** Shared context that forwards new engines and socket factories to the factory's accepted material. */ private final SSLContext sharedCtx; /** */ @@ -70,7 +70,7 @@ SslBundle bundle() { return NODE_ACCESS.current(sslFactory); } - /** @return Stable context shared by every transport configured with this factory. */ + /** @return Context shared by every transport configured with this factory. */ SSLContext sharedContext() { return sharedCtx; } @@ -80,7 +80,7 @@ void addTransport(String transport) { transports.add(transport); } - /** @return Comma-separated transport names used in command output and node logs. */ + /** @return Transport names in alphabetical order, separated by commas. */ public String transports() { return String.join(", ", transports); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 0899b0c857fff..6529f222bd163 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -53,9 +53,8 @@ public SslContextRegistry(GridKernalContext ctx) { /** * @param factory Factory the transport is configured with. - * @param transport Transport, one of the names above. - * @return Context that delegates to the factory's current context. Transports configured with the same factory object share it, - * so reload updates the context for all of them. + * @param transport Transport name from the constants of this class. + * @return Context shared by the transports using this factory. New connections use its current material. */ public synchronized SSLContext register(Factory factory, String transport) { SslContextProvider provider = providers.stream().filter(p -> p.factory() == factory).findFirst().orElse(null); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index ce610587bbf38..b8953b0d5e0f2 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -220,7 +220,7 @@ public class GridNioServerWrapper { private final int msgQueueWarningSize = IgniteSystemProperties.getInteger( IgniteSystemProperties.IGNITE_TCP_COMM_MSG_QUEUE_WARN_SIZE, 0); - /** Context shared by incoming and outgoing connections until the SPI stops. */ + /** SSL context for incoming and outgoing connections, or {@code null} if SSL is disabled. */ private SSLContext sslCtx; /** NIO server. */ diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index fb48018244a28..55ed23d67eae8 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -1690,7 +1690,6 @@ Socket acceptedSocket(Socket sock) throws IOException { SSLSocket sslSock = (SSLSocket)sslCtx.getSocketFactory() .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); - // Accepted discovery connections use server mode and require a peer certificate. sslSock.setUseClientMode(false); sslSock.setNeedClientAuth(true); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java index 6bee0ed308e65..5f7f98d943214 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslContextProviderTest.java @@ -53,6 +53,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -97,6 +98,24 @@ public void testReloadInstallsNewBundle() throws Exception { assertTrue(p.reloadOutcome().successTime() > 0); } + /** Registration reuses accepted startup material. An earlier failed first load is not a reload failure. */ + @Test + public void testRegistrationAfterInitialLoadFailure() throws Exception { + TestSslContextFactory factory = mock(TestSslContextFactory.class, CALLS_REAL_METHODS); + SslBundle accepted = new SslBundle(SSLContext.getDefault(), null); + + doThrow(new IgniteException("Source unavailable")).doReturn(accepted).when(factory).createSslBundle(); + + GridTestUtils.assertThrows(log, factory::reloadFromSource, IgniteException.class, "Source unavailable"); + factory.reloadFromSource(); + + SslContextProvider provider = new SslContextProvider(factory); + + assertSame(accepted, provider.bundle()); + assertEquals(new ReloadOutcome(0, 0, null, 0), provider.reloadOutcome()); + verify(factory, times(2)).createSslBundle(); + } + /** Direct initialization updates new provider engines, while captured contexts and engines retain their material. */ @Test public void testDirectFactoryInitUpdatesProvider() throws Exception { @@ -122,7 +141,6 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { SSLSocketFactory capturedSockets = shared.getSocketFactory(); assertSame(startup.context(), captured); - assertEquals(new ReloadOutcome(0, 0, null, 0), p.reloadOutcome()); f.reloadFromSource(); @@ -130,7 +148,6 @@ public void testDirectFactoryInitUpdatesProvider() throws Exception { assertSame(next.context(), f.create()); assertSame(next.context(), p.bundle().context()); assertEquals(next.certificateChains(), p.certificateChains()); - assertSame(shared, p.sharedContext()); assertSame(next.context().getClientSessionContext(), shared.getClientSessionContext()); assertSame(next.context().getServerSessionContext(), shared.getServerSessionContext()); assertArrayEquals(firstParams.getProtocols(), captured.createSSLEngine().getEnabledProtocols()); @@ -308,7 +325,7 @@ void reloadFromSource() { } /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() throws SSLException { + @Override protected SslBundle createSslBundle() throws SSLException { return super.createSslBundle(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index db20527840e41..7c4e4ae898e41 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -28,6 +28,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -299,6 +300,7 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { placeKeys(newKeys); fail.set(true); + Instant reloadStarted = Instant.ofEpochMilli(System.currentTimeMillis()); String res = reloadFailure(g); assertContains(log, res, node(g) + ": failed on " + COMMUNICATION + ", " + DISCOVERY + @@ -308,8 +310,11 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { String status = status(g); - assertContains(log, status, "last reload failed 1 time(s) in a row"); - assertContains(log, status, "Certificate provider class is unavailable"); + Matcher failure = Pattern.compile(Pattern.quote("last reload failed 1 time(s) in a row, the last at ") + + "(\\S+): Certificate provider class is unavailable").matcher(status); + + assertTrue(status, failure.find()); + assertFalse(status, Instant.parse(failure.group(1)).isBefore(reloadStarted)); assertContains(log, status, "last reload succeeded at"); } diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java index 0b20f3cde7ed3..3db17fd645614 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -17,8 +17,6 @@ package org.apache.ignite.testframework; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.math.BigInteger; import java.nio.file.Files; @@ -26,7 +24,6 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; -import java.security.KeyStoreSpi; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.security.spec.ECGenParameterSpec; @@ -134,28 +131,6 @@ public KeyStore keyStoreWithPrivateKey(String cn, long notBefore, long notAfter) return store; } - /** - * @param type Store type. - * @param alias Private-key alias. - * @return Saved and reloaded store with a private key but no certificate chain, protected by {@link #PWD}. - */ - public static KeyStore keyStoreWithoutCertificateChain(String type, String alias) throws Exception { - KeyStore store = KeyStore.getInstance(type); - - // KeyStore rejects a missing chain on insertion; its provider can still store and load such a key. - KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", type).newInstance(null); - - spi.engineLoad(null, PWD); - spi.engineSetKeyEntry(alias, keyPair().getPrivate(), PWD, null); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - spi.engineStore(out, PWD); - store.load(new ByteArrayInputStream(out.toByteArray()), PWD); - - return store; - } - /** @return Trust store with this authority alone. */ public KeyStore trustStore() throws Exception { return trustStore(this); From 6822aa58702a9184d97adb40fbd3914fdc71a3a2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:28:20 +0300 Subject: [PATCH 131/242] IGNITE-29209 Shorten SSL factory hook Javadoc --- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index f09e8079e3bc7..e7e32d843ed48 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -122,7 +122,7 @@ public void setProtocols(String... protocols) { /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. Leave the accepted context and its key managers intact. Calling {@link #create()} from this hook would recurse on first use. + * loading it. Preserve the accepted key managers. Calling {@link #create()} from this hook would recurse on first use. * * @return Fresh context and the certificate metadata available to this factory. * @throws SSLException If the context could not be created. From 6a6cb7d0e053bc2583747ec107e1a94988ac5fbe Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:30:56 +0300 Subject: [PATCH 132/242] IGNITE-29210 Distinguish initial load retries from TLS replacement --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 9acd715803ccd..2a5bc5016ea11 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -313,9 +313,9 @@ protected void checkNullParameter(Object param, String name) throws SSLException * the context and its metadata. Calls run one at a time. A failed initialization leaves the previous material in use. * *

- * Until initialization succeeds, the node can start with no certificate chains or with certificates outside their - * validity period. Later calls reject the context already in use, an empty chain map, or a chain containing an expired - * or not-yet-valid certificate. Check peer trust with a new connection. + * The first load, including a retry after failure, can accept no certificate chains or certificates outside their + * validity period. After a successful load, calls reject the context already in use, an empty chain map, or an expired + * or not-yet-valid certificate in any chain. Check peer trust with a new connection. * * @return Context and metadata accepted by this initialization. * @throws IgniteException If the material cannot be loaded, the context cannot be created or a replacement is rejected. From 5888cb298bb88f4b9bd37b8e48b56f1d6bafb3d5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 00:31:39 +0300 Subject: [PATCH 133/242] IGNITE-29211 Clarify renewal settings and make the node test wait for its source --- docs/_docs/security/ssl-tls.adoc | 2 +- docs/_docs/tools/control-script.adoc | 2 +- .../ignite/internal/ssl/SslFactoryAccess.java | 2 +- .../ignite/internal/ssl/SslRenewal.java | 2 +- .../ignite/ssl/AbstractSslContextFactory.java | 19 +++++--- .../internal/ssl/SslContextProvider.java | 2 +- .../internal/ssl/SslContextRegistry.java | 2 +- .../internal/ssl/SslRenewalNodeTest.java | 2 +- .../ignite/internal/ssl/SslRenewalTest.java | 44 +++++++++++-------- 9 files changed, 46 insertions(+), 31 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 786e07a4d619e..15cceb95f3ed2 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -300,7 +300,7 @@ To change how early renewal starts, set `renewBeforeFraction` as described in th === Check Renewal -Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and the next attempt time. +Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and next attempt time for each transport. At startup the renewal log lists only the transports registered so far. If the source still returns the current certificates or the replacement fails validation or loading, the node keeps its current context. It retries after 60 to 90 seconds, then at increasing intervals of up to 90 minutes. Retries continue after expiry. The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] metric counts these unsuccessful attempts. Check the node log for the cause, fix certificate delivery or configuration, and use reload if you need to apply the correction immediately. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 97e37c59b53a8..f5f23bab0a731 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -1802,7 +1802,7 @@ control.sh|bat --ssl status ---- For each factory, the output lists its transports and last reload result, including automatic renewals. Each reported certificate chain has a `loaded` line identified by its key alias. -If automatic renewal is scheduled, it also shows the next attempt time: +When automatic renewal is scheduled, the output also shows the next attempt time: [source,text] ---- diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java index 8239e14ca1169..e7525da33a4b9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslFactoryAccess.java @@ -36,7 +36,7 @@ public abstract class SslFactoryAccess { /** @return Last successful reload and any failures since then. */ protected abstract ReloadOutcome reloadOutcome(AbstractSslContextFactory factory); - /** Attaches this node's renewal executor. */ + /** Attaches the node's renewal executor. */ protected abstract void startRenewal( AbstractSslContextFactory factory, ScheduledExecutorService exec, diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java index c20ef449a4c17..91e6a1c3fe99a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslRenewal.java @@ -126,7 +126,7 @@ public SslRenewal( this.currentTimeMillis = currentTimeMillis; } - /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ + /** @return Time of the next automatic renewal, or {@code 0} if none is planned or the node has stopped. */ public long nextRenewalTime() { return exec.isShutdown() ? 0 : nextRenewalTime; } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 7ab668ff0f301..5fc8a56b62b02 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -64,7 +64,7 @@ public abstract class AbstractSslContextFactory implements Factory { public static final double DFLT_RENEW_BEFORE_FRACTION = 0.15; /** - * Gives the node access to the accepted material and the same reload path used by subclasses. + * Gives the node access to the accepted material, reload and automatic renewal. * @hidden */ public static final SslFactoryAccess NODE_ACCESS = new SslFactoryAccess() { @@ -195,14 +195,18 @@ public void setProtocols(String... protocols) { this.protocols = protocols; } - /** @return Whether a node builds its SSL context again before the certificate expires, to pick up a new certificate. */ + /** + * A node reads this setting at startup, including when a subclass overrides it. + * + * @return Whether the node automatically loads replacement certificates before expiry. + */ public boolean isRenewalEnabled() { return renewalEnabled; } /** * Sets whether a node calls {@link #createSslBundle()} before the certificates expire to pick up replacements. - * A file-based factory rereads its files. It does not replace them. + * A file-based factory rereads its files. Supply replacement certificates in those files. * When enabled, the factory must report at least one X.509 certificate chain, or the node does not start. * With renewal enabled, use a separate factory instance for each running node. Disabled by default. * Set this before starting the node; changing it on a running node takes effect after a restart. @@ -213,7 +217,11 @@ public void setRenewalEnabled(boolean renewalEnabled) { this.renewalEnabled = renewalEnabled; } - /** @return Share of the certificate lifetime left when a node starts to renew the certificate. */ + /** + * A node with renewal enabled reads this setting at startup, including when a subclass overrides it. + * + * @return Share of the certificate lifetime left when renewal starts, strictly between zero and one. + */ public double getRenewBeforeFraction() { return renewBeforeFraction; } @@ -447,7 +455,8 @@ protected final synchronized SslBundle init() { * @param exec Renewal executor owned and stopped by the node. * @param log Renewal logger. * @param transports Names of the transports currently using this factory, evaluated when logging. - * @throws IgniteException If another running node owns renewal, the renewal window is invalid or certificate metadata is unavailable. + * @throws IgniteException If another running node uses this factory and either node enables renewal, or if renewal is + * enabled and the window is invalid, initialization fails or the factory reports no X.509 chain. */ private synchronized void startRenewal(ScheduledExecutorService exec, IgniteLogger log, Supplier transports) { boolean enabled = isRenewalEnabled(); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java index 218148a7dbc42..71971eab5bcd6 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextProvider.java @@ -100,7 +100,7 @@ void startRenewal(ScheduledExecutorService exec, IgniteLogger log) { NODE_ACCESS.startRenewal(sslFactory, exec, log, this::transports); } - /** @return Time of the next automatic renewal, {@code 0} if none is planned. */ + /** @return Time of the next automatic renewal, or {@code 0} if none is planned or the node has stopped. */ public long nextRenewalTime() { return NODE_ACCESS.nextRenewalTime(sslFactory); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java index 9ee4ee4255b47..3c4d77a636ab2 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/ssl/SslContextRegistry.java @@ -33,7 +33,7 @@ import static org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor; -/** SSL contexts of a node: one provider per configured factory, with automatic renewals and their node-wide failure metric. */ +/** Node SSL context providers shared by transports using the same factory, with automatic renewal and a node-wide failure metric. */ public class SslContextRegistry extends GridProcessorAdapter { /** */ public static final String COMMUNICATION = "communication"; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java index 8940c06561f07..322b7ee4e0d81 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalNodeTest.java @@ -236,7 +236,7 @@ public void testReplacedKeyStoreFileRenewed() throws Exception { @Override protected SslBundle createSslBundle() throws SSLException { if (Thread.currentThread().getName().startsWith("ssl-renewal")) { try { - assertTrue(transportsReady.await(10, TimeUnit.SECONDS)); + transportsReady.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 2488276555bca..abb7a5e07b4f4 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -133,7 +133,7 @@ public void testInvalidSettingsRefused() { assertThrowsWithCause(() -> factory.setRenewBeforeFraction(1), IllegalArgumentException.class); } - /** renew installs all new chains together; their aliases need not match the ones in use. */ + /** Renewal replaces all chains together, even if their aliases have changed. */ @Test public void testRenewInstallsAllNewChains() throws Exception { long now = System.currentTimeMillis(); @@ -167,7 +167,7 @@ public void testRenewalCanRemoveEarliestChain() throws Exception { assertEquals(Map.of("remaining", chain(remaining)), NODE_ACCESS.current(f).certificateChains()); } - /** renew replaces an expired certificate in use with a valid one. */ + /** Renewal replaces an expired certificate in use with a valid one. */ @Test public void testExpiredCertificateRenewed() throws Exception { long now = System.currentTimeMillis(); @@ -183,7 +183,7 @@ public void testExpiredCertificateRenewed() throws Exception { assertEquals(Map.of("valid", chain(valid)), NODE_ACCESS.current(f).certificateChains()); } - /** renew applies the checks of reload: here a candidate without certificate chains and one that is not valid yet are refused. */ + /** Renewal rejects a replacement without certificate chains or with a certificate that is not yet valid. */ @Test public void testRenewalAppliesReloadChecks() throws Exception { long now = System.currentTimeMillis(); @@ -243,8 +243,7 @@ public void testChangedMetadataCannotRenewCachedContext() throws Exception { } /** - * renew refuses new chains unless the earliest expiry across them, authorities included, is later than the one in use: here - * another key with the same expiry, one of two keys renewed, and a later leaf from an authority that expires no later. + * Renewal rejects new chains when another key or the issuing authority still expires at the old earliest expiry. */ @Test public void testRenewalMustAdvanceEarliestExpiry() throws Exception { @@ -309,6 +308,7 @@ public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { exec.runNext(); // Plan renewal. exec.runNext(); // Load the replacement. + assertTrue(exec.isShutdown()); assertSame(inUse, NODE_ACCESS.current(factory)); assertEquals(0, NODE_ACCESS.renewalFailures(factory)); assertEquals(new ReloadOutcome(0, 0, null, 0), NODE_ACCESS.reloadOutcome(factory)); @@ -367,11 +367,13 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { public void testRenewalCanBeDisabledBySubclass() throws Exception { SslBundle noChains = bundle(ca.trustStore(), ca.trustStore()); AbstractSslContextFactory factory = new SslContextFactory() { + /** {@inheritDoc} */ @Override public boolean isRenewalEnabled() { return false; } - @Override public SslBundle createSslBundle() { + /** {@inheritDoc} */ + @Override protected SslBundle createSslBundle() { return noChains; } }; @@ -389,14 +391,16 @@ public void testRenewalCanBeDisabledBySubclass() throws Exception { /** Renewal uses the fraction supplied by a subclass when the node attaches its executor. */ @Test public void testRenewalWindowUsesOverriddenFraction() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + 3 * HOUR), ca.trustStore()); AbstractSslContextFactory factory = new SslContextFactory() { + /** {@inheritDoc} */ @Override public double getRenewBeforeFraction() { return QUARTER; } - @Override public SslBundle createSslBundle() { + /** {@inheritDoc} */ + @Override protected SslBundle createSslBundle() { return startup; } }; @@ -480,7 +484,7 @@ public void testReloadAfterExecutorStops() throws Exception { /** Settings apply at attachment and stay fixed until the node stops, including when a factory was already initialized. */ @Test public void testRenewalSettingsAppliedAtAttachmentAndRestart() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + 3 * HOUR), ca.trustStore()); SslBundle replacement = bundle(ca.keyStoreWithPrivateKey("replacement", now - HOUR, now + 5 * HOUR), ca.trustStore()); TestSslContextFactory f = renewingFactory(QUARTER); @@ -547,7 +551,7 @@ public void testRenewalCanBeDisabledOnRestart() throws Exception { /** A subclass waits for renewal before it reloads, resets failures and schedules renewal for its replacement. */ @Test public void testSubclassInitSerializesWithRenewal() throws Exception { - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + HOUR), ca.trustStore()); SslBundle replacement = bundle(ca.keyStoreWithPrivateKey("replacement", now, now + 2 * HOUR), ca.trustStore()); TestSslContextFactory f = renewingFactory(QUARTER); @@ -663,8 +667,7 @@ public void testReloadClearsRenewalFailuresOnlyWithLaterExpiry() throws Exceptio */ @Test public void testFirstAttemptAtWindowOfEarliestChain() throws Exception { - // X.509 keeps whole seconds. - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); TestCertificateAuthority shortCa = new TestCertificateAuthority("short-ca", now - 2 * HOUR, now + HOUR); KeyStore earliestExpiry = merged( @@ -749,8 +752,7 @@ public void testFailedAttemptsBackOff() throws Exception { */ @Test public void testReloadReplans() throws Exception { - // X.509 keeps whole seconds. - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - 100 * MINUTE, now + 20 * MINUTE), ca.trustStore()); SslBundle reloaded = bundle(ca.keyStoreWithPrivateKey("reloaded", now, now + 2 * HOUR), ca.trustStore()); TestSslContextFactory f = renewingFactory(QUARTER); @@ -880,13 +882,12 @@ public void testRenewalLogsAppliedCertificate() throws Exception { } /** - * A failure is a warning while more than half of the window is left and an error once less is left, the retry a minute later - * comes after expiry or the certificates have expired. In each case the first failure is logged with its reason and stack trace. + * Renewal logs a warning in the first half of the window. It logs an error in the second half, after expiry or when the next + * retry would fall after expiry. Both levels include the reason and stack trace. */ @Test public void testFailureLogLevelFollowsTimeLeft() throws Exception { - // X.509 keeps whole seconds. - long now = System.currentTimeMillis() / SECOND * SECOND; + long now = currentCertificateTime(); assertFailureLogged("More than half of the window left", ca.keyStoreWithPrivateKey("early", now - 100 * MINUTE, now + 20 * MINUTE), QUARTER, "WARN " + FAILED); @@ -988,6 +989,11 @@ private List problems() { .toList(); } + /** @return Current time in milliseconds, truncated to the whole seconds stored in X.509 certificates. */ + private static long currentCertificateTime() { + return System.currentTimeMillis() / SECOND * SECOND; + } + /** Lets tests supply bundles and initialize the factory from a subclass. */ private static class TestSslContextFactory extends SslContextFactory { /** Reloads material through the protected factory method. */ @@ -996,7 +1002,7 @@ private SslBundle reloadFromSubclass() { } /** {@inheritDoc} */ - @Override public SslBundle createSslBundle() throws SSLException { + @Override protected SslBundle createSslBundle() throws SSLException { return super.createSslBundle(); } } From dcb1e827663ebfde6d596627c914e6ab90c4ffd3 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 01:48:30 +0300 Subject: [PATCH 134/242] IGNITE-29211 Keep renewal tests focused on configured settings --- .../ignite/ssl/AbstractSslContextFactory.java | 4 +- .../ignite/internal/ssl/SslRenewalTest.java | 52 ------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 208f1d8d00cce..fefa351f81508 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -196,7 +196,7 @@ public void setProtocols(String... protocols) { } /** - * A node reads this setting at startup, including when a subclass overrides it. + * A node reads this setting at startup. * * @return Whether the node automatically loads replacement certificates before expiry. */ @@ -218,7 +218,7 @@ public void setRenewalEnabled(boolean renewalEnabled) { } /** - * A node with renewal enabled reads this setting at startup, including when a subclass overrides it. + * A node with renewal enabled reads this setting at startup. * * @return Share of the certificate lifetime left when renewal starts, strictly between zero and one. */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index abb7a5e07b4f4..2c94cdd4a6714 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -68,7 +68,6 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; /** * Tests certificate replacement and retries on a factory. The recording executor runs scheduled attempts only when the @@ -362,57 +361,6 @@ public void testRenewalRefusedToStartWithoutChains() throws Exception { IgniteException.class, reason); } - /** A subclass can disable renewal even when configured, without requiring chains or using the executor. */ - @Test - public void testRenewalCanBeDisabledBySubclass() throws Exception { - SslBundle noChains = bundle(ca.trustStore(), ca.trustStore()); - AbstractSslContextFactory factory = new SslContextFactory() { - /** {@inheritDoc} */ - @Override public boolean isRenewalEnabled() { - return false; - } - - /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() { - return noChains; - } - }; - ScheduledExecutorService exec = mock(ScheduledExecutorService.class); - - factory.setRenewalEnabled(true); - factory.create(); - NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); - - assertSame(noChains, NODE_ACCESS.current(factory)); - assertEquals(0, NODE_ACCESS.nextRenewalTime(factory)); - verifyNoInteractions(exec); - } - - /** Renewal uses the fraction supplied by a subclass when the node attaches its executor. */ - @Test - public void testRenewalWindowUsesOverriddenFraction() throws Exception { - long now = currentCertificateTime(); - SslBundle startup = bundle(ca.keyStoreWithPrivateKey("startup", now - HOUR, now + 3 * HOUR), ca.trustStore()); - AbstractSslContextFactory factory = new SslContextFactory() { - /** {@inheritDoc} */ - @Override public double getRenewBeforeFraction() { - return QUARTER; - } - - /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() { - return startup; - } - }; - RecordingExecutor exec = new RecordingExecutor(); - - factory.setRenewalEnabled(true); - NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); - exec.runNext(); - - assertEquals(now + 2 * HOUR, NODE_ACCESS.nextRenewalTime(factory)); - } - /** A subclass must supply a valid window before its renewal schedule can start. */ @Test public void testInvalidOverriddenFractionPreventsRenewal() throws Exception { From bafc0048dcca8e6960fc5261db01eb357f37a69b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 01:49:41 +0300 Subject: [PATCH 135/242] IGNITE-29211 Remove unused test mock import --- .../test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 2c94cdd4a6714..760f0911f55d1 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -64,7 +64,6 @@ import static org.apache.ignite.testframework.TestCertificateAuthority.merged; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; From 32bee264b44f7c44aa906932d5caab826d6b69b4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 02:47:39 +0300 Subject: [PATCH 136/242] IGNITE-29211 Check renewal with an interrupted source thread --- docs/_docs/security/ssl-tls.adoc | 3 ++- .../ignite/ssl/AbstractSslContextFactory.java | 7 ++++--- .../ignite/internal/ssl/SslRenewalTest.java | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 15cceb95f3ed2..1962fc851c699 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -295,12 +295,13 @@ Configure the factory used by each connection type: |=== Connection types on one node that share a factory renew together. Use a separate factory instance for each node with renewal enabled. +A node with renewal enabled does not start if another running node already uses the same instance. To change how early renewal starts, set `renewBeforeFraction` as described in the link:https://ignite.apache.org/releases/{version}/javadoc/org/apache/ignite/ssl/AbstractSslContextFactory.html#setRenewBeforeFraction(double)[AbstractSslContextFactory Javadoc]. Changes to these settings take effect after a node restart. === Check Renewal -Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and next attempt time for each transport. At startup the renewal log lists only the transports registered so far. +Use link:tools/control-script#checking-certificates[`control.sh --ssl status`] to check the loaded certificate and the next attempt time for each factory and its transports. The startup log line `TLS certificates will be renewed automatically` can omit transports that register later. Status lists all of them. If the source still returns the current certificates or the replacement fails validation or loading, the node keeps its current context. It retries after 60 to 90 seconds, then at increasing intervals of up to 90 minutes. Retries continue after expiry. The link:monitoring-metrics/new-metrics#ssl[`ssl.ConsecutiveRenewalFailures`] metric counts these unsuccessful attempts. Check the node log for the cause, fix certificate delivery or configuration, and use reload if you need to apply the correction immediately. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index fefa351f81508..2bce7e81dabb6 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -218,9 +218,10 @@ public void setRenewalEnabled(boolean renewalEnabled) { } /** - * A node with renewal enabled reads this setting at startup. + * A node with renewal enabled reads this setting at startup and does not start unless its value is greater than + * {@code 0} and less than {@code 1}. * - * @return Share of the certificate lifetime left when renewal starts, strictly between zero and one. + * @return Share of the certificate lifetime left when renewal starts. */ public double getRenewBeforeFraction() { return renewBeforeFraction; @@ -429,7 +430,7 @@ protected final synchronized SslBundle init() { SslBundle next = reload ? buildReplacement() : prepareSslBundle(); if (reload) - checkReplacement(next, renewal != null && !renewalExec.isShutdown()); + checkReplacement(next, renewal != null && !renewalStopped()); putInUse(next); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java index 760f0911f55d1..c21b3de27e20c 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/ssl/SslRenewalTest.java @@ -273,11 +273,23 @@ public void testInterruptedSourceCountsAsRenewalFailure() throws Exception { TestSslContextFactory factory = renewingFactory(QUARTER); RecordingExecutor exec = new RecordingExecutor(); - doReturn(startup).doThrow(new SSLException(ISSUER_DOWN, new InterruptedException())).when(factory).createSslBundle(); + doReturn(startup).doAnswer(inv -> { + Thread.currentThread().interrupt(); + + throw new SSLException(ISSUER_DOWN, new InterruptedException()); + }).when(factory).createSslBundle(); NODE_ACCESS.startRenewal(factory, exec, renewalLog, () -> ""); exec.runNext(); // Plan renewal. - exec.runNext(); // Load the replacement. + + try { + exec.runNext(); // Load the replacement. + + assertTrue("Source's interrupt flag", Thread.currentThread().isInterrupted()); + } + finally { + Thread.interrupted(); + } assertSame(startup, NODE_ACCESS.current(factory)); assertEquals(1, NODE_ACCESS.renewalFailures(factory)); @@ -315,7 +327,7 @@ public void testRenewalDiscardsMaterialLoadedDuringShutdown() throws Exception { /** * With renewal attached, reload refuses a bundle without certificate metadata, even after the factory has turned - * renewal off: the renewal settings are fixed until the attached node stops. renew refuses such a bundle too. + * renewal off: the renewal settings are fixed until the attached node stops. Renewal refuses such a bundle too. */ @Test public void testReloadCannotLoseMetadataWithRenewal() throws Exception { From 7b1c2fbfb2df422d485ee3b2df47e9d6674e76e3 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 02:48:29 +0300 Subject: [PATCH 137/242] IGNITE-29209 Clarify custom SSL factory material and protocol requirements --- docs/_docs/security/ssl-tls.adoc | 2 +- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 0aab504a39402..8718c409d060c 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -185,7 +185,7 @@ Now all nodes trust the new certificates. A subclass of `AbstractSslContextFactory` implements `createSslBundle()` to build an initialized context and report the certificate chains loaded with it. For a key store, load it once and pass it to `createSslBundle(store, keyPassword, algorithm)`. -For custom key managers, initialize the context with those managers and the intended trust managers. Apply the configured protocols and cipher suites, then return the context and its private-key chains indexed by alias. +For custom key managers, create the context with `SSLContext.getInstance(getProtocol())` and initialize it with those managers and the intended trust managers. Apply the configured protocols and cipher suites, then return the context and its private-key chains indexed by alias. Use a null chain map if the source cannot report certificates. An empty map means the store has no X.509 key chains. Call `create()` to obtain the accepted context. If a subclass needs a fresh certificate when a node restarts with the same factory, it can call the protected `init()` method and take the chain from the returned bundle. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index e7e32d843ed48..91b7088908395 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -122,7 +122,8 @@ public void setProtocols(String... protocols) { /** * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. Preserve the accepted key managers. Calling {@link #create()} from this hook would recurse on first use. + * loading it. Do not modify a key store or key managers that the accepted context uses. Calling {@link #create()} + * from this hook would recurse on first use. * * @return Fresh context and the certificate metadata available to this factory. * @throws SSLException If the context could not be created. From f45312f5f481b22653f5fc93bc70e6488fb7f972 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 02:48:56 +0300 Subject: [PATCH 138/242] IGNITE-29210 Clarify reload requirements and bound failure timestamps --- docs/_docs/security/ssl-tls.adoc | 6 +++--- .../org/apache/ignite/internal/ssl/SslCertificates.java | 2 +- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 2 +- .../java/org/apache/ignite/ssl/SslContextReloadTest.java | 6 +++++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index e0c449f226fe5..cd315c1952f5e 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -62,7 +62,7 @@ tab:C#/.NET[] tab:C++[unsupported] -- -The keystore must contain the node's certificate and private key. +The keystore must contain the node's certificate, including its private key. The trust store defines which peer certificates the node accepts. Configure trust on all nodes and clients before replacing certificates. @@ -263,8 +263,8 @@ Use a null chain map if the source cannot report certificates. An empty map mean Call `create()` to obtain the accepted context. If a subclass needs a fresh certificate when a node restarts with the same factory, it can call the protected `init()` method and take the chain from the returned bundle. A failed initialization leaves the previous context in use. -After the first successful initialization, both the reload command and a subclass calling `init()` must supply a fresh context. The factory rejects a replacement if it reports no X.509 chains or any certificate in a chain is expired or not yet valid. -These checks also apply when the subclass calls `init()` on node restart. The `--ssl status` command reports the result as a reload. +After the first successful initialization, every `init()` call, from the reload command or from a subclass, needs a fresh context from `createSslBundle()`. The factory rejects a replacement with an empty chain map or with an expired or not-yet-valid certificate in any chain. +These checks also apply when the subclass calls `init()` on node restart, so such a call rejects material that a new factory accepts at startup. The `--ssl status` command reports the result as a reload. If the factory cannot supply metadata, reload can replace the context but cannot check certificate dates. Check peer trust with a new connection. Transports sharing a factory use its new context for new connections. Previously obtained contexts and sockets keep their material. Use a separate factory instance for each node. Nodes sharing an instance also share its reload results and switch to new material together. diff --git a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java index 0fad04c9cc3bf..536a2b4d50d0f 100644 --- a/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java +++ b/modules/commons/src/main/java/org/apache/ignite/internal/ssl/SslCertificates.java @@ -24,7 +24,7 @@ import org.apache.ignite.internal.util.typedef.X; import org.jetbrains.annotations.Nullable; -/** Validity dates and descriptions of X.509 certificate chains. */ +/** Checks X.509 chain dates and formats certificates and failure reasons as message text. */ public class SslCertificates { /** */ private SslCertificates() { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 7c53ee38314b1..978feee3aa057 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -314,7 +314,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * the context and its metadata. Calls run one at a time. A failed initialization leaves the previous material in use. * *

- * The first load, including a retry after failure, can accept no certificate chains or certificates outside their + * The first load, including a retry after failure, accepts material with no certificate chains or with certificates outside their * validity period. After a successful load, calls reject the context already in use, an empty chain map, or an expired * or not-yet-valid certificate in any chain. Check peer trust with a new connection. * diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 7c4e4ae898e41..ec4f0960f2854 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -302,6 +302,7 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { Instant reloadStarted = Instant.ofEpochMilli(System.currentTimeMillis()); String res = reloadFailure(g); + Instant reloadFinished = Instant.ofEpochMilli(System.currentTimeMillis()); assertContains(log, res, node(g) + ": failed on " + COMMUNICATION + ", " + DISCOVERY + " (Certificate provider class is unavailable)\n" + @@ -314,7 +315,10 @@ public void testFailingFactoryDoesNotSkipOthers() throws Exception { "(\\S+): Certificate provider class is unavailable").matcher(status); assertTrue(status, failure.find()); - assertFalse(status, Instant.parse(failure.group(1)).isBefore(reloadStarted)); + Instant failureTime = Instant.parse(failure.group(1)); + + assertFalse(status, failureTime.isBefore(reloadStarted)); + assertFalse(status, failureTime.isAfter(reloadFinished)); assertContains(log, status, "last reload succeeded at"); } From e29bfc913ae6959c93cfa74d8a88f142aa4d89c1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 02:51:40 +0300 Subject: [PATCH 139/242] IGNITE-29209 Mark the key password array nullable --- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 91b7088908395..1a6dc026e33cc 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -141,7 +141,7 @@ public void setProtocols(String... protocols) { * @return Context and nonempty X.509 chains of key entries, ordered by alias. An empty map means no such chains were found. * @throws SSLException If the store cannot be read or the context cannot be created. */ - protected final SslBundle createSslBundle(@Nullable KeyStore store, @Nullable char[] keyPwd, String algorithm) + protected final SslBundle createSslBundle(@Nullable KeyStore store, char @Nullable [] keyPwd, String algorithm) throws SSLException { A.notNull(algorithm, "algorithm"); From a8d6d1f61ef07af62cd6d5249e3f0425875c4f74 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 16:36:35 +0300 Subject: [PATCH 140/242] IGNITE-29209 Limit explicit factory initialization to the SSL context --- docs/_docs/security/ssl-tls.adoc | 25 +- .../jdbc/thin/JdbcThinConnectionSSLTest.java | 8 +- .../ignite/ssl/AbstractSslContextFactory.java | 211 +++------- .../java/org/apache/ignite/ssl/SslBundle.java | 79 ---- .../apache/ignite/ssl/SslContextFactory.java | 33 +- .../GridCommandHandlerWithSslFactoryTest.java | 6 +- .../ignite/ssl/SslContextFactoryInitTest.java | 155 ++++++++ .../ignite/ssl/SslContextMetadataTest.java | 372 ------------------ .../ignite/testsuites/SecurityTestSuite.java | 4 +- 9 files changed, 240 insertions(+), 653 deletions(-) delete mode 100644 modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java delete mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 8718c409d060c..6bef13c6a6a43 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -65,8 +65,6 @@ tab:C++[unsupported] The keystore must contain the node's certificate, including its private key. The trust store must contain the trusted certificates for all other cluster nodes. -If the JVM does not recognize a configured protocol or cipher suite name, the node fails to start. - You can define other properties, such as key algorithm, key store type, or trust manager. See the description of the properties in the <> section. After starting the node, you should see the following messages in the logs: @@ -183,25 +181,12 @@ Now all nodes trust the new certificates. == Custom SSL Context Factories -A subclass of `AbstractSslContextFactory` implements `createSslBundle()` to build an initialized context and report the certificate chains loaded with it. -For a key store, load it once and pass it to `createSslBundle(store, keyPassword, algorithm)`. -For custom key managers, create the context with `SSLContext.getInstance(getProtocol())` and initialize it with those managers and the intended trust managers. Apply the configured protocols and cipher suites, then return the context and its private-key chains indexed by alias. -Use a null chain map if the source cannot report certificates. An empty map means the store has no X.509 key chains. - -Call `create()` to obtain the accepted context. If a subclass needs a fresh certificate when a node restarts with the same factory, it can call the protected `init()` method and take the chain from the returned bundle. -A failed initialization leaves the previous context in use. - -=== Migrating an Existing Factory - -Update and recompile subclasses that use the old `createKeyManagers()` hook, override `create()` or access the protected `sslCtx` field. - -* Move key loading and custom context configuration into `createSslBundle()`. A subclass of `SslContextFactory` can use `super.createSslBundle()` and retain its certificate chains when customizing the context. The hook can throw `SSLException`. -* Remove custom context caching. The base factory retains the accepted bundle. After a replacement, call the final `create()` method again to obtain the new context. -* Direct subclasses of `AbstractSslContextFactory` that implement `checkParameters()` must remove its `@Override` and call it before loading in `createSslBundle()`. Subclasses of `SslContextFactory` can keep that override. -* Rename any existing no-argument `init()` in your subclass. The base factory defines it as final. +A subclass of `AbstractSslContextFactory` can call the protected `init()` method when a node restarts with the same factory. +It reloads the context using the factory's key and trust managers. If the load fails, the base implementation of `create()` still returns the previous context. +An unrecognized protocol or cipher suite name fails initialization, including on first use. -A subclass that only overrides `SslContextFactory.loadKeyStore()` can keep that override. A client that authenticates only the server can return a null key store. -Implementations of `Factory` can continue to supply a context directly. +Subclasses that accessed the old protected `sslCtx` field should use `create()` to get the current context and `init()` to replace it. +If your subclass already declares a no-argument `init()` method, rename it before recompiling. == SslContextFactory Properties diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index 355a91380d310..49f2b9038076b 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -642,7 +642,9 @@ public void testUnsupportedSslProtocol() throws Exception { } } - /** An unsupported key-manager algorithm is reported together with the affected key store. */ + /** + * @throws Exception If failed. + */ @Test public void testInvalidKeyAlgorithm() throws Exception { setSslCtxFactoryToCli = true; @@ -651,7 +653,7 @@ public void testInvalidKeyAlgorithm() throws Exception { startGrids(1); try { - Throwable err = GridTestUtils.assertThrows(log, new Callable() { + GridTestUtils.assertThrows(log, new Callable() { @Override public Object call() throws Exception { Connection c = DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + "&sslKeyAlgorithm=INVALID" + @@ -663,8 +665,6 @@ public void testInvalidKeyAlgorithm() throws Exception { return null; } }, SQLException.class, "Unsupported key algorithm: INVALID"); - - GridTestUtils.assertContains(log, err.getMessage(), "keyStoreFile=" + CLI_KEY_STORE_PATH); } finally { stopAllGrids(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 1a6dc026e33cc..c58c86ed63f71 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -17,33 +17,20 @@ package org.apache.ignite.ssl; -import java.security.GeneralSecurityException; import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; -import org.jetbrains.annotations.Nullable; /** - * Owns the SSL context and the certificate chains loaded with it. An initialized factory retains its material across - * node restarts. A subclass can call {@link #init()} to replace it. Configure the factory before use. + * Base class for SSL context factories. The first {@link #create()} call loads a context. + * A subclass can call {@link #init()} to reload it. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -52,9 +39,6 @@ public abstract class AbstractSslContextFactory implements Factory { /** Default SSL protocol. */ public static final String DFLT_SSL_PROTOCOL = "TLS"; - /** Accepted context and the metadata loaded with it. */ - private transient volatile SslBundle bundle; - /** SSL protocol. */ protected String proto = DFLT_SSL_PROTOCOL; @@ -64,6 +48,9 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; + /** Currently initialized SSL context. */ + private transient volatile SSLContext sslCtx; + /** * Gets protocol for secure transport. * @@ -121,115 +108,16 @@ public void setProtocols(String... protocols) { } /** - * Loads a fresh context and matching certificate metadata from this factory's source. Check source parameters before - * loading it. Do not modify a key store or key managers that the accepted context uses. Calling {@link #create()} - * from this hook would recurse on first use. + * Creates SSL context based on factory settings. * - * @return Fresh context and the certificate metadata available to this factory. - * @throws SSLException If the context could not be created. - */ - protected abstract SslBundle createSslBundle() throws SSLException; - - /** - * Creates a context and certificate metadata from one loaded key store, using this factory's trust and TLS settings. - * Do not modify the store while the context is in use: key managers such as PKIX read it during handshakes. - * Load replacement material into a separate store. - * - * @param store Loaded key store, or {@code null} for a client without private keys. - * @param keyPwd Password for the loaded store's private keys, or {@code null} if no password is required. - * @param algorithm Key manager algorithm. - * @return Context and nonempty X.509 chains of key entries, ordered by alias. An empty map means no such chains were found. - * @throws SSLException If the store cannot be read or the context cannot be created. - */ - protected final SslBundle createSslBundle(@Nullable KeyStore store, char @Nullable [] keyPwd, String algorithm) - throws SSLException { - A.notNull(algorithm, "algorithm"); - - KeyManager[] keyMgrs; - - try { - KeyManagerFactory factory = KeyManagerFactory.getInstance(algorithm); - - factory.init(store, keyPwd); - - keyMgrs = factory.getKeyManagers(); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + algorithm + " [factory=" + this + ']', e); - } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred) [factory=" + this + ']', e); - } - - Map> chains = store == null ? Collections.emptyMap() : certificateChains(store); - - return new SslBundle(createSslContext(keyMgrs), chains); - } - - /** @return Fresh context and certificate chains. */ - private SslBundle prepareSslBundle() { - try { - return createSslBundle(); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } - - /** - * @param store Loaded key store. - * @return Nonempty X.509 chains of key entries; entries without such a chain are omitted. - * @throws SSLException If the store cannot be read. + * @return Initialized SSL context. + * @throws SSLException If SSL context could not be created. */ - private static Map> certificateChains(KeyStore store) throws SSLException { - try { - Map> chains = new HashMap<>(); - - for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { - String alias = aliases.nextElement(); + private SSLContext createSslContext() throws SSLException { + checkParameters(); - if (!store.isKeyEntry(alias)) - continue; + KeyManager[] keyMgrs = createKeyManagers(); - List chain = certificateChain(store.getCertificateChain(alias)); - - if (!chain.isEmpty()) - chains.put(alias, chain); - } - - return chains; - } - catch (KeyStoreException e) { - throw new SSLException("Failed to read certificate chains from key store", e); - } - } - - /** - * @param certs Chain from the store, if present. - * @return X.509 chain, or an empty list if it is absent or contains another certificate type. - */ - private static List certificateChain(@Nullable Certificate[] certs) { - if (certs == null) - return Collections.emptyList(); - - List chain = new ArrayList<>(certs.length); - - for (Certificate cert : certs) { - if (!(cert instanceof X509Certificate)) - return Collections.emptyList(); - - chain.add((X509Certificate)cert); - } - - return chain; - } - - /** - * @param keyMgrs Key managers for the loaded material. - * @return Initialized context. - * @throws SSLException If trust managers or the context cannot be created. - */ - private SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -269,6 +157,19 @@ protected void checkNullParameter(Object param, String name) throws SSLException throw new SSLException("Failed to initialize SSL context (parameter cannot be null): " + name); } + /** + * Checks that all required parameters are set. + * + * @throws SSLException If any of required parameters is missing. + */ + protected abstract void checkParameters() throws SSLException; + + /** + * @return Created Key Managers. + * @throws SSLException If Key Managers could not be created. + */ + protected abstract KeyManager[] createKeyManagers() throws SSLException; + /** * @return Created Trust Managers. * @throws SSLException If Trust Managers could not be created. @@ -276,55 +177,43 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads fresh material and checks that the configured protocols and cipher suites can be applied before publishing - * the context and its metadata. Calls run one at a time. A failed initialization leaves the previous material in use. + * Loads and checks a new SSL context. If initialization fails, the previous context stays in use. * - * @return Context and metadata accepted by this initialization. - * @throws IgniteException If the material cannot be loaded or the context cannot be created. - * @throws IllegalArgumentException If the JVM does not recognize a name in {@link #setProtocols} or {@link #setCipherSuites}. + * @throws IgniteException If the context cannot be initialized. + * @throws IllegalArgumentException If an enabled protocol or cipher suite is unsupported. */ - protected final synchronized SslBundle init() { - SslBundle next = prepareSslBundle(); - - next.context().createSSLEngine(); + protected final synchronized void init() { + try { + SSLContext ctx = createSslContext(); - bundle = next; + if (ctx instanceof SSLContextWrapper) { + // The wrapper applies configured TLS parameters when it creates an engine. + ctx.createSSLEngine(); + } - return next; + sslCtx = ctx; + } + catch (SSLException e) { + throw new IgniteException(e); + } } /** - * Returns the accepted context and its metadata, initializing the factory on first use. + * Returns the current context, initializing it on first use. During a reload, callers get the previous context. * - * @return Accepted bundle. - * @throws IgniteException If the first initialization fails to load the material or create the context. - * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. + * @return Current SSL context. */ - final SslBundle sslBundle() { - SslBundle cur = bundle; - - if (cur == null) { - synchronized (this) { - cur = bundle; + @Override public SSLContext create() { + SSLContext ctx = sslCtx; - if (cur == null) - cur = init(); - } - } + if (ctx != null) + return ctx; - return cur; - } + synchronized (this) { + if (sslCtx == null) + init(); - /** - * Returns the accepted context, initializing the factory on first use. While a replacement loads, callers receive - * the current context without waiting. Call this method again after a successful replacement to obtain the new context. - * Previously returned contexts, engines and sockets retain their original material. Do not reinitialize the context directly. - * - * @return Currently accepted context. - * @throws IgniteException If the first initialization fails to load the material or create the context. - * @throws IllegalArgumentException If the JVM does not recognize a configured protocol or cipher suite name. - */ - @Override public final SSLContext create() { - return sslBundle().context(); + return sslCtx; + } } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java deleted file mode 100644 index 3d08dfb44d0ee..0000000000000 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.security.cert.X509Certificate; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import javax.net.ssl.SSLContext; -import org.apache.ignite.internal.util.typedef.internal.A; -import org.jetbrains.annotations.Nullable; - -/** - * An initialized SSL context and a snapshot of the certificate chains loaded with it. The chains describe its key material, - * not the certificate selected for a particular connection. Do not reinitialize the context after creating the bundle. - */ -public final class SslBundle { - /** */ - private final SSLContext ctx; - - /** */ - private final Map> chains; - - /** - * @param ctx Initialized SSL context. - * @param chains Nonempty X.509 chains of key entries, by alias, the key's certificate first. {@code null} means certificate - * metadata is unavailable; an empty map means there are no chains. Trusted certificate entries and secret keys - * must not be included. - * @throws IllegalArgumentException If a chain is empty. - */ - public SslBundle(SSLContext ctx, @Nullable Map> chains) { - A.notNull(ctx, "ctx"); - - this.ctx = ctx; - - if (chains == null) - this.chains = null; - else { - Map> copy = new TreeMap<>(); - - chains.forEach((alias, chain) -> { - A.ensure(!chain.isEmpty(), "Certificate chain must not be empty [alias=" + alias + ']'); - - copy.put(alias, List.copyOf(chain)); - }); - - this.chains = Collections.unmodifiableMap(copy); - } - } - - /** @return Initialized context. */ - public SSLContext context() { - return ctx; - } - - /** - * @return Immutable, nonempty chains ordered by alias, or {@code null} if certificate metadata is unavailable. - * An empty map means there are no chains. - */ - public @Nullable Map> certificateChains() { - return chains; - } -} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 7f635793b1aa3..ae123d048acc8 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -27,6 +27,9 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -37,8 +40,7 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. * - * The first {@link #create()} call loads the stores. Subsequent calls reuse the accepted material. A subclass can call {@link #init()} - * to reread the stores. + * The first {@link #create()} call loads the stores. A subclass can call {@link #init()} to reread them. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager @@ -259,10 +261,23 @@ public static TrustManager getDisabledTrustManager() { } /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() throws SSLException { - checkParameters(); + @Override protected final KeyManager[] createKeyManagers() throws SSLException { + try { + KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - return createSslBundle(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); + keyMgrFactory.init(keyStore, keyStorePwd); + + return keyMgrFactory.getKeyManagers(); + } + catch (NoSuchAlgorithmException e) { + throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); + } + catch (GeneralSecurityException e) { + throw new SSLException("Failed to initialize Key Manager (security exception occurred) [type=" + + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + } } /** {@inheritDoc} */ @@ -308,12 +323,8 @@ private String parameters() { return buf.toString(); } - /** - * Checks that all required parameters are set. - * - * @throws SSLException If any of required parameters is missing. - */ - protected void checkParameters() throws SSLException { + /** {@inheritDoc} */ + @Override protected void checkParameters() throws SSLException { assert keyStoreType != null; assert proto != null; diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java index a497083501359..ed2828b8af1e7 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerWithSslFactoryTest.java @@ -20,9 +20,7 @@ import java.util.List; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.ssl.SslBundle; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.junit.AfterClass; @@ -94,10 +92,10 @@ public static void tearDown() { */ public static class SslFactory extends SslContextFactory { /** {@inheritDoc} */ - @Override protected SslBundle createSslBundle() throws SSLException { + @Override public SSLContext create() { factoryUsed = true; - return super.createSslBundle(); + return super.create(); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java new file mode 100644 index 0000000000000..79f9e24a1ff12 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -0,0 +1,155 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.junit.Assert.assertArrayEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +/** Tests replacement and lazy initialization of the factory's context. */ +public class SslContextFactoryInitTest extends GridCommonAbstractTest { + /** Initialization applies the new settings while previously returned contexts keep theirs. */ + @Test + public void testInitReplacesContext() { + SslContextFactory factory = factory(); + String[] oldProtocols = {"TLSv1.2"}; + String[] newProtocols = {"TLSv1.3"}; + + factory.setProtocols(oldProtocols); + + SSLContext original = factory.create(); + + factory.setProtocols(newProtocols); + factory.init(); + + SSLContext replacement = factory.create(); + + assertNotSame(original, replacement); + assertArrayEquals(oldProtocols, original.createSSLEngine().getEnabledProtocols()); + assertArrayEquals(newProtocols, replacement.createSSLEngine().getEnabledProtocols()); + } + + /** Both loading and validation failures leave the last working context in place. */ + @Test + public void testFailedInitKeepsContext() { + SslContextFactory factory = factory(); + SSLContext original = factory.create(); + String invalidProtocol = "unsupported-protocol"; + + factory.setProtocols(invalidProtocol); + + GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, invalidProtocol); + assertSame(original, factory.create()); + + factory.setProtocols("TLSv1.2"); + factory.setKeyStorePassword("wrong-password".toCharArray()); + + GridTestUtils.assertThrows(log, factory::init, IgniteException.class, "Failed to initialize key store"); + assertSame(original, factory.create()); + } + + /** Concurrent first callers share the same context and read the key store once. */ + @Test + public void testConcurrentFirstCreateLoadsOnce() throws Exception { + SslContextFactory factory = spy(factory()); + int threads = 4; + CyclicBarrier start = new CyclicBarrier(threads); + AtomicReference shared = new AtomicReference<>(); + + GridTestUtils.runMultiThreaded(() -> { + start.await(); + + SSLContext ctx = factory.create(); + + shared.compareAndSet(null, ctx); + assertSame(shared.get(), ctx); + + return null; + }, threads, "ssl-create"); + + verify(factory).loadKeyStore(anyString(), eq(keyStorePath("node01")), any(char[].class)); + } + + /** Reading the current context does not wait for the replacement key store. */ + @Test + public void testCreateReturnsCurrentContextDuringInit() throws Exception { + SslContextFactory factory = spy(factory()); + SSLContext original = factory.create(); + CountDownLatch loading = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + + doAnswer(inv -> { + loading.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + + return inv.callRealMethod(); + }).when(factory).loadKeyStore(anyString(), eq(keyStorePath("node01")), any(char[].class)); + + IgniteInternalFuture reload = GridTestUtils.runAsync(() -> factory.init()); + + try { + assertTrue(loading.await(10, TimeUnit.SECONDS)); + assertSame(original, GridTestUtils.runAsync(() -> factory.create()).get(10_000)); + } + finally { + release.countDown(); + reload.get(10_000); + } + + assertNotSame(original, factory.create()); + } + + /** Serialization retains the configuration so the restored factory can load its own context. */ + @Test + public void testSerializationRetainsSettings() throws Exception { + SslContextFactory factory = factory(); + String[] protocols = {"TLSv1.2"}; + + factory.setProtocols(protocols); + + SSLContext original = factory.create(); + SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), + getClass().getClassLoader()); + SSLContext replacement = restored.create(); + + assertNotSame(original, replacement); + assertArrayEquals(protocols, replacement.createSSLEngine().getEnabledProtocols()); + } + + /** @return File-based factory using the test stores. */ + private static SslContextFactory factory() { + return (SslContextFactory)sslTrustedFactory("node01", "trustone"); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java deleted file mode 100644 index a1f29b059949b..0000000000000 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * 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. - */ - -package org.apache.ignite.ssl; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.security.KeyStore; -import java.security.KeyStoreSpi; -import java.security.UnrecoverableKeyException; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CyclicBarrier; -import java.util.concurrent.FutureTask; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import javax.crypto.KeyGenerator; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; -import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.IgniteInternalFuture; -import org.apache.ignite.internal.util.typedef.X; -import org.apache.ignite.testframework.GridTestUtils; -import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; -import org.junit.Test; - -import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; -import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; -import static org.junit.Assert.assertArrayEquals; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; - -/** Tests how the factory loads, publishes and retains its context and certificate metadata. */ -public class SslContextMetadataTest extends GridCommonAbstractTest { - /** Each accepted context and its certificate chains come from the same store read. */ - @Test - public void testInitPublishesBundleFromOneKeyStoreRead() throws Exception { - KeyStore first = nodeStore("node01"); - KeyStore second = nodeStore("node02"); - InMemoryStoreFactory factory = new InMemoryStoreFactory(first); - - SslBundle original = factory.init(); - - factory.store = second; - - SslBundle replacement = factory.init(); - - assertEquals(2, factory.loads); - assertEquals(Map.of("node01", chain(first, "node01")), original.certificateChains()); - assertEquals(Map.of("node02", chain(second, "node02")), replacement.certificateChains()); - assertNotSame(original.context(), replacement.context()); - assertSame(replacement.context(), factory.create()); - } - - /** Concurrent requests share the context produced by the first store read. */ - @Test - public void testConcurrentFirstCreateLoadsOnce() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); - int threads = 4; - CyclicBarrier start = new CyclicBarrier(threads); - AtomicReference shared = new AtomicReference<>(); - - GridTestUtils.runMultiThreaded(() -> { - start.await(); - - SSLContext ctx = factory.create(); - - shared.compareAndSet(null, ctx); - assertSame(shared.get(), ctx); - - return null; - }, threads, "ssl-create"); - - assertEquals(1, factory.loads); - } - - /** During a reload, readers get the accepted context while another reload waits to install its own context. */ - @Test - public void testReadersProceedWhileReloadsRunInOrder() throws Exception { - InMemoryStoreFactory factory = spy(new InMemoryStoreFactory(nodeStore("node01"))); - SSLContext accepted = factory.create(); - CountDownLatch loading = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - long timeout = 10_000; - - doAnswer(inv -> { - loading.countDown(); - release.await(); - - return inv.callRealMethod(); - }).doCallRealMethod().when(factory).createSslBundle(); - - IgniteInternalFuture first = GridTestUtils.runAsync(() -> factory.init()); - FutureTask second = new FutureTask<>(factory::init); - Thread secondThread = new Thread(second, "second-init"); - - try { - assertTrue("Replacement load started", loading.await(timeout, TimeUnit.MILLISECONDS)); - assertSame(accepted, GridTestUtils.runAsync(() -> factory.create()).get(timeout)); - - secondThread.start(); - - assertTrue(GridTestUtils.waitForCondition(() -> secondThread.getState() == Thread.State.BLOCKED || second.isDone(), timeout)); - assertFalse("The second reload must wait for the first", second.isDone()); - } - finally { - release.countDown(); - first.get(timeout); - } - - assertSame(second.get(timeout, TimeUnit.MILLISECONDS), factory.sslBundle()); - } - - /** Invalid settings can be corrected after first use fails. A later failure leaves the last accepted bundle usable. */ - @Test - public void testFailedInitKeepsAcceptedBundle() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); - String invalidProtocol = "unsupported-protocol"; - - factory.setProtocols(invalidProtocol); - - GridTestUtils.assertThrows(log, factory::create, IllegalArgumentException.class, invalidProtocol); - - factory.setProtocols((String[])null); - - SslBundle accepted = factory.sslBundle(); - - factory.loadFailure = "Key store is unavailable"; - - GridTestUtils.assertThrows(log, factory::init, IgniteException.class, factory.loadFailure); - assertSame(accepted, factory.sslBundle()); - - factory.loadFailure = null; - factory.setProtocols(invalidProtocol); - - GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, invalidProtocol); - assertSame(accepted, factory.sslBundle()); - } - - /** A context already handed to a consumer retains its TLS settings after the factory reloads. */ - @Test - public void testPreviousContextKeepsItsSettings() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(nodeStore("node01")); - String[] originalProtocols = {"TLSv1.2"}; - String[] replacementProtocols = {"TLSv1.3"}; - - factory.setProtocols(originalProtocols); - - SSLContext original = factory.create(); - - factory.setProtocols(replacementProtocols); - factory.init(); - - assertArrayEquals(originalProtocols, original.createSSLEngine().getEnabledProtocols()); - assertArrayEquals(replacementProtocols, factory.create().createSSLEngine().getEnabledProtocols()); - } - - /** Deserializing a configured factory loads fresh material at first use. */ - @Test - public void testSerializationKeepsConfiguration() throws Exception { - SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); - SslBundle original = factory.init(); - SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), - getClass().getClassLoader()); - - assertNotSame(original.context(), restored.create()); - assertEquals(original.certificateChains(), restored.sslBundle().certificateChains()); - } - - /** A client factory can return no key store when it authenticates only the server. */ - @Test - public void testClientWithoutKeyStore() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(null); - SslBundle client = factory.init(); - - assertEquals(Map.of(), client.certificateChains()); - } - - /** Missing parameters are reported before the key store is read. */ - @Test - public void testInvalidParametersDoNotLoadStore() throws Exception { - InMemoryStoreFactory factory = new InMemoryStoreFactory(null); - - factory.setTrustManagers((TrustManager[])null); - - GridTestUtils.assertThrows(log, factory::create, IgniteException.class, - "either trustStoreFilePath or trustManagers must be provided"); - assertEquals(0, factory.loads); - } - - /** Metadata includes only nonempty X.509 chains of key entries. */ - @Test - public void testOnlyKeyEntriesWithX509Chains() throws Exception { - KeyStore keys = nodeStore("node01"); - KeyStore otherKeys = nodeStore("node02"); - String firstAlias = "node01"; - String secondAlias = "node02"; - char[] pwd = keyStorePassword().toCharArray(); - KeyStore.PasswordProtection protection = new KeyStore.PasswordProtection(pwd); - - // The provider can store a key without a chain, although KeyStore.setKeyEntry rejects it. - KeyStore store = KeyStore.getInstance("JCEKS"); - KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", store.getType()).newInstance(null); - - spi.engineLoad(null, pwd); - spi.engineSetKeyEntry("key-without-chain", keys.getKey(firstAlias, pwd), pwd, null); - - ByteArrayOutputStream encoded = new ByteArrayOutputStream(); - - spi.engineStore(encoded, pwd); - store.load(new ByteArrayInputStream(encoded.toByteArray()), pwd); - store.setEntry(firstAlias, keys.getEntry(firstAlias, protection), protection); - store.setEntry(secondAlias, otherKeys.getEntry(secondAlias, protection), protection); - store.setCertificateEntry("trusted", keys.getCertificate(firstAlias)); - store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), protection); - store.setKeyEntry("mixed-chain", keys.getKey(firstAlias, pwd), pwd, - new Certificate[] {keys.getCertificate(firstAlias), mock(Certificate.class)}); - - // SunX509 requires X.509 entries at initialization. PKIX defers key selection until a handshake. - InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - - factory.setKeyAlgorithm("PKIX"); - - assertEquals(Map.of(firstAlias, chain(keys, firstAlias), secondAlias, chain(otherKeys, secondAlias)), - factory.init().certificateChains()); - } - - /** An empty key store yields a context without private keys and an empty map. */ - @Test - public void testEmptyStoreCreatesKeylessContext() throws Exception { - KeyStore empty = KeyStore.getInstance("JKS"); - - empty.load(null, null); - - SslBundle bundle = new InMemoryStoreFactory(empty).init(); - - assertEquals(Map.of(), bundle.certificateChains()); - } - - /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ - @Test - public void testBundleKeepsSortedImmutableCopy() throws Exception { - X509Certificate aCert = mock(X509Certificate.class); - X509Certificate bCert = mock(X509Certificate.class); - List aChain = new ArrayList<>(List.of(aCert)); - Map> input = new LinkedHashMap<>(); - - input.put("b", List.of(bCert)); - input.put("a", aChain); - - SslBundle bundle = new SslBundle(SSLContext.getDefault(), input); - - aChain.clear(); - input.clear(); - - Map> chains = bundle.certificateChains(); - - assertEquals(List.of("a", "b"), new ArrayList<>(chains.keySet())); - assertEquals(Map.of("a", List.of(aCert), "b", List.of(bCert)), chains); - GridTestUtils.assertThrows(log, () -> chains.put("c", List.of()), UnsupportedOperationException.class, null); - GridTestUtils.assertThrows(log, () -> chains.get("a").set(0, bCert), UnsupportedOperationException.class, null); - } - - /** Custom factories must omit entries without a chain instead of reporting an empty chain. */ - @Test - public void testBundleRejectsEmptyChain() throws Exception { - String alias = "missing-chain"; - - GridTestUtils.assertThrows(log, () -> new SslBundle(SSLContext.getDefault(), Map.of(alias, List.of())), - IllegalArgumentException.class, "Certificate chain must not be empty [alias=" + alias + ']'); - } - - /** Unavailable metadata stays {@code null}, so it is not mistaken for a store without certificate chains. */ - @Test - public void testBundleKeepsUnavailableMetadata() throws Exception { - assertNull(new SslBundle(SSLContext.getDefault(), null).certificateChains()); - } - - /** A key the store password cannot open is reported with the store type and path and keeps the original cause. */ - @Test - public void testKeyPasswordFailureNamesStore() throws Exception { - String alias = "node01"; - KeyStore store = nodeStore(alias); - - store.setKeyEntry(alias, store.getKey(alias, keyStorePassword().toCharArray()), "other-key-password".toCharArray(), - store.getCertificateChain(alias)); - - InMemoryStoreFactory factory = new InMemoryStoreFactory(store); - - String storePath = "unreadable-key.jks"; - - factory.setKeyStoreFilePath(storePath); - - String storeDetails = "keyStoreFile=" + storePath; - Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); - - GridTestUtils.assertContains(log, err.getMessage(), "keyStoreType=" + store.getType()); - assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); - } - - /** Loads one of the node key stores shipped with the test suite. */ - private static KeyStore nodeStore(String name) throws Exception { - return KeyStore.getInstance(new File(keyStorePath(name)), keyStorePassword().toCharArray()); - } - - /** Returns the certificate chain saved in the source store. */ - private static List chain(KeyStore store, String alias) throws Exception { - Certificate[] chain = store.getCertificateChain(alias); - - return List.of(Arrays.copyOf(chain, chain.length, X509Certificate[].class)); - } - - /** - * Reads the key store from memory and counts the reads. A trust manager is supplied directly. - */ - private static class InMemoryStoreFactory extends SslContextFactory { - /** Store returned by the next read. */ - private KeyStore store; - - /** Number of key store reads. */ - private int loads; - - /** Message of the read failure, or {@code null} while the store is readable. */ - private String loadFailure; - - /** @param store Store returned by reads. */ - InMemoryStoreFactory(KeyStore store) { - this.store = store; - - if (store != null) - setKeyStoreType(store.getType()); - - setKeyStoreFilePath("in-memory"); - setKeyStorePassword(keyStorePassword().toCharArray()); - setTrustManagers(getDisabledTrustManager()); - } - - /** {@inheritDoc} */ - @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { - loads++; - - if (loadFailure != null) - throw new SSLException(loadFailure); - - return store; - } - } -} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 13e9204fd95f8..4a2770a20648c 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -80,7 +80,7 @@ import org.apache.ignite.plugin.security.SecurityBasicPermissionSetSerializationTest; import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; -import org.apache.ignite.ssl.SslContextMetadataTest; +import org.apache.ignite.ssl.SslContextFactoryInitTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -144,7 +144,7 @@ IgniteSecurityProcessorTest.class, MultipleSSLContextsTest.class, SSLContextWrapperTest.class, - SslContextMetadataTest.class, + SslContextFactoryInitTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, From c4a94414292622e115d0ab39dc6cc9775d254545 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 16:37:54 +0300 Subject: [PATCH 141/242] IGNITE-29209 Remove unused SSLContext import --- .../src/main/java/org/apache/ignite/ssl/SslContextFactory.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index ae123d048acc8..fbef8c005d5c0 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -29,7 +29,6 @@ import java.util.Arrays; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; From 4410ba4f06d784ae47884ce851c9faa6227ba288 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 16:40:05 +0300 Subject: [PATCH 142/242] IGNITE-29209 Check the initialization exception directly --- .../org/apache/ignite/ssl/SslContextFactoryInitTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 79f9e24a1ff12..5631b667647be 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -31,6 +31,7 @@ import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -70,13 +71,13 @@ public void testFailedInitKeepsContext() { factory.setProtocols(invalidProtocol); - GridTestUtils.assertThrows(log, factory::init, IllegalArgumentException.class, invalidProtocol); + assertThrows(IllegalArgumentException.class, factory::init); assertSame(original, factory.create()); factory.setProtocols("TLSv1.2"); factory.setKeyStorePassword("wrong-password".toCharArray()); - GridTestUtils.assertThrows(log, factory::init, IgniteException.class, "Failed to initialize key store"); + assertThrows(IgniteException.class, factory::init); assertSame(original, factory.create()); } From fbff93fe95e23c957b7bbdb461d91e12ca822a28 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 16:58:00 +0300 Subject: [PATCH 143/242] IGNITE-29209 Retain the protected SSL context cache --- docs/_docs/security/ssl-tls.adoc | 1 - .../ignite/ssl/AbstractSslContextFactory.java | 26 ++++++++++++++----- .../ignite/ssl/SslContextFactoryInitTest.java | 12 +++++---- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 6bef13c6a6a43..df6a8a1486b1b 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -185,7 +185,6 @@ A subclass of `AbstractSslContextFactory` can call the protected `init()` method It reloads the context using the factory's key and trust managers. If the load fails, the base implementation of `create()` still returns the previous context. An unrecognized protocol or cipher suite name fails initialization, including on first use. -Subclasses that accessed the old protected `sslCtx` field should use `create()` to get the current context and `init()` to replace it. If your subclass already declares a no-argument `init()` method, rename it before recompiling. == SslContextFactory Properties diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index c58c86ed63f71..5573b879b346f 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -19,6 +19,7 @@ import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; +import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; @@ -48,8 +49,8 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; - /** Currently initialized SSL context. */ - private transient volatile SSLContext sslCtx; + /** Cached instance of an {@link SSLContext}. */ + protected final AtomicReference sslCtx = new AtomicReference<>(); /** * Gets protocol for secure transport. @@ -183,6 +184,11 @@ protected void checkNullParameter(Object param, String name) throws SSLException * @throws IllegalArgumentException If an enabled protocol or cipher suite is unsupported. */ protected final synchronized void init() { + sslCtx.set(createCheckedSslContext()); + } + + /** @return A new context with its TLS settings checked. */ + private SSLContext createCheckedSslContext() { try { SSLContext ctx = createSslContext(); @@ -191,7 +197,7 @@ protected final synchronized void init() { ctx.createSSLEngine(); } - sslCtx = ctx; + return ctx; } catch (SSLException e) { throw new IgniteException(e); @@ -204,16 +210,22 @@ protected final synchronized void init() { * @return Current SSL context. */ @Override public SSLContext create() { - SSLContext ctx = sslCtx; + SSLContext ctx = sslCtx.get(); if (ctx != null) return ctx; synchronized (this) { - if (sslCtx == null) - init(); + ctx = sslCtx.get(); + + if (ctx != null) + return ctx; + + SSLContext candidate = createCheckedSslContext(); + + ctx = sslCtx.compareAndExchange(null, candidate); - return sslCtx; + return ctx == null ? candidate : ctx; } } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 5631b667647be..ed79e29da5e87 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -58,8 +58,13 @@ public void testInitReplacesContext() { SSLContext replacement = factory.create(); assertNotSame(original, replacement); + assertSame(replacement, factory.sslCtx.get()); assertArrayEquals(oldProtocols, original.createSSLEngine().getEnabledProtocols()); assertArrayEquals(newProtocols, replacement.createSSLEngine().getEnabledProtocols()); + + factory.sslCtx.set(original); + + assertSame(original, factory.create()); } /** Both loading and validation failures leave the last working context in place. */ @@ -132,7 +137,7 @@ public void testCreateReturnsCurrentContextDuringInit() throws Exception { assertNotSame(original, factory.create()); } - /** Serialization retains the configuration so the restored factory can load its own context. */ + /** A factory serialized before first use can still load its context after being restored. */ @Test public void testSerializationRetainsSettings() throws Exception { SslContextFactory factory = factory(); @@ -140,13 +145,10 @@ public void testSerializationRetainsSettings() throws Exception { factory.setProtocols(protocols); - SSLContext original = factory.create(); SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), getClass().getClassLoader()); - SSLContext replacement = restored.create(); - assertNotSame(original, replacement); - assertArrayEquals(protocols, replacement.createSSLEngine().getEnabledProtocols()); + assertArrayEquals(protocols, restored.create().createSSLEngine().getEnabledProtocols()); } /** @return File-based factory using the test stores. */ From 7d9331f58d839e2009f8ea576bef514e38ba1002 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 17:12:47 +0300 Subject: [PATCH 144/242] IGNITE-29209 Keep the first fix scoped to explicit SSL context reload --- docs/_docs/security/ssl-tls.adoc | 3 +- .../jdbc/thin/JdbcThinConnectionSSLTest.java | 39 ++++++++----- .../ignite/ssl/AbstractSslContextFactory.java | 52 +++++------------ .../apache/ignite/ssl/SslContextFactory.java | 2 - .../ignite/ssl/SslContextFactoryInitTest.java | 56 ++----------------- 5 files changed, 47 insertions(+), 105 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index df6a8a1486b1b..64761ab2943c2 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -182,8 +182,7 @@ Now all nodes trust the new certificates. == Custom SSL Context Factories A subclass of `AbstractSslContextFactory` can call the protected `init()` method when a node restarts with the same factory. -It reloads the context using the factory's key and trust managers. If the load fails, the base implementation of `create()` still returns the previous context. -An unrecognized protocol or cipher suite name fails initialization, including on first use. +If the load fails, the base implementation of `create()` still returns the previous context. If your subclass already declares a no-argument `init()` method, rename it before recompiling. diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java index 49f2b9038076b..c97fbe559117a 100644 --- a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinConnectionSSLTest.java @@ -447,28 +447,41 @@ public void testUnsupportedCustomCipher() throws Exception { } } - /** An unknown cipher suite prevents a node from starting or a client from connecting. */ + /** + * @throws Exception If failed. + */ @Test public void testInvalidCustomCipher() throws Exception { setSslCtxFactoryToCli = true; supportedCiphers = new String[] {"TLS_RSA_WITH_INVALID_SHA256"}; sslCtxFactory = getTestSslContextFactory(); - GridTestUtils.assertThrowsAnyCause(log, () -> startGrids(1), IllegalArgumentException.class, "TLS_RSA_WITH_INVALID_SHA256"); + startGrids(1); - supportedCiphers = null; - sslCtxFactory = getTestSslContextFactory(); + try { + // Same invalid cipher. + GridTestUtils.assertThrows(log, () -> { + return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + + "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + + "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + + "&sslClientCertificateKeyStorePassword=123456" + + "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + + "&sslTrustCertificateKeyStorePassword=123456"); + }, SQLException.class, "Failed to connect to server"); - startGrids(1); + // Default ciphers. + GridTestUtils.assertThrows(log, () -> { + return DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + + "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + + "&sslClientCertificateKeyStorePassword=123456" + + "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + + "&sslTrustCertificateKeyStorePassword=123456"); + }, SQLException.class, "Failed to SSL connect to server"); + } + finally { + stopAllGrids(); + } - GridTestUtils.assertThrows(log, () -> - DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/?sslMode=require" + - "&sslCipherSuites=TLS_RSA_WITH_INVALID_SHA256" + - "&sslClientCertificateKeyStoreUrl=" + CLI_KEY_STORE_PATH + - "&sslClientCertificateKeyStorePassword=123456" + - "&sslTrustCertificateKeyStoreUrl=" + TRUST_KEY_STORE_PATH + - "&sslTrustCertificateKeyStorePassword=123456"), - SQLException.class, "Failed to connect to server"); } /** diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 5573b879b346f..c17a7c3337fba 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -29,10 +29,7 @@ import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; -/** - * Base class for SSL context factories. The first {@link #create()} call loads a context. - * A subclass can call {@link #init()} to reload it. - */ +/** Base class for SSL context factories that let subclasses {@linkplain #init() reload} the cached context. */ public abstract class AbstractSslContextFactory implements Factory { /** */ private static final long serialVersionUID = 0L; @@ -178,54 +175,35 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads and checks a new SSL context. If initialization fails, the previous context stays in use. + * Loads a new SSL context. If loading fails, the previous context stays in use. * * @throws IgniteException If the context cannot be initialized. - * @throws IllegalArgumentException If an enabled protocol or cipher suite is unsupported. */ protected final synchronized void init() { - sslCtx.set(createCheckedSslContext()); - } - - /** @return A new context with its TLS settings checked. */ - private SSLContext createCheckedSslContext() { try { - SSLContext ctx = createSslContext(); - - if (ctx instanceof SSLContextWrapper) { - // The wrapper applies configured TLS parameters when it creates an engine. - ctx.createSSLEngine(); - } - - return ctx; + sslCtx.set(createSslContext()); } catch (SSLException e) { throw new IgniteException(e); } } - /** - * Returns the current context, initializing it on first use. During a reload, callers get the previous context. - * - * @return Current SSL context. - */ + /** {@inheritDoc} */ @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); - if (ctx != null) - return ctx; - - synchronized (this) { - ctx = sslCtx.get(); - - if (ctx != null) - return ctx; - - SSLContext candidate = createCheckedSslContext(); + if (ctx == null) { + try { + ctx = createSslContext(); - ctx = sslCtx.compareAndExchange(null, candidate); - - return ctx == null ? candidate : ctx; + if (!sslCtx.compareAndSet(null, ctx)) + ctx = sslCtx.get(); + } + catch (SSLException e) { + throw new IgniteException(e); + } } + + return ctx; } } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index fbef8c005d5c0..5db5c511c7b1e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -38,8 +38,6 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. - * - * The first {@link #create()} call loads the stores. A subclass can call {@link #init()} to reread them. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index ed79e29da5e87..9befa1420f98e 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -18,9 +18,7 @@ package org.apache.ignite.ssl; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteInternalFuture; @@ -37,11 +35,10 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -/** Tests replacement and lazy initialization of the factory's context. */ +/** */ public class SslContextFactoryInitTest extends GridCommonAbstractTest { - /** Initialization applies the new settings while previously returned contexts keep theirs. */ + /** */ @Test public void testInitReplacesContext() { SslContextFactory factory = factory(); @@ -67,48 +64,19 @@ public void testInitReplacesContext() { assertSame(original, factory.create()); } - /** Both loading and validation failures leave the last working context in place. */ + /** */ @Test public void testFailedInitKeepsContext() { SslContextFactory factory = factory(); SSLContext original = factory.create(); - String invalidProtocol = "unsupported-protocol"; - factory.setProtocols(invalidProtocol); - - assertThrows(IllegalArgumentException.class, factory::init); - assertSame(original, factory.create()); - - factory.setProtocols("TLSv1.2"); factory.setKeyStorePassword("wrong-password".toCharArray()); assertThrows(IgniteException.class, factory::init); assertSame(original, factory.create()); } - /** Concurrent first callers share the same context and read the key store once. */ - @Test - public void testConcurrentFirstCreateLoadsOnce() throws Exception { - SslContextFactory factory = spy(factory()); - int threads = 4; - CyclicBarrier start = new CyclicBarrier(threads); - AtomicReference shared = new AtomicReference<>(); - - GridTestUtils.runMultiThreaded(() -> { - start.await(); - - SSLContext ctx = factory.create(); - - shared.compareAndSet(null, ctx); - assertSame(shared.get(), ctx); - - return null; - }, threads, "ssl-create"); - - verify(factory).loadKeyStore(anyString(), eq(keyStorePath("node01")), any(char[].class)); - } - - /** Reading the current context does not wait for the replacement key store. */ + /** */ @Test public void testCreateReturnsCurrentContextDuringInit() throws Exception { SslContextFactory factory = spy(factory()); @@ -137,21 +105,7 @@ public void testCreateReturnsCurrentContextDuringInit() throws Exception { assertNotSame(original, factory.create()); } - /** A factory serialized before first use can still load its context after being restored. */ - @Test - public void testSerializationRetainsSettings() throws Exception { - SslContextFactory factory = factory(); - String[] protocols = {"TLSv1.2"}; - - factory.setProtocols(protocols); - - SslContextFactory restored = TEST_JDK_MARSHALLER.unmarshal(TEST_JDK_MARSHALLER.marshal(factory), - getClass().getClassLoader()); - - assertArrayEquals(protocols, restored.create().createSSLEngine().getEnabledProtocols()); - } - - /** @return File-based factory using the test stores. */ + /** */ private static SslContextFactory factory() { return (SslContextFactory)sslTrustedFactory("node01", "trustone"); } From b86cc224ebb15f5a3531dfb5170c235ae0a4ef7b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 19:20:53 +0300 Subject: [PATCH 145/242] IGNITE-29209 Narrow the explicit SSL initialization contract --- docs/_docs/security/ssl-tls.adoc | 9 +-- .../ignite/ssl/AbstractSslContextFactory.java | 10 ++- .../ignite/ssl/SslContextFactoryInitTest.java | 75 +++++-------------- 3 files changed, 26 insertions(+), 68 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 64761ab2943c2..b56b2094d586c 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -30,7 +30,7 @@ This certificate must be trusted by all other server nodes. To enable SSL/TLS for cluster nodes, configure an `SSLContext` factory in the node configuration. You can use the `org.apache.ignite.ssl.SslContextFactory`, which is the default factory that uses a configurable keystore to initialize the SSL context. -To implement your own `SSLContext` factory, see <>. +//You can also implement your own `SSLContext` factory. [CAUTION] ==== @@ -179,13 +179,6 @@ Now all nodes trust the new certificates. //Otherwise, first you have to push the trust store to all nodes one by one. It will contain trusts for both new and old certificates while you transition. -== Custom SSL Context Factories - -A subclass of `AbstractSslContextFactory` can call the protected `init()` method when a node restarts with the same factory. -If the load fails, the base implementation of `create()` still returns the previous context. - -If your subclass already declares a no-argument `init()` method, rename it before recompiling. - == SslContextFactory Properties `SslContextFactory` supports the following properties: diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index c17a7c3337fba..c88ceb9d30587 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -29,7 +29,10 @@ import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.typedef.internal.A; -/** Base class for SSL context factories that let subclasses {@linkplain #init() reload} the cached context. */ +/** + * Base class for SSL context factories. {@link #create()} caches the first successfully loaded + * {@link SSLContext} and returns it until {@link #init()} replaces it. + */ public abstract class AbstractSslContextFactory implements Factory { /** */ private static final long serialVersionUID = 0L; @@ -175,11 +178,12 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads a new SSL context. If loading fails, the previous context stays in use. + * Loads a new SSL context and makes the base implementation of {@link #create()} return it. + * If a context is already cached, the base implementation keeps returning it until a load succeeds. * * @throws IgniteException If the context cannot be initialized. */ - protected final synchronized void init() { + protected final void init() { try { sslCtx.set(createSslContext()); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 9befa1420f98e..1c068ed180e82 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -17,47 +17,42 @@ package org.apache.ignite.ssl; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.IgniteInternalFuture; -import org.apache.ignite.testframework.GridTestUtils; -import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; /** */ -public class SslContextFactoryInitTest extends GridCommonAbstractTest { +public class SslContextFactoryInitTest { /** */ @Test - public void testInitReplacesContext() { - SslContextFactory factory = factory(); - String[] oldProtocols = {"TLSv1.2"}; - String[] newProtocols = {"TLSv1.3"}; + public void testInitReplacesContextAfterLoad() throws Exception { + SslContextFactory factory = spy((SslContextFactory)sslTrustedFactory("node01", "trustone")); - factory.setProtocols(oldProtocols); + factory.setProtocols("TLSv1.2"); SSLContext original = factory.create(); + AtomicReference duringInit = new AtomicReference<>(); - factory.setProtocols(newProtocols); - factory.init(); + doAnswer(inv -> { + duringInit.set(runAsync(factory::create).get(10_000)); - SSLContext replacement = factory.create(); + return inv.callRealMethod(); + }).doCallRealMethod().when(factory).createKeyManagers(); + + factory.setProtocols("TLSv1.3"); + factory.init(); - assertNotSame(original, replacement); - assertSame(replacement, factory.sslCtx.get()); - assertArrayEquals(oldProtocols, original.createSSLEngine().getEnabledProtocols()); - assertArrayEquals(newProtocols, replacement.createSSLEngine().getEnabledProtocols()); + assertSame(original, duringInit.get()); + assertArrayEquals(new String[] {"TLSv1.3"}, factory.create().createSSLEngine().getEnabledProtocols()); factory.sslCtx.set(original); @@ -67,7 +62,7 @@ public void testInitReplacesContext() { /** */ @Test public void testFailedInitKeepsContext() { - SslContextFactory factory = factory(); + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); SSLContext original = factory.create(); factory.setKeyStorePassword("wrong-password".toCharArray()); @@ -75,38 +70,4 @@ public void testFailedInitKeepsContext() { assertThrows(IgniteException.class, factory::init); assertSame(original, factory.create()); } - - /** */ - @Test - public void testCreateReturnsCurrentContextDuringInit() throws Exception { - SslContextFactory factory = spy(factory()); - SSLContext original = factory.create(); - CountDownLatch loading = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - - doAnswer(inv -> { - loading.countDown(); - assertTrue(release.await(10, TimeUnit.SECONDS)); - - return inv.callRealMethod(); - }).when(factory).loadKeyStore(anyString(), eq(keyStorePath("node01")), any(char[].class)); - - IgniteInternalFuture reload = GridTestUtils.runAsync(() -> factory.init()); - - try { - assertTrue(loading.await(10, TimeUnit.SECONDS)); - assertSame(original, GridTestUtils.runAsync(() -> factory.create()).get(10_000)); - } - finally { - release.countDown(); - reload.get(10_000); - } - - assertNotSame(original, factory.create()); - } - - /** */ - private static SslContextFactory factory() { - return (SslContextFactory)sslTrustedFactory("node01", "trustone"); - } } From ef19a92417f234aba43f60f8c022356ba1e614ac Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 19:41:45 +0300 Subject: [PATCH 146/242] IGNITE-29209 Test SSL cache overrides separately --- .../org/apache/ignite/ssl/SslContextFactoryInitTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 1c068ed180e82..bc692c58e7f5b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -53,6 +53,15 @@ public void testInitReplacesContextAfterLoad() throws Exception { assertSame(original, duringInit.get()); assertArrayEquals(new String[] {"TLSv1.3"}, factory.create().createSSLEngine().getEnabledProtocols()); + } + + /** */ + @Test + public void testCreateUsesContextSetAfterInit() { + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + SSLContext original = factory.create(); + + factory.init(); factory.sslCtx.set(original); From 460d6b3b521e5abe67554ee223441b0ac6a967ad Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 19:53:09 +0300 Subject: [PATCH 147/242] IGNITE-29209 Pause SSL init at key store open in test --- .../java/org/apache/ignite/ssl/SslContextFactoryInitTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index bc692c58e7f5b..ca2f09a9c2d60 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -41,12 +41,14 @@ public void testInitReplacesContextAfterLoad() throws Exception { SSLContext original = factory.create(); AtomicReference duringInit = new AtomicReference<>(); + String keyStorePath = factory.getKeyStoreFilePath(); + // Pause init() just before it opens the key store and read the cached context from another thread. doAnswer(inv -> { duringInit.set(runAsync(factory::create).get(10_000)); return inv.callRealMethod(); - }).doCallRealMethod().when(factory).createKeyManagers(); + }).doCallRealMethod().when(factory).openFileInputStream(keyStorePath); factory.setProtocols("TLSv1.3"); factory.init(); From 99917c95534a067d33c7c34d2e29bbd6582c3dbf Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 20:00:27 +0300 Subject: [PATCH 148/242] IGNITE-29209 Test the outcomes of explicit SSL initialization --- .../ignite/ssl/AbstractSslContextFactory.java | 7 +++---- .../ignite/ssl/SslContextFactoryInitTest.java | 21 +++---------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index c88ceb9d30587..5139f98bc396b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -30,8 +30,7 @@ import org.apache.ignite.internal.util.typedef.internal.A; /** - * Base class for SSL context factories. {@link #create()} caches the first successfully loaded - * {@link SSLContext} and returns it until {@link #init()} replaces it. + * Base class for SSL context factories that cache their {@link SSLContext}. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -178,8 +177,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads a new SSL context and makes the base implementation of {@link #create()} return it. - * If a context is already cached, the base implementation keeps returning it until a load succeeds. + * Loads a new SSL context and caches it for the base implementation of {@link #create()}. + * If loading fails, the previously cached context is kept. * * @throws IgniteException If the context cannot be initialized. */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index ca2f09a9c2d60..92d95bd24e026 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -17,43 +17,28 @@ package org.apache.ignite.ssl; -import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteException; import org.junit.Test; -import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.spy; /** */ public class SslContextFactoryInitTest { /** */ @Test - public void testInitReplacesContextAfterLoad() throws Exception { - SslContextFactory factory = spy((SslContextFactory)sslTrustedFactory("node01", "trustone")); + public void testInitUsesUpdatedProtocols() { + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); factory.setProtocols("TLSv1.2"); - - SSLContext original = factory.create(); - AtomicReference duringInit = new AtomicReference<>(); - String keyStorePath = factory.getKeyStoreFilePath(); - - // Pause init() just before it opens the key store and read the cached context from another thread. - doAnswer(inv -> { - duringInit.set(runAsync(factory::create).get(10_000)); - - return inv.callRealMethod(); - }).doCallRealMethod().when(factory).openFileInputStream(keyStorePath); + factory.create(); factory.setProtocols("TLSv1.3"); factory.init(); - assertSame(original, duringInit.get()); assertArrayEquals(new String[] {"TLSv1.3"}, factory.create().createSSLEngine().getEnabledProtocols()); } From 0d8a872f3551ce4172320ef218c4c43bdd33d04d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 5 Nov 2026 20:04:06 +0300 Subject: [PATCH 149/242] IGNITE-29209 Drop the direct SSL cache mutation test --- .../ignite/ssl/SslContextFactoryInitTest.java | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 92d95bd24e026..5c3b9564b0916 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -42,19 +42,6 @@ public void testInitUsesUpdatedProtocols() { assertArrayEquals(new String[] {"TLSv1.3"}, factory.create().createSSLEngine().getEnabledProtocols()); } - /** */ - @Test - public void testCreateUsesContextSetAfterInit() { - SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); - SSLContext original = factory.create(); - - factory.init(); - - factory.sslCtx.set(original); - - assertSame(original, factory.create()); - } - /** */ @Test public void testFailedInitKeepsContext() { From c10814832400e96722c511933ad2837250684de0 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 20:55:07 +0300 Subject: [PATCH 150/242] IGNITE-29256 Use the current SSL context for new TCP connections --- .../ignite/ssl/AbstractSslContextFactory.java | 9 +- .../odbc/ClientListenerProcessor.java | 2 +- .../protocols/tcp/GridTcpRestProtocol.java | 22 +- .../ignite/internal/util/IgniteUtils.java | 12 +- .../tcp/internal/GridNioServerWrapper.java | 7 +- .../ignite/spi/discovery/tcp/ServerImpl.java | 33 ++- .../spi/discovery/tcp/TcpDiscoverySpi.java | 85 +++++- .../internal/util/nio/GridNioSslSelfTest.java | 70 +++++ .../TcpDiscoverySslConnectionFailureTest.java | 181 ++++++++++++ .../ignite/ssl/SslContextReloadTest.java | 268 ++++++++++++++++++ .../IgniteSpiDiscoverySelfTestSuite.java | 2 + .../ignite/testsuites/SecurityTestSuite.java | 2 + .../util/nio/ssl/GridNioSslFilter.java | 37 ++- 13 files changed, 695 insertions(+), 35 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 5139f98bc396b..b7256d7c11810 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -191,7 +191,14 @@ protected final void init() { } } - /** {@inheritDoc} */ + /** + * Returns the cached SSL context, creating it if the cache is empty. + * Ignite calls this method for each new discovery, communication, client connector or binary REST connection, + * including on NIO threads. + * + * @return Current SSL context. + * @throws IgniteException If the context cannot be initialized. + */ @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java index 1186bc3427c2b..2d67eacfddc4d 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java @@ -537,7 +537,7 @@ else if (connCtx.managementClient()) { throw new IgniteCheckedException("Failed to create client listener " + "(SSL is enabled but factory is null). Check the ClientConnectorConfiguration"); - GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory.create(), + GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtxFactory.create(), true, ByteOrder.nativeOrder(), log, ctx.metric().registry(CLIENT_CONNECTOR_METRICS)); sslFilter.directMode(true); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index b0d3502b25027..9e4e908cc7b4c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -90,28 +90,28 @@ public GridTcpRestProtocol(GridKernalContext ctx) { try { host = resolveRestTcpHost(ctx.config()); + Factory sslCtxFactory = null; SSLContext sslCtx = null; if (cfg.isSslEnabled()) { - Factory igniteFactory = ctx.config().getSslContextFactory(); + sslCtxFactory = cfg.getSslFactory(); - Factory factory = cfg.getSslFactory(); + if (sslCtxFactory == null) + sslCtxFactory = ctx.config().getSslContextFactory(); - if (factory == null && igniteFactory == null) + if (sslCtxFactory == null) // Thrown SSL exception instead of IgniteCheckedException for writing correct warning message into log. throw new SSLException("SSL is enabled, but SSL context factory is not specified."); - if (factory != null) - sslCtx = factory.create(); - else - sslCtx = igniteFactory.create(); + sslCtx = sslCtxFactory.create(); } + int startPort = cfg.getPort(); int portRange = cfg.getPortRange(); int lastPort = portRange == 0 ? startPort : startPort + portRange - 1; for (int port0 = startPort; port0 <= lastPort; port0++) { - if (startTcpServer(host, port0, lsnr, parser, sslCtx, cfg)) { + if (startTcpServer(host, port0, lsnr, parser, sslCtxFactory, sslCtx, cfg)) { port = port0; if (log.isInfoEnabled()) @@ -186,13 +186,14 @@ private InetAddress resolveRestTcpHost(IgniteConfiguration cfg) throws IOExcepti * @param port Port on which server should be bound. * @param lsnr Server message listener. * @param parser Server message parser. + * @param sslCtxFactory SSL context factory if SSL is enabled. * @param sslCtx SSL context in case if SSL is enabled. * @param cfg Configuration for other parameters. * @return {@code True} if server successfully started, {@code false} if port is used and * server was unable to start. */ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerListener lsnr, - GridNioParser parser, @Nullable SSLContext sslCtx, ConnectorConfiguration cfg) { + GridNioParser parser, @Nullable Factory sslCtxFactory, @Nullable SSLContext sslCtx, ConnectorConfiguration cfg) { try { GridNioFilter codec = new GridNioCodecFilter(parser, log, false); @@ -201,7 +202,10 @@ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerList MetricRegistryImpl mreg = ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME); if (sslCtx != null) { + assert sslCtxFactory != null; + GridNioSslFilter sslFilter = U.sslFilter( + sslCtxFactory, sslCtx, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index cbcd98495d04d..7413a4978e4dd 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -114,6 +114,7 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.function.Consumer; import java.util.function.LongConsumer; +import java.util.function.Supplier; import java.util.jar.JarFile; import java.util.logging.ConsoleHandler; import java.util.logging.Handler; @@ -125,6 +126,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; +import javax.cache.configuration.Factory; import javax.management.DynamicMBean; import javax.management.JMException; import javax.management.MBeanRegistrationException; @@ -225,6 +227,7 @@ import org.apache.ignite.spi.discovery.DiscoverySpiCustomMessage; import org.apache.ignite.spi.discovery.DiscoverySpiOrderSupport; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.transactions.TransactionDeadlockException; import org.apache.ignite.transactions.TransactionHeuristicException; import org.apache.ignite.transactions.TransactionOptimisticException; @@ -7730,7 +7733,8 @@ public static void registerNioServerMetrics(GridNioServer srv, GridNioFilter[ /** * Creates an SSL NIO filter, wiring its metrics from the given registry. * - * @param sslCtx SSL context. + * @param sslCtxFactory Configured SSL context factory. + * @param sslCtx SSL context obtained at transport startup. * @param directBuf Direct buffer flag. * @param order Byte order. * @param log Logger to use. @@ -7738,6 +7742,7 @@ public static void registerNioServerMetrics(GridNioServer srv, GridNioFilter[ * @return SSL NIO filter. */ public static GridNioSslFilter sslFilter( + Factory sslCtxFactory, SSLContext sslCtx, boolean directBuf, ByteOrder order, @@ -7753,6 +7758,9 @@ public static GridNioSslFilter sslFilter( GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME, "TCP sessions count that were rejected due to SSL errors.")::increment; - return new GridNioSslFilter(sslCtx, directBuf, order, log, handshakeDuration, rejectedSesCnt); + Supplier sslCtxSupplier = sslCtxFactory instanceof AbstractSslContextFactory + ? sslCtxFactory::create : () -> sslCtx; + + return new GridNioSslFilter(sslCtxSupplier, directBuf, order, log, handshakeDuration, rejectedSesCnt); } } diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index 9368f9f8a83db..902a8c06106a8 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -40,6 +40,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteCheckedException; @@ -918,8 +920,11 @@ private IgniteMessageFactory get() { filters.add(new GridConnectionBytesVerifyFilter(log)); if (stateProvider.isSslEnabled()) { + Factory sslCtxFactory = igniteCfg.getSslContextFactory(); + GridNioSslFilter sslFilter = U.sslFilter( - igniteCfg.getSslContextFactory().create(), + sslCtxFactory, + sslCtxFactory.create(), true, ByteOrder.LITTLE_ENDIAN, log, diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index 4c5e3c72a42ce..9acb2fe592e64 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -63,7 +63,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLSocket; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteCheckedException; @@ -6284,6 +6283,9 @@ private class TcpServer extends GridWorker { /** Socket TCP server listens to. */ private ServerSocket srvrSock; + /** Whether this listener accepts TLS connections. */ + private final boolean sslEnabled; + /** Port to listen. */ private int port; @@ -6294,20 +6296,13 @@ private class TcpServer extends GridWorker { TcpServer(IgniteLogger log) throws IgniteSpiException { super(ctx.igniteInstanceName(), "tcp-disco-srvr-[]", log, ctx.workersRegistry()); + sslEnabled = spi.isSslEnabled(); + int lastPort = spi.locPortRange == 0 ? spi.locPort : spi.locPort + spi.locPortRange - 1; for (port = spi.locPort; port <= lastPort; port++) { try { - if (spi.isSslEnabled()) { - SSLServerSocket sslSock = (SSLServerSocket)spi.sslSrvSockFactory - .createServerSocket(port, 0, spi.locHost); - - sslSock.setNeedClientAuth(true); - - srvrSock = sslSock; - } - else - srvrSock = new ServerSocket(port, 0, spi.locHost); + srvrSock = new ServerSocket(port, 0, spi.locHost); if (log.isInfoEnabled()) { log.info("Successfully bound to TCP port [port=" + port + @@ -6353,6 +6348,22 @@ private class TcpServer extends GridWorker { blockingSectionEnd(); } + if (sslEnabled) { + try { + sock = spi.wrapAcceptedSocket(sock); + } + catch (IOException | RuntimeException e) { + LT.warn(log, "Failed to set up TLS on an accepted discovery socket [rmtAddr=" + + sock.getRemoteSocketAddress() + ']', e); + + U.closeQuiet(sock); + + onIdle(); + + continue; + } + } + if (log.isInfoEnabled()) { log.info("TCP discovery accepted incoming connection " + "[rmtAddr=" + sock.getInetAddress() + ", rmtPort=" + sock.getPort() + ']'); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 1ec99c1b0382c..321f2f179cf4a 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -38,9 +38,11 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; +import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; @@ -107,6 +109,7 @@ import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryDuplicateIdMessage; import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryEnsureDelivery; import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryHandshakeResponse; +import org.apache.ignite.ssl.AbstractSslContextFactory; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -416,12 +419,32 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery /** Node authenticator. */ protected DiscoverySpiNodeAuthenticator nodeAuth; - /** SSL server socket factory. */ + /** + * SSL server socket factory from the startup context. + * + * @deprecated Discovery no longer reads this field. Assigning it has no effect on connections, + * and reloading the SSL context does not update it. + */ + @Deprecated protected SSLServerSocketFactory sslSrvSockFactory; - /** SSL socket factory. */ + /** + * SSL socket factory from the startup context. + * + * @deprecated Discovery no longer reads this field. Assigning it has no effect on connections, + * and reloading the SSL context does not update it. + */ + @Deprecated protected SSLSocketFactory sslSockFactory; + /** Reloadable factory, if configured. */ + @GridToStringExclude + private AbstractSslContextFactory reloadableSslFactory; + + /** Context obtained at startup for a factory that does not support reload. */ + @GridToStringExclude + private SSLContext startupSslCtx; + /** SSL enable/disable flag. */ protected boolean sslEnable; @@ -1660,7 +1683,7 @@ Socket createSocket() throws IOException { try { if (isSslEnabled()) - sock = sslSockFactory.createSocket(); + sock = sslContext().getSocketFactory().createSocket(); else sock = new Socket(); @@ -1678,6 +1701,45 @@ Socket createSocket() throws IOException { } } + /** + * @return SSL context for a new connection. + * @throws IOException If the factory cannot provide the context. + */ + private SSLContext sslContext() throws IOException { + if (reloadableSslFactory == null) + return startupSslCtx; + + try { + return reloadableSslFactory.create(); + } + catch (RuntimeException e) { + throw new IOException("Failed to obtain the SSL context for a discovery connection.", e); + } + } + + /** + * @param sock Accepted TCP socket. + * @return Socket configured for server-side mutual TLS. + * @throws IOException If the socket cannot be wrapped. + */ + SSLSocket wrapAcceptedSocket(Socket sock) throws IOException { + // Use the numeric address to avoid a reverse DNS lookup on the accept thread. + SSLSocket sslSock = (SSLSocket)sslContext().getSocketFactory() + .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); + + try { + sslSock.setUseClientMode(false); + sslSock.setNeedClientAuth(true); + + return sslSock; + } + catch (RuntimeException e) { + U.closeQuiet(sslSock); + + throw e; + } + } + /** * Writes raw data to the session socket. * @@ -2106,7 +2168,9 @@ private void initializeImpl() { if (impl != null) return; - sslEnable = ignite.configuration().getSslContextFactory() != null; + Factory sslFactory = ignite.configuration().getSslContextFactory(); + + sslEnable = sslFactory != null; initFailureDetectionTimeout(); @@ -2154,14 +2218,21 @@ private void initializeImpl() { if (isSslEnabled()) { try { - SSLContext sslCtx = ignite.configuration().getSslContextFactory().create(); + SSLContext sslCtx = sslFactory.create(); + + // Validate protocols and ciphers at startup, before binding the plain listener. + sslCtx.createSSLEngine(); + + if (sslFactory instanceof AbstractSslContextFactory reloadable) + reloadableSslFactory = reloadable; + else + startupSslCtx = sslCtx; sslSockFactory = sslCtx.getSocketFactory(); sslSrvSockFactory = sslCtx.getServerSocketFactory(); } catch (IgniteException e) { - throw new IgniteSpiException("Failed to create SSL context. SSL factory: " - + ignite.configuration().getSslContextFactory(), e); + throw new IgniteSpiException("Failed to create SSL context. SSL factory: " + sslFactory, e); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 9b2199f213e94..3554e72fb37b7 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -17,13 +17,20 @@ package org.apache.ignite.internal.util.nio; +import java.net.InetSocketAddress; import java.net.Socket; +import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter; import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; +import org.jetbrains.annotations.Nullable; import org.junit.Test; /** @@ -40,6 +47,69 @@ public class GridNioSslSelfTest extends GridNioSelfTest { sslCtx = GridTestUtils.sslContext(); } + /** A failed context lookup closes only its session and leaves the same NIO worker available. */ + @Test + public void testContextLookupFailure() throws Exception { + AtomicBoolean failLookup = new AtomicBoolean(true); + + SslContextFactory sslCtxFactory = new SslContextFactory() { + /** */ + private static final long serialVersionUID = 0L; + + @Override public SSLContext create() { + if (failLookup.getAndSet(false)) + throw new IgniteException("Test SSL context lookup failure"); + + return GridNioSslSelfTest.sslCtx; + } + }; + + GridNioServer srv = GridNioServer.builder() + .address(U.getLocalHost()) + .port(GridTestUtils.getFreeCommPort()) + .listener(new GridNioServerListenerAdapter<>() { + @Override public void onConnected(GridNioSession ses) { + // No-op. + } + + @Override public void onDisconnected(GridNioSession ses, @Nullable Exception e) { + // No-op. + } + + @Override public void onMessage(GridNioSession ses, ByteBuffer msg) { + // No-op. + } + }) + .logger(log) + .selectorCount(1) + .byteOrder(ByteOrder.nativeOrder()) + .filters(U.sslFilter(sslCtxFactory, sslCtx, false, ByteOrder.nativeOrder(), log, null)) + .build(); + + srv.start(); + + try { + InetSocketAddress addr = new InetSocketAddress(U.getLocalHost(), srv.port()); + + try (Socket sock = new Socket()) { + sock.connect(addr, 5_000); + sock.setSoTimeout(5_000); + + assertEquals(-1, sock.getInputStream().read()); + assertFalse(failLookup.get()); + } + + try (SSLSocket sock = (SSLSocket)createSocket()) { + sock.connect(addr, 5_000); + sock.setSoTimeout(5_000); + sock.startHandshake(); + } + } + finally { + srv.stop(); + } + } + /** {@inheritDoc} */ @Override protected Socket createSocket() throws IgniteCheckedException { try { diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java new file mode 100644 index 0000000000000..5b3984aab785d --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -0,0 +1,181 @@ +/* + * 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. + */ + +package org.apache.ignite.spi.discovery.tcp; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.atomic.AtomicReference; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; +import org.apache.ignite.ssl.SSLContextWrapper; +import org.apache.ignite.ssl.SslContextFactory; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; + +/** Tests that discovery keeps accepting connections when TLS setup fails for one socket. */ +public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest { + /** Timeout for connecting and reading from the discovery socket. */ + private static final int SOCK_TIMEOUT = 10_000; + + /** Address finder shared by the nodes in one test. */ + private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); + + /** {@inheritDoc} */ + @Override protected void afterTest() { + stopAllGrids(); + } + + /** A failed context lookup leaves the discovery listener available. */ + @Test + public void testContextLookupFailureKeepsAccepting() throws Exception { + FailingSslFactory factory = new FailingSslFactory(); + InetSocketAddress addr = address(startNode(0, factory)); + + assertRejectsOneConnection(addr, factory, () -> { + throw new IgniteException("Test failure looking up the SSL context"); + }); + assertAcceptsTls(addr, factory.create()); + } + + /** A failed socket wrap leaves the discovery listener available. */ + @Test + public void testSocketWrapFailureKeepsAccepting() throws Exception { + FailingSslFactory factory = new FailingSslFactory(); + InetSocketAddress addr = address(startNode(0, factory)); + SSLContext ctx = factory.create(); + + SSLParameters badParams = new SSLParameters(); + badParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); + + // This context can be obtained, but applying its parameters to the layered socket fails. + assertRejectsOneConnection(addr, factory, () -> new SSLContextWrapper(ctx, badParams)); + assertAcceptsTls(addr, ctx); + } + + /** A plain factory uses the same TLS discovery path. */ + @Test + public void testOrdinaryFactoryJoinsAndAcceptsTls() throws Exception { + Factory nodeFactory = sslTrustedFactory("node01", "trustone"); + + startNode(0, nodeFactory); + + Factory delegate = sslTrustedFactory("node01", "trustone"); + IgniteEx ordinaryNode = startNode(1, delegate::create); + + assertEquals(2, ordinaryNode.cluster().nodes().size()); + assertAcceptsTls(address(ordinaryNode), nodeFactory.create()); + } + + /** + * @param idx Node index. + * @param factory Node SSL context factory. + * @return Started node. + * @throws Exception If the node cannot start. + */ + private IgniteEx startNode(int idx, Factory factory) throws Exception { + return startGrid(idx, cfg -> { + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + cfg.setSslContextFactory(factory); + }); + } + + /** + * @param node Node with the discovery listener. + * @return Local address of its discovery listener. + */ + private static InetSocketAddress address(IgniteEx node) { + int port = ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); + + return new InetSocketAddress("127.0.0.1", port); + } + + /** + * @param addr Discovery listener address. + * @param factory Factory to arm for one accepted socket. + * @param nextCtx Supplies a context that fails setup, or throws during lookup. + * @throws Exception If the failed connection is not closed. + */ + private static void assertRejectsOneConnection( + InetSocketAddress addr, + FailingSslFactory factory, + Factory nextCtx + ) throws Exception { + assertTrue(factory.nextCtx.compareAndSet(null, nextCtx)); + + try (Socket sock = new Socket()) { + sock.connect(addr, SOCK_TIMEOUT); + sock.setSoTimeout(SOCK_TIMEOUT); + + assertEquals("Discovery must close the socket after TLS setup fails", -1, sock.getInputStream().read()); + assertNull(factory.nextCtx.get()); + } + } + + /** + * @param addr Discovery listener address. + * @param ctx Client context with a trusted certificate. + * @throws Exception If the TLS handshake fails. + */ + private static void assertAcceptsTls(InetSocketAddress addr, SSLContext ctx) throws Exception { + try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { + sock.connect(addr, SOCK_TIMEOUT); + sock.setSoTimeout(SOCK_TIMEOUT); + sock.startHandshake(); + } + } + + /** Uses a different result once, when the discovery listener requests a context. */ + private static class FailingSslFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0L; + + /** Result for the next context lookup on the accept thread. */ + private final AtomicReference> nextCtx = new AtomicReference<>(); + + /** */ + private FailingSslFactory() { + char[] pwd = keyStorePassword().toCharArray(); + + setKeyStoreFilePath(keyStorePath("node01")); + setKeyStorePassword(pwd); + setTrustStoreFilePath(keyStorePath("trustone")); + setTrustStorePassword(pwd); + } + + /** {@inheritDoc} */ + @Override public SSLContext create() { + if (Thread.currentThread().getName().startsWith("tcp-disco-srvr-")) { + Factory next = nextCtx.getAndSet(null); + + if (next != null) + return next.create(); + } + + return super.create(); + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java new file mode 100644 index 0000000000000..fcf18158d3c9e --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -0,0 +1,268 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import org.apache.ignite.IgniteException; +import org.apache.ignite.Ignition; +import org.apache.ignite.client.ClientRetryNonePolicy; +import org.apache.ignite.client.IgniteClient; +import org.apache.ignite.client.SslMode; +import org.apache.ignite.configuration.ClientConfiguration; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.configuration.ConnectorConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.junit.Assert.assertThrows; + +/** Tests how running TCP transports use a shared factory when its context is reloaded. */ +public class SslContextReloadTest extends GridCommonAbstractTest { + /** Timeout for opening and reading from a TLS probe socket. */ + private static final int SOCKET_TIMEOUT = 10_000; + + /** Discovery addresses shared by the nodes of this test. */ + private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); + + /** {@inheritDoc} */ + @Override protected void afterTest() { + stopAllGrids(); + } + + /** New connections use the replacement key and trust. An established thin connection stays usable. */ + @Test + public void testRotateKeyAndTrust() throws Exception { + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); + IgniteEx node = startNode(0, factory); + int[] ports = tlsPorts(node); + + for (int port : ports) + assertCertificate(port, "node01", "trustone", "CN=node01"); + + // This client trusts only the original CA, so it cannot reconnect after the server rotates. + try (IgniteClient client = thinClient(node)) { + client.cacheNames(); + + factory.reload("node02", "trusttwo"); + + for (int port : ports) { + assertCertificate(port, "node03", "trusttwo", "CN=node02"); + assertClientCertificateRejected(port); + } + + client.cacheNames(); + } + + // The joining server trusts only the new CA. It did not exist before the reload. + IgniteEx peer = startNode(1, sslTrustedFactory("node03", "trusttwo")); + + assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peer.localNode().id())); + + int result = node.compute(node.cluster().forNodeId(peer.localNode().id())).call(() -> 1); + + assertEquals(1, result); + } + + /** Fresh handshakes and an established thin connection work while a replacement loads and after it fails. */ + @Test + public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { + String replacement = keyStorePath("node02"); + CountDownLatch opening = new CountDownLatch(1); + CountDownLatch resume = new CountDownLatch(1); + + ReloadableSslContextFactory factory = new ReloadableSslContextFactory() { + /** */ + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + if (replacement.equals(path)) { + opening.countDown(); + + try { + if (!resume.await(60, TimeUnit.SECONDS)) + throw new IOException("Timed out waiting to open the replacement key store"); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + + throw new IOException(e); + } + } + + return super.openFileInputStream(path); + } + }; + + IgniteEx node = startNode(0, factory); + int[] ports = tlsPorts(node); + + try (IgniteClient client = thinClient(node)) { + client.cacheNames(); + + factory.setKeyStorePassword("wrong-password".toCharArray()); + + IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { + assertThrows(IgniteException.class, () -> factory.reload("node02", "trusttwo")); + }); + + try { + assertTrue("Reload did not reach the replacement key store", opening.await(30, TimeUnit.SECONDS)); + + for (int port : ports) + assertCertificate(port, "node01", "trustone", "CN=node01"); + + client.cacheNames(); + } + finally { + resume.countDown(); + } + + reload.get(30, TimeUnit.SECONDS); + + for (int port : ports) + assertCertificate(port, "node01", "trustone", "CN=node01"); + + client.cacheNames(); + } + } + + /** Starts discovery, communication and both client listeners with the same factory. */ + private IgniteEx startNode(int idx, Factory factory) throws Exception { + return startGrid(idx, cfg -> { + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + + // Paired connections ensure the compute request opens or uses an outgoing connection. + ((TcpCommunicationSpi)cfg.getCommunicationSpi()).setUsePairedConnections(true); + + cfg.setSslContextFactory(factory) + .setClientConnectorConfiguration(new ClientConnectorConfiguration() + .setSslEnabled(true) + .setSslClientAuth(true) + .setUseIgniteSslContextFactory(true)) + .setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); + }); + } + + /** Returns the ports of all four TLS listeners. */ + private static int[] tlsPorts(IgniteEx node) { + return new int[] { + ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(), + ((TcpCommunicationSpi)node.configuration().getCommunicationSpi()).boundPort(), + node.context().clientListener().port(), + node.localNode().attribute(ATTR_REST_TCP_PORT) + }; + } + + /** Connects a thin client which trusts the original server certificate. */ + private static IgniteClient thinClient(IgniteEx node) { + return Ignition.startClient(new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setTimeout(SOCKET_TIMEOUT) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(sslTrustedFactory("node01", "trustone")) + .setRetryPolicy(new ClientRetryNonePolicy()) + .setPartitionAwarenessEnabled(false) + .setClusterDiscoveryEnabled(false)); + } + + /** Checks the server certificate after successful mutual TLS authentication. */ + private static void assertCertificate(int port, String keys, String trust, String subject) throws IOException { + try (SSLSocket sock = probeSocket(port, keys, trust)) { + sock.startHandshake(); + + assertEquals("Peer certificate on port " + port, subject, sock.getSession().getPeerPrincipal().getName()); + } + } + + /** Checks that the server no longer accepts the old client CA, even when the client trusts the new server. */ + private static void assertClientCertificateRejected(int port) throws IOException { + try (SSLSocket sock = probeSocket(port, "node01", "trustboth")) { + try { + sock.startHandshake(); + + fail("Port " + port + " accepted a client from the old CA"); + } + catch (SSLException | SocketException expected) { + // A TLS alert or connection reset can report a rejected client certificate. + } + } + } + + /** Uses a fresh context to avoid resuming a TLS session established before reload. */ + private static SSLSocket probeSocket(int port, String keys, String trust) throws IOException { + SSLContext ctx = sslTrustedFactory(keys, trust).create(); + SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket(); + + try { + // TLS 1.2 completes the client handshake only after the server checks the client certificate. + sock.setEnabledProtocols(new String[] {"TLSv1.2"}); + sock.connect(new InetSocketAddress("127.0.0.1", port), SOCKET_TIMEOUT); + sock.setSoTimeout(SOCKET_TIMEOUT); + + return sock; + } + catch (IOException | RuntimeException e) { + U.closeQuiet(sock); + + throw e; + } + } + + /** Exposes the protected factory operation to the test. */ + private static class ReloadableSslContextFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0L; + + /** */ + private ReloadableSslContextFactory() { + setKeyStoreFilePath(keyStorePath("node01")); + setKeyStorePassword(keyStorePassword().toCharArray()); + setTrustStoreFilePath(keyStorePath("trustone")); + setTrustStorePassword(keyStorePassword().toCharArray()); + } + + /** Loads the configured replacement key and trust stores. */ + private void reload(String keys, String trust) { + setKeyStoreFilePath(keyStorePath(keys)); + setTrustStoreFilePath(keyStorePath(trust)); + + init(); + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteSpiDiscoverySelfTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteSpiDiscoverySelfTestSuite.java index 517d528e3a0e5..54647aaf5ad3a 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteSpiDiscoverySelfTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteSpiDiscoverySelfTestSuite.java @@ -46,6 +46,7 @@ import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpiSelfTest; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpiStartStopSelfTest; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpiWildcardSelfTest; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySslConnectionFailureTest; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySslParametersTest; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySslSecuredUnsecuredTest; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySslTrustedSelfTest; @@ -107,6 +108,7 @@ TcpDiscoverySslTrustedSelfTest.class, TcpDiscoverySslSecuredUnsecuredTest.class, TcpDiscoverySslParametersTest.class, + TcpDiscoverySslConnectionFailureTest.class, // Disco cache reuse. diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 4a2770a20648c..d8e935eaf8b58 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -81,6 +81,7 @@ import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; import org.apache.ignite.ssl.SslContextFactoryInitTest; +import org.apache.ignite.ssl.SslContextReloadTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -145,6 +146,7 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextFactoryInitTest.class, + SslContextReloadTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, diff --git a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java index 9abcae2ad56a4..826e79860e5e5 100644 --- a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java +++ b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java @@ -21,6 +21,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.function.LongConsumer; +import java.util.function.Supplier; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; @@ -68,8 +69,8 @@ public class GridNioSslFilter extends GridNioFilterAdapter { /** Array of enabled protocols. */ private String[] enabledProtos; - /** SSL context to use. */ - private SSLContext sslCtx; + /** Supplies the SSL context for a new session. */ + private final Supplier sslCtxSupplier; /** Order. */ private ByteOrder order; @@ -106,11 +107,32 @@ public GridNioSslFilter( IgniteLogger log, @Nullable LongConsumer handshakeDuration, @Nullable Runnable rejectedSesCnt + ) { + this(() -> sslCtx, directBuf, order, log, handshakeDuration, rejectedSesCnt); + } + + /** + * Creates an SSL filter that obtains a context for each new SSL engine. + * + * @param sslCtxSupplier SSL context supplier. + * @param directBuf Direct buffer flag. + * @param order Byte order. + * @param log Logger to use. + * @param handshakeDuration Records SSL handshake duration (ms), or {@code null} if metrics disabled. + * @param rejectedSesCnt Increments the rejected-sessions counter, or {@code null} if metrics disabled. + */ + public GridNioSslFilter( + Supplier sslCtxSupplier, + boolean directBuf, + ByteOrder order, + IgniteLogger log, + @Nullable LongConsumer handshakeDuration, + @Nullable Runnable rejectedSesCnt ) { super("SSL filter"); this.log = log; - this.sslCtx = sslCtx; + this.sslCtxSupplier = sslCtxSupplier; this.directBuf = directBuf; this.order = order; this.handshakeDuration = handshakeDuration; @@ -179,6 +201,15 @@ public void enabledProtocols(String... enabledProtos) { GridSslMeta sslMeta = ses.meta(SSL_META.ordinal()); if (sslMeta == null) { + SSLContext sslCtx; + + try { + sslCtx = sslCtxSupplier.get(); + } + catch (RuntimeException e) { + throw new IgniteCheckedException("Failed to obtain SSL context for a new connection.", e); + } + try { engine = sslCtx.createSSLEngine(); } From 7ae2081a3c8db7acb4aa71bde5f4543a69f67cc9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 21:01:03 +0300 Subject: [PATCH 151/242] IGNITE-29256 Keep Javadoc focused on transport changes --- .../org/apache/ignite/ssl/AbstractSslContextFactory.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index b7256d7c11810..5fecea7993902 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -192,12 +192,10 @@ protected final void init() { } /** - * Returns the cached SSL context, creating it if the cache is empty. - * Ignite calls this method for each new discovery, communication, client connector or binary REST connection, - * including on NIO threads. + * {@inheritDoc} * - * @return Current SSL context. - * @throws IgniteException If the context cannot be initialized. + *

Ignite calls this method for each new discovery, communication, client connector or binary REST connection, + * including on NIO threads. */ @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); From 34da982ea7fd57d402a32182b6af0440c07f88de Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 21:06:54 +0300 Subject: [PATCH 152/242] IGNITE-29256 Simplify added Javadoc --- .../ignite/ssl/AbstractSslContextFactory.java | 7 +--- .../ignite/spi/discovery/tcp/ServerImpl.java | 2 +- .../spi/discovery/tcp/TcpDiscoverySpi.java | 25 +++-------- .../internal/util/nio/GridNioSslSelfTest.java | 6 ++- .../TcpDiscoverySslConnectionFailureTest.java | 41 ++++++------------- .../ignite/ssl/SslContextReloadTest.java | 26 ++++++------ .../util/nio/ssl/GridNioSslFilter.java | 13 +----- 7 files changed, 40 insertions(+), 80 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 5fecea7993902..5139f98bc396b 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -191,12 +191,7 @@ protected final void init() { } } - /** - * {@inheritDoc} - * - *

Ignite calls this method for each new discovery, communication, client connector or binary REST connection, - * including on NIO threads. - */ + /** {@inheritDoc} */ @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index 9acb2fe592e64..a055aa1448dde 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -6283,7 +6283,7 @@ private class TcpServer extends GridWorker { /** Socket TCP server listens to. */ private ServerSocket srvrSock; - /** Whether this listener accepts TLS connections. */ + /** */ private final boolean sslEnabled; /** Port to listen. */ diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 321f2f179cf4a..f797864c146ca 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -420,28 +420,22 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery protected DiscoverySpiNodeAuthenticator nodeAuth; /** - * SSL server socket factory from the startup context. - * - * @deprecated Discovery no longer reads this field. Assigning it has no effect on connections, - * and reloading the SSL context does not update it. + * @deprecated Unused. */ @Deprecated protected SSLServerSocketFactory sslSrvSockFactory; /** - * SSL socket factory from the startup context. - * - * @deprecated Discovery no longer reads this field. Assigning it has no effect on connections, - * and reloading the SSL context does not update it. + * @deprecated Unused. */ @Deprecated protected SSLSocketFactory sslSockFactory; - /** Reloadable factory, if configured. */ + /** */ @GridToStringExclude private AbstractSslContextFactory reloadableSslFactory; - /** Context obtained at startup for a factory that does not support reload. */ + /** */ @GridToStringExclude private SSLContext startupSslCtx; @@ -1701,10 +1695,7 @@ Socket createSocket() throws IOException { } } - /** - * @return SSL context for a new connection. - * @throws IOException If the factory cannot provide the context. - */ + /** */ private SSLContext sslContext() throws IOException { if (reloadableSslFactory == null) return startupSslCtx; @@ -1717,11 +1708,7 @@ private SSLContext sslContext() throws IOException { } } - /** - * @param sock Accepted TCP socket. - * @return Socket configured for server-side mutual TLS. - * @throws IOException If the socket cannot be wrapped. - */ + /** */ SSLSocket wrapAcceptedSocket(Socket sock) throws IOException { // Use the numeric address to avoid a reverse DNS lookup on the accept thread. SSLSocket sslSock = (SSLSocket)sslContext().getSocketFactory() diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 3554e72fb37b7..9ea7d01226038 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -47,7 +47,7 @@ public class GridNioSslSelfTest extends GridNioSelfTest { sslCtx = GridTestUtils.sslContext(); } - /** A failed context lookup closes only its session and leaves the same NIO worker available. */ + /** A failed context lookup leaves the NIO worker available. */ @Test public void testContextLookupFailure() throws Exception { AtomicBoolean failLookup = new AtomicBoolean(true); @@ -56,6 +56,7 @@ public void testContextLookupFailure() throws Exception { /** */ private static final long serialVersionUID = 0L; + /** {@inheritDoc} */ @Override public SSLContext create() { if (failLookup.getAndSet(false)) throw new IgniteException("Test SSL context lookup failure"); @@ -68,14 +69,17 @@ public void testContextLookupFailure() throws Exception { .address(U.getLocalHost()) .port(GridTestUtils.getFreeCommPort()) .listener(new GridNioServerListenerAdapter<>() { + /** {@inheritDoc} */ @Override public void onConnected(GridNioSession ses) { // No-op. } + /** {@inheritDoc} */ @Override public void onDisconnected(GridNioSession ses, @Nullable Exception e) { // No-op. } + /** {@inheritDoc} */ @Override public void onMessage(GridNioSession ses, ByteBuffer msg) { // No-op. } diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index 5b3984aab785d..e27aee97f26ca 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -36,12 +36,12 @@ import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; -/** Tests that discovery keeps accepting connections when TLS setup fails for one socket. */ +/** Tests discovery after individual TLS setup failures. */ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest { - /** Timeout for connecting and reading from the discovery socket. */ + /** */ private static final int SOCK_TIMEOUT = 10_000; - /** Address finder shared by the nodes in one test. */ + /** Discovery addresses shared within one test. */ private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); /** {@inheritDoc} */ @@ -49,7 +49,7 @@ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest stopAllGrids(); } - /** A failed context lookup leaves the discovery listener available. */ + /** */ @Test public void testContextLookupFailureKeepsAccepting() throws Exception { FailingSslFactory factory = new FailingSslFactory(); @@ -61,7 +61,7 @@ public void testContextLookupFailureKeepsAccepting() throws Exception { assertAcceptsTls(addr, factory.create()); } - /** A failed socket wrap leaves the discovery listener available. */ + /** */ @Test public void testSocketWrapFailureKeepsAccepting() throws Exception { FailingSslFactory factory = new FailingSslFactory(); @@ -76,7 +76,7 @@ public void testSocketWrapFailureKeepsAccepting() throws Exception { assertAcceptsTls(addr, ctx); } - /** A plain factory uses the same TLS discovery path. */ + /** */ @Test public void testOrdinaryFactoryJoinsAndAcceptsTls() throws Exception { Factory nodeFactory = sslTrustedFactory("node01", "trustone"); @@ -90,12 +90,7 @@ public void testOrdinaryFactoryJoinsAndAcceptsTls() throws Exception { assertAcceptsTls(address(ordinaryNode), nodeFactory.create()); } - /** - * @param idx Node index. - * @param factory Node SSL context factory. - * @return Started node. - * @throws Exception If the node cannot start. - */ + /** */ private IgniteEx startNode(int idx, Factory factory) throws Exception { return startGrid(idx, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); @@ -103,22 +98,14 @@ private IgniteEx startNode(int idx, Factory factory) throws Exceptio }); } - /** - * @param node Node with the discovery listener. - * @return Local address of its discovery listener. - */ + /** */ private static InetSocketAddress address(IgniteEx node) { int port = ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); return new InetSocketAddress("127.0.0.1", port); } - /** - * @param addr Discovery listener address. - * @param factory Factory to arm for one accepted socket. - * @param nextCtx Supplies a context that fails setup, or throws during lookup. - * @throws Exception If the failed connection is not closed. - */ + /** Injects a TLS failure for one accepted socket. */ private static void assertRejectsOneConnection( InetSocketAddress addr, FailingSslFactory factory, @@ -135,11 +122,7 @@ private static void assertRejectsOneConnection( } } - /** - * @param addr Discovery listener address. - * @param ctx Client context with a trusted certificate. - * @throws Exception If the TLS handshake fails. - */ + /** */ private static void assertAcceptsTls(InetSocketAddress addr, SSLContext ctx) throws Exception { try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { sock.connect(addr, SOCK_TIMEOUT); @@ -148,12 +131,12 @@ private static void assertAcceptsTls(InetSocketAddress addr, SSLContext ctx) thr } } - /** Uses a different result once, when the discovery listener requests a context. */ + /** One-shot context override on the discovery accept thread. */ private static class FailingSslFactory extends SslContextFactory { /** */ private static final long serialVersionUID = 0L; - /** Result for the next context lookup on the accept thread. */ + /** One-shot context supplier. */ private final AtomicReference> nextCtx = new AtomicReference<>(); /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index fcf18158d3c9e..ff351686f41ee 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -51,12 +51,12 @@ import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertThrows; -/** Tests how running TCP transports use a shared factory when its context is reloaded. */ +/** Tests TLS reload across TCP transports. */ public class SslContextReloadTest extends GridCommonAbstractTest { - /** Timeout for opening and reading from a TLS probe socket. */ + /** */ private static final int SOCKET_TIMEOUT = 10_000; - /** Discovery addresses shared by the nodes of this test. */ + /** Discovery addresses shared within one test. */ private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); /** {@inheritDoc} */ @@ -64,7 +64,7 @@ public class SslContextReloadTest extends GridCommonAbstractTest { stopAllGrids(); } - /** New connections use the replacement key and trust. An established thin connection stays usable. */ + /** An established thin client survives rotation. */ @Test public void testRotateKeyAndTrust() throws Exception { ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); @@ -98,7 +98,7 @@ public void testRotateKeyAndTrust() throws Exception { assertEquals(1, result); } - /** Fresh handshakes and an established thin connection work while a replacement loads and after it fails. */ + /** Checks both new and established connections. */ @Test public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { String replacement = keyStorePath("node02"); @@ -162,7 +162,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception } } - /** Starts discovery, communication and both client listeners with the same factory. */ + /** All four listeners share one factory. */ private IgniteEx startNode(int idx, Factory factory) throws Exception { return startGrid(idx, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); @@ -179,7 +179,7 @@ private IgniteEx startNode(int idx, Factory factory) throws Exceptio }); } - /** Returns the ports of all four TLS listeners. */ + /** */ private static int[] tlsPorts(IgniteEx node) { return new int[] { ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(), @@ -189,7 +189,7 @@ private static int[] tlsPorts(IgniteEx node) { }; } - /** Connects a thin client which trusts the original server certificate. */ + /** Thin client that trusts only the original CA. */ private static IgniteClient thinClient(IgniteEx node) { return Ignition.startClient(new ClientConfiguration() .setAddresses("127.0.0.1:" + node.context().clientListener().port()) @@ -201,7 +201,7 @@ private static IgniteClient thinClient(IgniteEx node) { .setClusterDiscoveryEnabled(false)); } - /** Checks the server certificate after successful mutual TLS authentication. */ + /** Checks the server certificate after mutual TLS. */ private static void assertCertificate(int port, String keys, String trust, String subject) throws IOException { try (SSLSocket sock = probeSocket(port, keys, trust)) { sock.startHandshake(); @@ -210,7 +210,7 @@ private static void assertCertificate(int port, String keys, String trust, Strin } } - /** Checks that the server no longer accepts the old client CA, even when the client trusts the new server. */ + /** The probe trusts the replacement server certificate. */ private static void assertClientCertificateRejected(int port) throws IOException { try (SSLSocket sock = probeSocket(port, "node01", "trustboth")) { try { @@ -224,7 +224,7 @@ private static void assertClientCertificateRejected(int port) throws IOException } } - /** Uses a fresh context to avoid resuming a TLS session established before reload. */ + /** A fresh TLS context prevents session resumption. */ private static SSLSocket probeSocket(int port, String keys, String trust) throws IOException { SSLContext ctx = sslTrustedFactory(keys, trust).create(); SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket(); @@ -244,7 +244,7 @@ private static SSLSocket probeSocket(int port, String keys, String trust) throws } } - /** Exposes the protected factory operation to the test. */ + /** Test factory with access to {@code init()}. */ private static class ReloadableSslContextFactory extends SslContextFactory { /** */ private static final long serialVersionUID = 0L; @@ -257,7 +257,7 @@ private ReloadableSslContextFactory() { setTrustStorePassword(keyStorePassword().toCharArray()); } - /** Loads the configured replacement key and trust stores. */ + /** */ private void reload(String keys, String trust) { setKeyStoreFilePath(keyStorePath(keys)); setTrustStoreFilePath(keyStorePath(trust)); diff --git a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java index 826e79860e5e5..36d051436eec0 100644 --- a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java +++ b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java @@ -69,7 +69,7 @@ public class GridNioSslFilter extends GridNioFilterAdapter { /** Array of enabled protocols. */ private String[] enabledProtos; - /** Supplies the SSL context for a new session. */ + /** */ private final Supplier sslCtxSupplier; /** Order. */ @@ -111,16 +111,7 @@ public GridNioSslFilter( this(() -> sslCtx, directBuf, order, log, handshakeDuration, rejectedSesCnt); } - /** - * Creates an SSL filter that obtains a context for each new SSL engine. - * - * @param sslCtxSupplier SSL context supplier. - * @param directBuf Direct buffer flag. - * @param order Byte order. - * @param log Logger to use. - * @param handshakeDuration Records SSL handshake duration (ms), or {@code null} if metrics disabled. - * @param rejectedSesCnt Increments the rejected-sessions counter, or {@code null} if metrics disabled. - */ + /** */ public GridNioSslFilter( Supplier sslCtxSupplier, boolean directBuf, From bca5874b2ae5107fc06d988d33456101c5e4230d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 21:21:48 +0300 Subject: [PATCH 153/242] IGNITE-29256 Remove unnecessary line breaks --- .../rest/protocols/tcp/GridTcpRestProtocol.java | 8 +------- .../org/apache/ignite/internal/util/IgniteUtils.java | 3 +-- .../communication/tcp/internal/GridNioServerWrapper.java | 9 ++------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index 9e4e908cc7b4c..ec204d71be0b6 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -204,13 +204,7 @@ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerList if (sslCtx != null) { assert sslCtxFactory != null; - GridNioSslFilter sslFilter = U.sslFilter( - sslCtxFactory, - sslCtx, - cfg.isDirectBuffer(), - ByteOrder.nativeOrder(), - log, - mreg); + GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtx, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), log, mreg); sslFilter.directMode(false); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index 7413a4978e4dd..920e85ef0cae0 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -7758,8 +7758,7 @@ public static GridNioSslFilter sslFilter( GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME, "TCP sessions count that were rejected due to SSL errors.")::increment; - Supplier sslCtxSupplier = sslCtxFactory instanceof AbstractSslContextFactory - ? sslCtxFactory::create : () -> sslCtx; + Supplier sslCtxSupplier = sslCtxFactory instanceof AbstractSslContextFactory ? sslCtxFactory::create : () -> sslCtx; return new GridNioSslFilter(sslCtxSupplier, directBuf, order, log, handshakeDuration, rejectedSesCnt); } diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index 902a8c06106a8..ade5ba0571fb7 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -922,13 +922,8 @@ private IgniteMessageFactory get() { if (stateProvider.isSslEnabled()) { Factory sslCtxFactory = igniteCfg.getSslContextFactory(); - GridNioSslFilter sslFilter = U.sslFilter( - sslCtxFactory, - sslCtxFactory.create(), - true, - ByteOrder.LITTLE_ENDIAN, - log, - metricMgr == null ? null : metricMgr.registry(COMMUNICATION_METRICS_GROUP_NAME)); + GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtxFactory.create(), true, ByteOrder.LITTLE_ENDIAN, + log, metricMgr == null ? null : metricMgr.registry(COMMUNICATION_METRICS_GROUP_NAME)); sslFilter.directMode(true); From 1bc5dad1c4630855ac62145fae5e3e0679a84c16 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 21:27:20 +0300 Subject: [PATCH 154/242] IGNITE-29256 Simplify discovery SSL socket handling --- .../ignite/spi/discovery/tcp/ServerImpl.java | 9 +++- .../spi/discovery/tcp/TcpDiscoverySpi.java | 48 +++++-------------- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index a055aa1448dde..ce3e6a99f94ea 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -6350,7 +6350,14 @@ private class TcpServer extends GridWorker { if (sslEnabled) { try { - sock = spi.wrapAcceptedSocket(sock); + // Use the numeric address to avoid a reverse DNS lookup on the accept thread. + SSLSocket sslSock = (SSLSocket)spi.sslSocketFactory() + .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); + + sock = sslSock; + + sslSock.setUseClientMode(false); + sslSock.setNeedClientAuth(true); } catch (IOException | RuntimeException e) { LT.warn(log, "Failed to set up TLS on an accepted discovery socket [rmtAddr=" + diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index f797864c146ca..5fe9d97f383d7 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -37,12 +37,12 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLServerSocketFactory; -import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; @@ -433,11 +433,7 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery /** */ @GridToStringExclude - private AbstractSslContextFactory reloadableSslFactory; - - /** */ - @GridToStringExclude - private SSLContext startupSslCtx; + private Supplier sslSockFactorySupplier; /** SSL enable/disable flag. */ protected boolean sslEnable; @@ -1677,7 +1673,7 @@ Socket createSocket() throws IOException { try { if (isSslEnabled()) - sock = sslContext().getSocketFactory().createSocket(); + sock = sslSocketFactory().createSocket(); else sock = new Socket(); @@ -1696,34 +1692,12 @@ Socket createSocket() throws IOException { } /** */ - private SSLContext sslContext() throws IOException { - if (reloadableSslFactory == null) - return startupSslCtx; - - try { - return reloadableSslFactory.create(); - } - catch (RuntimeException e) { - throw new IOException("Failed to obtain the SSL context for a discovery connection.", e); - } - } - - /** */ - SSLSocket wrapAcceptedSocket(Socket sock) throws IOException { - // Use the numeric address to avoid a reverse DNS lookup on the accept thread. - SSLSocket sslSock = (SSLSocket)sslContext().getSocketFactory() - .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); - + SSLSocketFactory sslSocketFactory() throws IOException { try { - sslSock.setUseClientMode(false); - sslSock.setNeedClientAuth(true); - - return sslSock; + return sslSockFactorySupplier.get(); } catch (RuntimeException e) { - U.closeQuiet(sslSock); - - throw e; + throw new IOException("Failed to obtain the SSL socket factory for a discovery connection.", e); } } @@ -2210,12 +2184,12 @@ private void initializeImpl() { // Validate protocols and ciphers at startup, before binding the plain listener. sslCtx.createSSLEngine(); - if (sslFactory instanceof AbstractSslContextFactory reloadable) - reloadableSslFactory = reloadable; - else - startupSslCtx = sslCtx; + SSLSocketFactory initSockFactory = sslCtx.getSocketFactory(); + + sslSockFactorySupplier = sslFactory instanceof AbstractSslContextFactory + ? () -> sslFactory.create().getSocketFactory() : () -> initSockFactory; - sslSockFactory = sslCtx.getSocketFactory(); + sslSockFactory = initSockFactory; sslSrvSockFactory = sslCtx.getServerSocketFactory(); } catch (IgniteException e) { From d654ae858a6a0363cb8ae0478c6e42e35ec66eaf Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 21:52:09 +0300 Subject: [PATCH 155/242] IGNITE-29256 Verify ordinary discovery factories retain their context --- .../tcp/TcpDiscoverySslConnectionFailureTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index e27aee97f26ca..cc0203d1e772a 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -78,15 +78,18 @@ public void testSocketWrapFailureKeepsAccepting() throws Exception { /** */ @Test - public void testOrdinaryFactoryJoinsAndAcceptsTls() throws Exception { + public void testOrdinaryFactoryKeepsStartupContext() throws Exception { Factory nodeFactory = sslTrustedFactory("node01", "trustone"); startNode(0, nodeFactory); - Factory delegate = sslTrustedFactory("node01", "trustone"); - IgniteEx ordinaryNode = startNode(1, delegate::create); + AtomicReference ctx = new AtomicReference<>(nodeFactory.create()); + IgniteEx ordinaryNode = startNode(1, ctx::get); assertEquals(2, ordinaryNode.cluster().nodes().size()); + + ctx.set(sslTrustedFactory("node02", "trusttwo").create()); + assertAcceptsTls(address(ordinaryNode), nodeFactory.create()); } From 3d9112b19a6b8274b73d8544ef7fa62902d664f1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 22:07:43 +0300 Subject: [PATCH 156/242] IGNITE-29256 Simplify SSL factory wiring Load the initial context inside the NIO helper and create the REST SSL filter before trying ports. Verify that bind retries retain an ordinary factory context. Remove the unused protected discovery socket factory fields. Subclasses that access them must be adapted and rebuilt. --- .../odbc/ClientListenerProcessor.java | 4 +- .../protocols/tcp/GridTcpRestProtocol.java | 47 +++++++------------ .../ignite/internal/util/IgniteUtils.java | 4 +- .../tcp/internal/GridNioServerWrapper.java | 6 +-- .../spi/discovery/tcp/TcpDiscoverySpi.java | 16 ------- .../internal/util/nio/GridNioSslSelfTest.java | 6 ++- .../ignite/ssl/SslContextReloadTest.java | 40 ++++++++++++++++ 7 files changed, 66 insertions(+), 57 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java index 2d67eacfddc4d..2ec17e552c165 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java @@ -537,8 +537,8 @@ else if (connCtx.managementClient()) { throw new IgniteCheckedException("Failed to create client listener " + "(SSL is enabled but factory is null). Check the ClientConnectorConfiguration"); - GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtxFactory.create(), - true, ByteOrder.nativeOrder(), log, ctx.metric().registry(CLIENT_CONNECTOR_METRICS)); + GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, true, ByteOrder.nativeOrder(), log, + ctx.metric().registry(CLIENT_CONNECTOR_METRICS)); sslFilter.directMode(true); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index ec204d71be0b6..ba77e38f9b14a 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -90,11 +90,10 @@ public GridTcpRestProtocol(GridKernalContext ctx) { try { host = resolveRestTcpHost(ctx.config()); - Factory sslCtxFactory = null; - SSLContext sslCtx = null; + GridNioSslFilter sslFilter = null; if (cfg.isSslEnabled()) { - sslCtxFactory = cfg.getSslFactory(); + Factory sslCtxFactory = cfg.getSslFactory(); if (sslCtxFactory == null) sslCtxFactory = ctx.config().getSslContextFactory(); @@ -103,7 +102,15 @@ public GridTcpRestProtocol(GridKernalContext ctx) { // Thrown SSL exception instead of IgniteCheckedException for writing correct warning message into log. throw new SSLException("SSL is enabled, but SSL context factory is not specified."); - sslCtx = sslCtxFactory.create(); + sslFilter = U.sslFilter(sslCtxFactory, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), log, + ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME)); + + sslFilter.directMode(false); + + boolean auth = cfg.isSslClientAuth(); + + sslFilter.wantClientAuth(auth); + sslFilter.needClientAuth(auth); } int startPort = cfg.getPort(); @@ -111,7 +118,7 @@ public GridTcpRestProtocol(GridKernalContext ctx) { int lastPort = portRange == 0 ? startPort : startPort + portRange - 1; for (int port0 = startPort; port0 <= lastPort; port0++) { - if (startTcpServer(host, port0, lsnr, parser, sslCtxFactory, sslCtx, cfg)) { + if (startTcpServer(host, port0, lsnr, parser, sslFilter, cfg)) { port = port0; if (log.isInfoEnabled()) @@ -186,42 +193,22 @@ private InetAddress resolveRestTcpHost(IgniteConfiguration cfg) throws IOExcepti * @param port Port on which server should be bound. * @param lsnr Server message listener. * @param parser Server message parser. - * @param sslCtxFactory SSL context factory if SSL is enabled. - * @param sslCtx SSL context in case if SSL is enabled. + * @param sslFilter SSL filter if SSL is enabled. * @param cfg Configuration for other parameters. * @return {@code True} if server successfully started, {@code false} if port is used and * server was unable to start. */ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerListener lsnr, - GridNioParser parser, @Nullable Factory sslCtxFactory, @Nullable SSLContext sslCtx, ConnectorConfiguration cfg) { + GridNioParser parser, @Nullable GridNioSslFilter sslFilter, ConnectorConfiguration cfg) { try { GridNioFilter codec = new GridNioCodecFilter(parser, log, false); - GridNioFilter[] filters; + GridNioFilter[] filters = sslFilter == null + ? new GridNioFilter[] { codec } + : new GridNioFilter[] { codec, sslFilter }; MetricRegistryImpl mreg = ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME); - if (sslCtx != null) { - assert sslCtxFactory != null; - - GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtx, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), log, mreg); - - sslFilter.directMode(false); - - boolean auth = cfg.isSslClientAuth(); - - sslFilter.wantClientAuth(auth); - - sslFilter.needClientAuth(auth); - - filters = new GridNioFilter[] { - codec, - sslFilter - }; - } - else - filters = new GridNioFilter[] { codec }; - GridNioServer.Builder builder = GridNioServer.builder() .address(hostAddr) .port(port) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index 920e85ef0cae0..b3951a319213b 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -7734,7 +7734,6 @@ public static void registerNioServerMetrics(GridNioServer srv, GridNioFilter[ * Creates an SSL NIO filter, wiring its metrics from the given registry. * * @param sslCtxFactory Configured SSL context factory. - * @param sslCtx SSL context obtained at transport startup. * @param directBuf Direct buffer flag. * @param order Byte order. * @param log Logger to use. @@ -7743,12 +7742,13 @@ public static void registerNioServerMetrics(GridNioServer srv, GridNioFilter[ */ public static GridNioSslFilter sslFilter( Factory sslCtxFactory, - SSLContext sslCtx, boolean directBuf, ByteOrder order, IgniteLogger log, @Nullable MetricRegistryImpl mreg ) { + SSLContext sslCtx = sslCtxFactory.create(); + LongConsumer handshakeDuration = mreg == null ? null : mreg.histogram( GridNioSslFilter.SSL_HANDSHAKE_DURATION_HISTOGRAM_METRIC_NAME, new long[] {250, 500, 1000}, diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index ade5ba0571fb7..bc2bf444939b0 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -40,8 +40,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import java.util.function.Supplier; -import javax.cache.configuration.Factory; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import org.apache.ignite.IgniteCheckedException; @@ -920,9 +918,7 @@ private IgniteMessageFactory get() { filters.add(new GridConnectionBytesVerifyFilter(log)); if (stateProvider.isSslEnabled()) { - Factory sslCtxFactory = igniteCfg.getSslContextFactory(); - - GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, sslCtxFactory.create(), true, ByteOrder.LITTLE_ENDIAN, + GridNioSslFilter sslFilter = U.sslFilter(igniteCfg.getSslContextFactory(), true, ByteOrder.LITTLE_ENDIAN, log, metricMgr == null ? null : metricMgr.registry(COMMUNICATION_METRICS_GROUP_NAME)); sslFilter.directMode(true); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 5fe9d97f383d7..96ff37305167f 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -42,7 +42,6 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLServerSocketFactory; import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; @@ -419,18 +418,6 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery /** Node authenticator. */ protected DiscoverySpiNodeAuthenticator nodeAuth; - /** - * @deprecated Unused. - */ - @Deprecated - protected SSLServerSocketFactory sslSrvSockFactory; - - /** - * @deprecated Unused. - */ - @Deprecated - protected SSLSocketFactory sslSockFactory; - /** */ @GridToStringExclude private Supplier sslSockFactorySupplier; @@ -2188,9 +2175,6 @@ private void initializeImpl() { sslSockFactorySupplier = sslFactory instanceof AbstractSslContextFactory ? () -> sslFactory.create().getSocketFactory() : () -> initSockFactory; - - sslSockFactory = initSockFactory; - sslSrvSockFactory = sslCtx.getServerSocketFactory(); } catch (IgniteException e) { throw new IgniteSpiException("Failed to create SSL context. SSL factory: " + sslFactory, e); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 9ea7d01226038..a56500a5437ad 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -50,7 +50,7 @@ public class GridNioSslSelfTest extends GridNioSelfTest { /** A failed context lookup leaves the NIO worker available. */ @Test public void testContextLookupFailure() throws Exception { - AtomicBoolean failLookup = new AtomicBoolean(true); + AtomicBoolean failLookup = new AtomicBoolean(); SslContextFactory sslCtxFactory = new SslContextFactory() { /** */ @@ -87,12 +87,14 @@ public void testContextLookupFailure() throws Exception { .logger(log) .selectorCount(1) .byteOrder(ByteOrder.nativeOrder()) - .filters(U.sslFilter(sslCtxFactory, sslCtx, false, ByteOrder.nativeOrder(), log, null)) + .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, null)) .build(); srv.start(); try { + failLookup.set(true); + InetSocketAddress addr = new InetSocketAddress(U.getLocalHost(), srv.port()); try (Socket sock = new Socket()) { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index ff351686f41ee..1588187171a26 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -20,9 +20,11 @@ import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; +import java.net.ServerSocket; import java.net.SocketException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -64,6 +66,44 @@ public class SslContextReloadTest extends GridCommonAbstractTest { stopAllGrids(); } + /** */ + @Test + public void testRestRetainsContextOnPortRetry() throws Exception { + SSLContext replacement = sslTrustedFactory("node02", "trusttwo").create(); + AtomicReference ctx = new AtomicReference<>(sslTrustedFactory("node01", "trustone").create()); + + for (int attempt = 0; attempt < 10; attempt++) { + try (ServerSocket occupied = new ServerSocket()) { + occupied.bind(new InetSocketAddress("127.0.0.1", 0)); + + if (occupied.getLocalPort() > 65_525) + continue; + + IgniteEx node = startGrid(0, cfg -> { + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + + cfg.setClientConnectorConfiguration(null) + .setConnectorConfiguration(new ConnectorConfiguration() + .setHost("127.0.0.1") + .setPort(occupied.getLocalPort()) + .setPortRange(10) + .setSslEnabled(true) + .setSslClientAuth(true) + .setSslFactory(() -> ctx.getAndSet(replacement))); + }); + + int port = node.localNode().attribute(ATTR_REST_TCP_PORT); + + assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); + assertCertificate(port, "node01", "trustone", "CN=node01"); + + return; + } + } + + fail("Could not allocate a port with room for REST retries"); + } + /** An established thin client survives rotation. */ @Test public void testRotateKeyAndTrust() throws Exception { From d697adbd2550f0414cc4fc95c2651fad418af96d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 22:30:19 +0300 Subject: [PATCH 157/242] IGNITE-29256 Prepare REST protocol filters before port retries --- .../protocols/tcp/GridTcpRestProtocol.java | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index ba77e38f9b14a..bd36d384b82b2 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -41,14 +41,12 @@ import org.apache.ignite.internal.processors.rest.protocols.GridRestProtocolAdapter; import org.apache.ignite.internal.util.nio.GridNioCodecFilter; import org.apache.ignite.internal.util.nio.GridNioFilter; -import org.apache.ignite.internal.util.nio.GridNioParser; import org.apache.ignite.internal.util.nio.GridNioServer; import org.apache.ignite.internal.util.nio.GridNioServerListener; import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.plugin.PluginProvider; import org.apache.ignite.spi.IgnitePortProtocol; -import org.jetbrains.annotations.Nullable; import static org.apache.ignite.internal.processors.metric.impl.MetricUtils.metricName; @@ -85,40 +83,17 @@ public GridTcpRestProtocol(GridKernalContext ctx) { lsnr = new GridTcpRestNioListener(log, this, hnd, ctx); - GridNioParser parser = new GridTcpRestParser(false); - try { host = resolveRestTcpHost(ctx.config()); - GridNioSslFilter sslFilter = null; - - if (cfg.isSslEnabled()) { - Factory sslCtxFactory = cfg.getSslFactory(); - - if (sslCtxFactory == null) - sslCtxFactory = ctx.config().getSslContextFactory(); - - if (sslCtxFactory == null) - // Thrown SSL exception instead of IgniteCheckedException for writing correct warning message into log. - throw new SSLException("SSL is enabled, but SSL context factory is not specified."); - - sslFilter = U.sslFilter(sslCtxFactory, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), log, - ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME)); - - sslFilter.directMode(false); - - boolean auth = cfg.isSslClientAuth(); - - sslFilter.wantClientAuth(auth); - sslFilter.needClientAuth(auth); - } + GridNioFilter[] filters = makeFilters(cfg); int startPort = cfg.getPort(); int portRange = cfg.getPortRange(); int lastPort = portRange == 0 ? startPort : startPort + portRange - 1; for (int port0 = startPort; port0 <= lastPort; port0++) { - if (startTcpServer(host, port0, lsnr, parser, sslFilter, cfg)) { + if (startTcpServer(host, port0, lsnr, filters, cfg)) { port = port0; if (log.isInfoEnabled()) @@ -186,27 +161,49 @@ private InetAddress resolveRestTcpHost(IgniteConfiguration cfg) throws IOExcepti return U.resolveLocalHost(host); } + /** */ + private GridNioFilter[] makeFilters(ConnectorConfiguration cfg) throws SSLException { + GridNioFilter codec = new GridNioCodecFilter(new GridTcpRestParser(false), log, false); + + if (!cfg.isSslEnabled()) + return new GridNioFilter[] { codec }; + + Factory sslCtxFactory = cfg.getSslFactory(); + + if (sslCtxFactory == null) + sslCtxFactory = ctx.config().getSslContextFactory(); + + if (sslCtxFactory == null) + // Thrown SSL exception instead of IgniteCheckedException for writing correct warning message into log. + throw new SSLException("SSL is enabled, but SSL context factory is not specified."); + + GridNioSslFilter sslFilter = U.sslFilter(sslCtxFactory, cfg.isDirectBuffer(), ByteOrder.nativeOrder(), log, + ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME)); + + sslFilter.directMode(false); + + boolean auth = cfg.isSslClientAuth(); + + sslFilter.wantClientAuth(auth); + sslFilter.needClientAuth(auth); + + return new GridNioFilter[] { codec, sslFilter }; + } + /** * Tries to start server with given parameters. * * @param hostAddr Host on which server should be bound. * @param port Port on which server should be bound. * @param lsnr Server message listener. - * @param parser Server message parser. - * @param sslFilter SSL filter if SSL is enabled. + * @param filters Protocol filters. * @param cfg Configuration for other parameters. * @return {@code True} if server successfully started, {@code false} if port is used and * server was unable to start. */ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerListener lsnr, - GridNioParser parser, @Nullable GridNioSslFilter sslFilter, ConnectorConfiguration cfg) { + GridNioFilter[] filters, ConnectorConfiguration cfg) { try { - GridNioFilter codec = new GridNioCodecFilter(parser, log, false); - - GridNioFilter[] filters = sslFilter == null - ? new GridNioFilter[] { codec } - : new GridNioFilter[] { codec, sslFilter }; - MetricRegistryImpl mreg = ctx.metric().registry(REST_CONNECTOR_METRIC_REGISTRY_NAME); GridNioServer.Builder builder = GridNioServer.builder() From ea0071a552d0ae6452f0a5681160421cab890fe6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 22:56:15 +0300 Subject: [PATCH 158/242] IGNITE-29256 Use the current context from every SSL factory --- .../ignite/internal/util/IgniteUtils.java | 8 ++----- .../spi/discovery/tcp/TcpDiscoverySpi.java | 7 ++----- .../TcpDiscoverySslConnectionFailureTest.java | 18 ---------------- .../ignite/ssl/SslContextReloadTest.java | 21 ++++++++++++------- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index b3951a319213b..acf8f4b307140 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -114,7 +114,6 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.function.Consumer; import java.util.function.LongConsumer; -import java.util.function.Supplier; import java.util.jar.JarFile; import java.util.logging.ConsoleHandler; import java.util.logging.Handler; @@ -227,7 +226,6 @@ import org.apache.ignite.spi.discovery.DiscoverySpiCustomMessage; import org.apache.ignite.spi.discovery.DiscoverySpiOrderSupport; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; -import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.transactions.TransactionDeadlockException; import org.apache.ignite.transactions.TransactionHeuristicException; import org.apache.ignite.transactions.TransactionOptimisticException; @@ -7747,7 +7745,7 @@ public static GridNioSslFilter sslFilter( IgniteLogger log, @Nullable MetricRegistryImpl mreg ) { - SSLContext sslCtx = sslCtxFactory.create(); + sslCtxFactory.create(); LongConsumer handshakeDuration = mreg == null ? null : mreg.histogram( GridNioSslFilter.SSL_HANDSHAKE_DURATION_HISTOGRAM_METRIC_NAME, @@ -7758,8 +7756,6 @@ public static GridNioSslFilter sslFilter( GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME, "TCP sessions count that were rejected due to SSL errors.")::increment; - Supplier sslCtxSupplier = sslCtxFactory instanceof AbstractSslContextFactory ? sslCtxFactory::create : () -> sslCtx; - - return new GridNioSslFilter(sslCtxSupplier, directBuf, order, log, handshakeDuration, rejectedSesCnt); + return new GridNioSslFilter(sslCtxFactory::create, directBuf, order, log, handshakeDuration, rejectedSesCnt); } } diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 96ff37305167f..0d9f0984bfa3c 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -108,7 +108,6 @@ import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryDuplicateIdMessage; import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryEnsureDelivery; import org.apache.ignite.spi.discovery.tcp.messages.TcpDiscoveryHandshakeResponse; -import org.apache.ignite.ssl.AbstractSslContextFactory; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -2170,11 +2169,9 @@ private void initializeImpl() { // Validate protocols and ciphers at startup, before binding the plain listener. sslCtx.createSSLEngine(); + sslCtx.getSocketFactory(); - SSLSocketFactory initSockFactory = sslCtx.getSocketFactory(); - - sslSockFactorySupplier = sslFactory instanceof AbstractSslContextFactory - ? () -> sslFactory.create().getSocketFactory() : () -> initSockFactory; + sslSockFactorySupplier = () -> sslFactory.create().getSocketFactory(); } catch (IgniteException e) { throw new IgniteSpiException("Failed to create SSL context. SSL factory: " + sslFactory, e); diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index cc0203d1e772a..54d0638f65917 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -34,7 +34,6 @@ import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; -import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; /** Tests discovery after individual TLS setup failures. */ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest { @@ -76,23 +75,6 @@ public void testSocketWrapFailureKeepsAccepting() throws Exception { assertAcceptsTls(addr, ctx); } - /** */ - @Test - public void testOrdinaryFactoryKeepsStartupContext() throws Exception { - Factory nodeFactory = sslTrustedFactory("node01", "trustone"); - - startNode(0, nodeFactory); - - AtomicReference ctx = new AtomicReference<>(nodeFactory.create()); - IgniteEx ordinaryNode = startNode(1, ctx::get); - - assertEquals(2, ordinaryNode.cluster().nodes().size()); - - ctx.set(sslTrustedFactory("node02", "trusttwo").create()); - - assertAcceptsTls(address(ordinaryNode), nodeFactory.create()); - } - /** */ private IgniteEx startNode(int idx, Factory factory) throws Exception { return startGrid(idx, cfg -> { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 1588187171a26..e4506dc3c4849 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -24,7 +24,7 @@ import java.net.SocketException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -66,11 +66,11 @@ public class SslContextReloadTest extends GridCommonAbstractTest { stopAllGrids(); } - /** */ + /** Port retries do not repeat SSL context initialization. */ @Test - public void testRestRetainsContextOnPortRetry() throws Exception { - SSLContext replacement = sslTrustedFactory("node02", "trusttwo").create(); - AtomicReference ctx = new AtomicReference<>(sslTrustedFactory("node01", "trustone").create()); + public void testRestInitializesContextOnceOnPortRetry() throws Exception { + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + AtomicInteger calls = new AtomicInteger(); for (int attempt = 0; attempt < 10; attempt++) { try (ServerSocket occupied = new ServerSocket()) { @@ -89,12 +89,17 @@ public void testRestRetainsContextOnPortRetry() throws Exception { .setPortRange(10) .setSslEnabled(true) .setSslClientAuth(true) - .setSslFactory(() -> ctx.getAndSet(replacement))); + .setSslFactory(() -> { + calls.incrementAndGet(); + + return ctx; + })); }); int port = node.localNode().attribute(ATTR_REST_TCP_PORT); assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); + assertEquals("SSL context must be initialized once before the first connection", 1, calls.get()); assertCertificate(port, "node01", "trustone", "CN=node01"); return; @@ -104,11 +109,11 @@ public void testRestRetainsContextOnPortRetry() throws Exception { fail("Could not allocate a port with room for REST retries"); } - /** An established thin client survives rotation. */ + /** A plain factory supplies rotated contexts while an established thin client survives rotation. */ @Test public void testRotateKeyAndTrust() throws Exception { ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); - IgniteEx node = startNode(0, factory); + IgniteEx node = startNode(0, factory::create); int[] ports = tlsPorts(node); for (int port : ports) From 7674cb589fba1d8d1064dc3eea3047f2e87e902b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 23:22:19 +0300 Subject: [PATCH 159/242] IGNITE-29256 Preserve SSL filter call formatting --- .../communication/tcp/internal/GridNioServerWrapper.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java index bc2bf444939b0..00d36124b1886 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/internal/GridNioServerWrapper.java @@ -918,8 +918,12 @@ private IgniteMessageFactory get() { filters.add(new GridConnectionBytesVerifyFilter(log)); if (stateProvider.isSslEnabled()) { - GridNioSslFilter sslFilter = U.sslFilter(igniteCfg.getSslContextFactory(), true, ByteOrder.LITTLE_ENDIAN, - log, metricMgr == null ? null : metricMgr.registry(COMMUNICATION_METRICS_GROUP_NAME)); + GridNioSslFilter sslFilter = U.sslFilter( + igniteCfg.getSslContextFactory(), + true, + ByteOrder.LITTLE_ENDIAN, + log, + metricMgr == null ? null : metricMgr.registry(COMMUNICATION_METRICS_GROUP_NAME)); sslFilter.directMode(true); From 9b89ee0ab50b93434745c4aecce90f156bedd87b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 23:45:00 +0300 Subject: [PATCH 160/242] IGNITE-29256 Reuse discovery SSL enablement flag --- .../org/apache/ignite/spi/discovery/tcp/ServerImpl.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index ce3e6a99f94ea..67b5ba08368a1 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -6283,9 +6283,6 @@ private class TcpServer extends GridWorker { /** Socket TCP server listens to. */ private ServerSocket srvrSock; - /** */ - private final boolean sslEnabled; - /** Port to listen. */ private int port; @@ -6296,8 +6293,6 @@ private class TcpServer extends GridWorker { TcpServer(IgniteLogger log) throws IgniteSpiException { super(ctx.igniteInstanceName(), "tcp-disco-srvr-[]", log, ctx.workersRegistry()); - sslEnabled = spi.isSslEnabled(); - int lastPort = spi.locPortRange == 0 ? spi.locPort : spi.locPort + spi.locPortRange - 1; for (port = spi.locPort; port <= lastPort; port++) { @@ -6348,7 +6343,7 @@ private class TcpServer extends GridWorker { blockingSectionEnd(); } - if (sslEnabled) { + if (spi.isSslEnabled()) { try { // Use the numeric address to avoid a reverse DNS lookup on the accept thread. SSLSocket sslSock = (SSLSocket)spi.sslSocketFactory() From 77d95c5e5aa0fc6c5f8e470fbfaf47a498cb1fc9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 23:54:31 +0300 Subject: [PATCH 161/242] IGNITE-29256 Use the SSL context factory directly in discovery --- .../apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 0d9f0984bfa3c..176e8d9fdd1c5 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -37,7 +37,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; import java.util.regex.Pattern; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -419,7 +418,7 @@ public class TcpDiscoverySpi extends IgniteSpiAdapter implements IgniteDiscovery /** */ @GridToStringExclude - private Supplier sslSockFactorySupplier; + private Factory sslCtxFactory; /** SSL enable/disable flag. */ protected boolean sslEnable; @@ -1680,7 +1679,7 @@ Socket createSocket() throws IOException { /** */ SSLSocketFactory sslSocketFactory() throws IOException { try { - return sslSockFactorySupplier.get(); + return sslCtxFactory.create().getSocketFactory(); } catch (RuntimeException e) { throw new IOException("Failed to obtain the SSL socket factory for a discovery connection.", e); @@ -2171,7 +2170,7 @@ private void initializeImpl() { sslCtx.createSSLEngine(); sslCtx.getSocketFactory(); - sslSockFactorySupplier = () -> sslFactory.create().getSocketFactory(); + sslCtxFactory = sslFactory; } catch (IgniteException e) { throw new IgniteSpiException("Failed to create SSL context. SSL factory: " + sslFactory, e); From 3b7d641d450f042f935229735562ac0050ccd7bd Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 6 Nov 2026 23:59:47 +0300 Subject: [PATCH 162/242] IGNITE-29256 Remove redundant discovery comments --- .../java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java | 1 - .../org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 1 - 2 files changed, 2 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java index 67b5ba08368a1..a2f00010a37c5 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/ServerImpl.java @@ -6345,7 +6345,6 @@ private class TcpServer extends GridWorker { if (spi.isSslEnabled()) { try { - // Use the numeric address to avoid a reverse DNS lookup on the accept thread. SSLSocket sslSock = (SSLSocket)spi.sslSocketFactory() .createSocket(sock, sock.getInetAddress().getHostAddress(), sock.getPort(), true); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 176e8d9fdd1c5..767cf799cacb4 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -2166,7 +2166,6 @@ private void initializeImpl() { try { SSLContext sslCtx = sslFactory.create(); - // Validate protocols and ciphers at startup, before binding the plain listener. sslCtx.createSSLEngine(); sslCtx.getSocketFactory(); From c71769a8d9963fecb51f838086b2bba57ff48512 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 00:11:58 +0300 Subject: [PATCH 163/242] IGNITE-29256 Validate discovery SSL with an unconnected socket --- .../spi/discovery/tcp/TcpDiscoverySpi.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 767cf799cacb4..18b0c0783fefe 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -41,6 +41,7 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; @@ -2163,17 +2164,15 @@ private void initializeImpl() { assertParameter(statsPrintFreq >= 0, "statsPrintFreq >= 0"); if (isSslEnabled()) { - try { - SSLContext sslCtx = sslFactory.create(); - - sslCtx.createSSLEngine(); - sslCtx.getSocketFactory(); - - sslCtxFactory = sslFactory; + try (SSLSocket sock = (SSLSocket)sslFactory.create().getSocketFactory().createSocket()) { + sock.setUseClientMode(false); + sock.setNeedClientAuth(true); } - catch (IgniteException e) { - throw new IgniteSpiException("Failed to create SSL context. SSL factory: " + sslFactory, e); + catch (IOException | IgniteException e) { + throw new IgniteSpiException("Failed to initialize discovery SSL. SSL factory: " + sslFactory, e); } + + sslCtxFactory = sslFactory; } try { From cc1a169ae0bba0df2856b1fcdfc032b7b1882958 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 00:27:14 +0300 Subject: [PATCH 164/242] IGNITE-29256 Keep startup SSL validation focused on socket creation --- .../apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 18b0c0783fefe..30e5059fe4b67 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -41,7 +41,6 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import org.apache.ignite.Ignite; import org.apache.ignite.IgniteAuthenticationException; @@ -2164,9 +2163,8 @@ private void initializeImpl() { assertParameter(statsPrintFreq >= 0, "statsPrintFreq >= 0"); if (isSslEnabled()) { - try (SSLSocket sock = (SSLSocket)sslFactory.create().getSocketFactory().createSocket()) { - sock.setUseClientMode(false); - sock.setNeedClientAuth(true); + try { + sslFactory.create().getSocketFactory().createSocket().close(); } catch (IOException | IgniteException e) { throw new IgniteSpiException("Failed to initialize discovery SSL. SSL factory: " + sslFactory, e); From 490abbecbb1a6d738b1ffbe9cdbb293c31165258 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 01:05:08 +0300 Subject: [PATCH 165/242] IGNITE-29256 Explain startup SSL socket validation --- .../org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 30e5059fe4b67..72512f72b44e2 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -2164,6 +2164,7 @@ private void initializeImpl() { if (isSslEnabled()) { try { + // Validate protocols and ciphers at startup, before binding the plain listener. sslFactory.create().getSocketFactory().createSocket().close(); } catch (IOException | IgniteException e) { From 722fe7129a9bfdbb80df4ad366954fe231cd62da Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 01:15:16 +0300 Subject: [PATCH 166/242] IGNITE-29256 Use a single SSL filter constructor --- .../internal/util/nio/GridNioSslSelfTest.java | 2 +- .../internal/util/nio/ssl/GridNioSslFilter.java | 14 +------------- .../GridNioClientConnectionMultiplexer.java | 2 +- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index a56500a5437ad..72d68301e8905 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -147,7 +147,7 @@ public void testContextLookupFailure() throws Exception { .sendQueueLimit(0) .filters( new GridNioCodecFilter(parser, log, false), - new GridNioSslFilter(sslCtx, true, ByteOrder.nativeOrder(), log, null, null)); + new GridNioSslFilter(() -> sslCtx, true, ByteOrder.nativeOrder(), log, null, null)); } /** {@inheritDoc} */ diff --git a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java index 36d051436eec0..1f72ae7b6cb2e 100644 --- a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java +++ b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java @@ -93,25 +93,13 @@ public class GridNioSslFilter extends GridNioFilterAdapter { /** * Creates SSL filter. * - * @param sslCtx SSL context. + * @param sslCtxSupplier SSL context supplier. * @param directBuf Direct buffer flag. * @param order Byte order. * @param log Logger to use. * @param handshakeDuration Records SSL handshake duration (ms), or {@code null} if metrics disabled. * @param rejectedSesCnt Increments the rejected-sessions counter, or {@code null} if metrics disabled. */ - public GridNioSslFilter( - SSLContext sslCtx, - boolean directBuf, - ByteOrder order, - IgniteLogger log, - @Nullable LongConsumer handshakeDuration, - @Nullable Runnable rejectedSesCnt - ) { - this(() -> sslCtx, directBuf, order, log, handshakeDuration, rejectedSesCnt); - } - - /** */ public GridNioSslFilter( Supplier sslCtxSupplier, boolean directBuf, diff --git a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java index f5091c31d94ec..20a2f41fde0d8 100644 --- a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java +++ b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java @@ -86,7 +86,7 @@ public GridNioClientConnectionMultiplexer(ClientConfiguration cfg) { sslCtx = ClientSslUtils.getSslContext(cfg); if (sslCtx != null) { - GridNioSslFilter sslFilter = new GridNioSslFilter(sslCtx, true, ByteOrder.nativeOrder(), gridLog, null, null); + GridNioSslFilter sslFilter = new GridNioSslFilter(() -> sslCtx, true, ByteOrder.nativeOrder(), gridLog, null, null); sslFilter.directMode(false); filters = new GridNioFilter[] {codecFilter, sslFilter}; } From 50ae47ef66ec7a6643d979e5f871e94b9cd21348 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 01:33:44 +0300 Subject: [PATCH 167/242] IGNITE-29256 Pass SSL factories directly and refresh thin-client connections --- .../ignite/internal/util/IgniteUtils.java | 2 +- .../ignite/ssl/SslContextReloadTest.java | 78 +++++++++++++++++++ .../util/nio/ssl/GridNioSslFilter.java | 12 +-- .../GridNioClientConnectionMultiplexer.java | 18 +++-- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index acf8f4b307140..f0a026e988eab 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -7756,6 +7756,6 @@ public static GridNioSslFilter sslFilter( GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME, "TCP sessions count that were rejected due to SSL errors.")::increment; - return new GridNioSslFilter(sslCtxFactory::create, directBuf, order, log, handshakeDuration, rejectedSesCnt); + return new GridNioSslFilter(sslCtxFactory, directBuf, order, log, handshakeDuration, rejectedSesCnt); } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index e4506dc3c4849..932fd4e56437a 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -22,6 +22,9 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.SocketException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -31,6 +34,7 @@ import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteException; import org.apache.ignite.Ignition; +import org.apache.ignite.client.ClientConnectionException; import org.apache.ignite.client.ClientRetryNonePolicy; import org.apache.ignite.client.IgniteClient; import org.apache.ignite.client.SslMode; @@ -143,6 +147,80 @@ public void testRotateKeyAndTrust() throws Exception { assertEquals(1, result); } + /** */ + @Test + public void testThinClientReconnectUsesReloadedContext() throws Exception { + ReloadableSslContextFactory serverFactory = new ReloadableSslContextFactory(); + ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(); + IgniteEx node = startNode(0, serverFactory); + + ClientConfiguration cfg = new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setTimeout(SOCKET_TIMEOUT) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(clientFactory::create) + .setRetryPolicy(new ClientRetryNonePolicy()) + .setPartitionAwarenessEnabled(false) + .setClusterDiscoveryEnabled(false); + + try (IgniteClient client = Ignition.startClient(cfg)) { + client.cacheNames(); + + stopGrid(0); + + assertThrows(ClientConnectionException.class, client::cacheNames); + + serverFactory.reload("node02", "trusttwo"); + clientFactory.reload("node03", "trusttwo"); + + startNode(0, serverFactory); + + client.cacheNames(); + } + } + + /** */ + @Test + public void testThinClientReconnectDoesNotReloadFiles() throws Exception { + ReloadableSslContextFactory serverFactory = new ReloadableSslContextFactory(); + IgniteEx node = startNode(0, serverFactory); + Path keys = Files.createTempFile("thin-client-keys", ".jks"); + + try { + Files.copy(Path.of(keyStorePath("node01")), keys, StandardCopyOption.REPLACE_EXISTING); + + ClientConfiguration cfg = new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setTimeout(SOCKET_TIMEOUT) + .setSslMode(SslMode.REQUIRED) + .setSslClientCertificateKeyStorePath(keys.toString()) + .setSslClientCertificateKeyStoreType("JKS") + .setSslClientCertificateKeyStorePassword(keyStorePassword()) + .setSslTrustCertificateKeyStorePath(keyStorePath("trustone")) + .setSslTrustCertificateKeyStoreType("JKS") + .setSslTrustCertificateKeyStorePassword(keyStorePassword()) + .setRetryPolicy(new ClientRetryNonePolicy()) + .setPartitionAwarenessEnabled(false) + .setClusterDiscoveryEnabled(false); + + try (IgniteClient client = Ignition.startClient(cfg)) { + client.cacheNames(); + + Files.delete(keys); + stopGrid(0); + + assertThrows(ClientConnectionException.class, client::cacheNames); + + startNode(0, serverFactory); + + client.cacheNames(); + } + } + finally { + Files.deleteIfExists(keys); + } + } + /** Checks both new and established connections. */ @Test public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { diff --git a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java index 1f72ae7b6cb2e..d68aff5c73a34 100644 --- a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java +++ b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java @@ -21,7 +21,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.function.LongConsumer; -import java.util.function.Supplier; +import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; @@ -70,7 +70,7 @@ public class GridNioSslFilter extends GridNioFilterAdapter { private String[] enabledProtos; /** */ - private final Supplier sslCtxSupplier; + private final Factory sslCtxFactory; /** Order. */ private ByteOrder order; @@ -93,7 +93,7 @@ public class GridNioSslFilter extends GridNioFilterAdapter { /** * Creates SSL filter. * - * @param sslCtxSupplier SSL context supplier. + * @param sslCtxFactory SSL context factory. * @param directBuf Direct buffer flag. * @param order Byte order. * @param log Logger to use. @@ -101,7 +101,7 @@ public class GridNioSslFilter extends GridNioFilterAdapter { * @param rejectedSesCnt Increments the rejected-sessions counter, or {@code null} if metrics disabled. */ public GridNioSslFilter( - Supplier sslCtxSupplier, + Factory sslCtxFactory, boolean directBuf, ByteOrder order, IgniteLogger log, @@ -111,7 +111,7 @@ public GridNioSslFilter( super("SSL filter"); this.log = log; - this.sslCtxSupplier = sslCtxSupplier; + this.sslCtxFactory = sslCtxFactory; this.directBuf = directBuf; this.order = order; this.handshakeDuration = handshakeDuration; @@ -183,7 +183,7 @@ public void enabledProtocols(String... enabledProtos) { SSLContext sslCtx; try { - sslCtx = sslCtxSupplier.get(); + sslCtx = sslCtxFactory.create(); } catch (RuntimeException e) { throw new IgniteCheckedException("Failed to obtain SSL context for a new connection.", e); diff --git a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java index 20a2f41fde0d8..054dd3eb7ead2 100644 --- a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java +++ b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; @@ -63,7 +64,7 @@ public class GridNioClientConnectionMultiplexer implements ClientConnectionMulti private final GridNioServer srv; /** */ - private final SSLContext sslCtx; + private final boolean sslEnabled; /** */ private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); @@ -83,10 +84,17 @@ public GridNioClientConnectionMultiplexer(ClientConfiguration cfg) { GridNioFilter codecFilter = new GridNioCodecFilter(new GridNioClientParser(), gridLog, false); - sslCtx = ClientSslUtils.getSslContext(cfg); + SSLContext sslCtx = ClientSslUtils.getSslContext(cfg); - if (sslCtx != null) { - GridNioSslFilter sslFilter = new GridNioSslFilter(() -> sslCtx, true, ByteOrder.nativeOrder(), gridLog, null, null); + sslEnabled = sslCtx != null; + + if (sslEnabled) { + Factory sslCtxFactory = cfg.getSslContextFactory(); + + if (sslCtxFactory == null) + sslCtxFactory = () -> sslCtx; + + GridNioSslFilter sslFilter = new GridNioSslFilter(sslCtxFactory, true, ByteOrder.nativeOrder(), gridLog, null, null); sslFilter.directMode(false); filters = new GridNioFilter[] {codecFilter, sslFilter}; } @@ -189,7 +197,7 @@ private GridNioSession createNioSession(SocketChannel ch) throws IgniteCheckedEx GridFutureAdapter sslHandshakeFut = null; - if (sslCtx != null) { + if (sslEnabled) { sslHandshakeFut = new GridFutureAdapter<>(); meta.put(GridNioSslFilter.HANDSHAKE_FUT_META_KEY, sslHandshakeFut); From dc3852ed0fc62e85f827dcbc285d949e56678af1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 01:55:20 +0300 Subject: [PATCH 168/242] IGNITE-29256 Pass the thin client SSL factory directly to NIO --- .../ignite/ssl/SslContextReloadTest.java | 20 +++++++++++++++++ .../internal/client/thin/ClientSslUtils.java | 22 ++++++++----------- .../GridNioClientConnectionMultiplexer.java | 9 ++------ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 932fd4e56437a..5c7d245350053 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -43,6 +43,7 @@ import org.apache.ignite.configuration.ConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; @@ -179,6 +180,25 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { } } + /** */ + @Test + public void testThinClientReportsContextFactoryFailure() throws Exception { + IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); + IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); + + ClientConfiguration cfg = new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setHandshakeTimeout(SOCKET_TIMEOUT) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(() -> { + throw failure; + }); + + ClientConnectionException err = assertThrows(ClientConnectionException.class, () -> Ignition.startClient(cfg).close()); + + assertSame(failure, X.cause(err, IllegalStateException.class)); + } + /** */ @Test public void testThinClientReconnectDoesNotReloadFiles() throws Exception { diff --git a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java index 4264aa1c3a344..11d4404df0720 100644 --- a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java +++ b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java @@ -71,25 +71,19 @@ public class ClientSslUtils { }; /** - * Gets SSL context for the given client configuration. + * Gets SSL context factory for the given client configuration. * * @param cfg Configuration. - * @return {@link SSLContext} when SSL is enabled in the configuration; null otherwise. + * @return SSL context factory when SSL is enabled in the configuration; {@code null} otherwise. */ - public static SSLContext getSslContext(ClientConfiguration cfg) { + public static Factory getSslContextFactory(ClientConfiguration cfg) { if (cfg.getSslMode() == SslMode.DISABLED) return null; Factory sslCtxFactory = cfg.getSslContextFactory(); - if (sslCtxFactory != null) { - try { - return sslCtxFactory.create(); - } - catch (Exception e) { - throw new ClientError("SSL Context Factory failed", e); - } - } + if (sslCtxFactory != null) + return sslCtxFactory; BiFunction or = (val, dflt) -> val == null || val.isEmpty() ? dflt : val; @@ -131,7 +125,9 @@ public static SSLContext getSslContext(ClientConfiguration cfg) { .allMatch(s -> s == null || s.isEmpty()) ) { try { - return SSLContext.getDefault(); + SSLContext sslCtx = SSLContext.getDefault(); + + return () -> sslCtx; } catch (NoSuchAlgorithmException e) { throw new ClientError("Default SSL context cryptographic algorithm is not available", e); @@ -149,7 +145,7 @@ public static SSLContext getSslContext(ClientConfiguration cfg) { sslCtx.init(keyManagers, trustManagers, null); - return sslCtx; + return () -> sslCtx; } catch (NoSuchAlgorithmException e) { throw new ClientError("SSL context cryptographic algorithm is not available", e); diff --git a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java index 054dd3eb7ead2..568f2d1cc8b41 100644 --- a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java +++ b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/io/gridnioserver/GridNioClientConnectionMultiplexer.java @@ -84,16 +84,11 @@ public GridNioClientConnectionMultiplexer(ClientConfiguration cfg) { GridNioFilter codecFilter = new GridNioCodecFilter(new GridNioClientParser(), gridLog, false); - SSLContext sslCtx = ClientSslUtils.getSslContext(cfg); + Factory sslCtxFactory = ClientSslUtils.getSslContextFactory(cfg); - sslEnabled = sslCtx != null; + sslEnabled = sslCtxFactory != null; if (sslEnabled) { - Factory sslCtxFactory = cfg.getSslContextFactory(); - - if (sslCtxFactory == null) - sslCtxFactory = () -> sslCtx; - GridNioSslFilter sslFilter = new GridNioSslFilter(sslCtxFactory, true, ByteOrder.nativeOrder(), gridLog, null, null); sslFilter.directMode(false); filters = new GridNioFilter[] {codecFilter, sslFilter}; From 9d04c9062fb8f34177fdea96aac569d7eabf5571 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 02:27:54 +0300 Subject: [PATCH 169/242] IGNITE-29256 Preserve discovery retries and count early SSL rejections --- .../spi/discovery/tcp/TcpDiscoverySpi.java | 7 ++-- .../internal/util/nio/GridNioSslSelfTest.java | 26 +++++++++++--- .../TcpDiscoverySslConnectionFailureTest.java | 36 +++++++++++++++++++ .../util/nio/ssl/GridNioSslFilter.java | 3 ++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index 72512f72b44e2..d59d613faefd1 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -1668,11 +1668,14 @@ Socket createSocket() throws IOException { return sock; } - catch (IOException e) { + catch (IOException | RuntimeException e) { if (sock != null) U.closeQuiet(sock); - throw e; + if (e instanceof IOException) + throw (IOException)e; + + throw new IOException("Failed to create discovery socket.", e); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 72d68301e8905..cd51d713dc566 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -21,13 +21,17 @@ import java.net.Socket; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter; import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.metric.IntMetric; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.jetbrains.annotations.Nullable; @@ -47,10 +51,13 @@ public class GridNioSslSelfTest extends GridNioSelfTest { sslCtx = GridTestUtils.sslContext(); } - /** A failed context lookup leaves the NIO worker available. */ + /** A failed context lookup is counted and leaves the NIO worker available. */ @Test public void testContextLookupFailure() throws Exception { AtomicBoolean failLookup = new AtomicBoolean(); + CountDownLatch sesClosed = new CountDownLatch(1); + CountDownLatch sesConnected = new CountDownLatch(1); + MetricRegistryImpl mreg = new MetricRegistryImpl("nio-ssl", name -> null, name -> null, name -> null, log); SslContextFactory sslCtxFactory = new SslContextFactory() { /** */ @@ -71,12 +78,14 @@ public void testContextLookupFailure() throws Exception { .listener(new GridNioServerListenerAdapter<>() { /** {@inheritDoc} */ @Override public void onConnected(GridNioSession ses) { - // No-op. + sesConnected.countDown(); } /** {@inheritDoc} */ @Override public void onDisconnected(GridNioSession ses, @Nullable Exception e) { - // No-op. + // The close notification follows SSL filter cleanup; the error notification precedes it. + if (e == null) + sesClosed.countDown(); } /** {@inheritDoc} */ @@ -87,9 +96,11 @@ public void testContextLookupFailure() throws Exception { .logger(log) .selectorCount(1) .byteOrder(ByteOrder.nativeOrder()) - .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, null)) + .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, mreg)) .build(); + IntMetric rejected = mreg.findMetric(GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME); + srv.start(); try { @@ -105,15 +116,22 @@ public void testContextLookupFailure() throws Exception { assertFalse(failLookup.get()); } + assertTrue(sesClosed.await(5, TimeUnit.SECONDS)); + assertEquals(1, rejected.value()); + try (SSLSocket sock = (SSLSocket)createSocket()) { sock.connect(addr, 5_000); sock.setSoTimeout(5_000); sock.startHandshake(); + + assertTrue(sesConnected.await(5, TimeUnit.SECONDS)); } } finally { srv.stop(); } + + assertEquals(1, rejected.value()); } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index 54d0638f65917..de3a49d5dbd32 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -17,6 +17,7 @@ package org.apache.ignite.spi.discovery.tcp; +import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; import java.util.concurrent.atomic.AtomicReference; @@ -32,8 +33,11 @@ import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; +import static org.apache.ignite.testframework.GridTestUtils.assertThrowsAnyCause; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.junit.Assert.assertThrows; /** Tests discovery after individual TLS setup failures. */ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest { @@ -75,6 +79,38 @@ public void testSocketWrapFailureKeepsAccepting() throws Exception { assertAcceptsTls(addr, ctx); } + /** */ + @Test + public void testOutgoingSocketSetupFailure() throws Exception { + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + AtomicReference curCtx = new AtomicReference<>(ctx); + IgniteEx node = startNode(0, curCtx::get); + TcpDiscoverySpi spi = (TcpDiscoverySpi)node.configuration().getDiscoverySpi(); + + SSLParameters badParams = new SSLParameters(); + badParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); + curCtx.set(new SSLContextWrapper(ctx, badParams)); + + IOException err = assertThrows(IOException.class, () -> spi.createSocket().close()); + + assertTrue(err.getCause() instanceof IllegalArgumentException); + + curCtx.set(ctx); + + try (Socket sock = spi.createSocket()) { + assertTrue(sock.isBound()); + } + } + + /** */ + @Test + public void testInvalidProtocolPreventsFirstNodeStartup() throws Exception { + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + factory.setProtocols("TestUnsupportedProtocol"); + + assertThrowsAnyCause(log, () -> startNode(0, factory), IllegalArgumentException.class, "TestUnsupportedProtocol"); + } + /** */ private IgniteEx startNode(int idx, Factory factory) throws Exception { return startGrid(idx, cfg -> { diff --git a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java index d68aff5c73a34..260dd6b76b060 100644 --- a/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java +++ b/modules/nio/src/main/java/org/apache/ignite/internal/util/nio/ssl/GridNioSslFilter.java @@ -186,6 +186,9 @@ public void enabledProtocols(String... enabledProtos) { sslCtx = sslCtxFactory.create(); } catch (RuntimeException e) { + if (rejectedSesCnt != null && ses.meta(HANDSHAKE_FUT_META_KEY) == null) + rejectedSesCnt.run(); + throw new IgniteCheckedException("Failed to obtain SSL context for a new connection.", e); } From 46a0f4f3afa4480a70babb8085bf08343d067431 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 02:50:43 +0300 Subject: [PATCH 170/242] IGNITE-29256 Make TLS test scenarios explicit --- .../internal/util/nio/GridNioSslSelfTest.java | 44 ++- .../TcpDiscoverySslConnectionFailureTest.java | 111 +++---- .../ignite/ssl/SslContextReloadTest.java | 303 ++++++++---------- 3 files changed, 200 insertions(+), 258 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index cd51d713dc566..580a74b44599f 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteCheckedException; @@ -32,7 +33,6 @@ import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.metric.IntMetric; -import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; import org.jetbrains.annotations.Nullable; import org.junit.Test; @@ -51,25 +51,19 @@ public class GridNioSslSelfTest extends GridNioSelfTest { sslCtx = GridTestUtils.sslContext(); } - /** A failed context lookup is counted and leaves the NIO worker available. */ + /** */ @Test - public void testContextLookupFailure() throws Exception { - AtomicBoolean failLookup = new AtomicBoolean(); - CountDownLatch sesClosed = new CountDownLatch(1); - CountDownLatch sesConnected = new CountDownLatch(1); + public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() throws Exception { + AtomicBoolean failNextLookup = new AtomicBoolean(); + CountDownLatch failedSesClosed = new CountDownLatch(1); + CountDownLatch serverConnected = new CountDownLatch(1); MetricRegistryImpl mreg = new MetricRegistryImpl("nio-ssl", name -> null, name -> null, name -> null, log); - SslContextFactory sslCtxFactory = new SslContextFactory() { - /** */ - private static final long serialVersionUID = 0L; + Factory sslCtxFactory = () -> { + if (failNextLookup.getAndSet(false)) + throw new IgniteException("Test SSL context lookup failure"); - /** {@inheritDoc} */ - @Override public SSLContext create() { - if (failLookup.getAndSet(false)) - throw new IgniteException("Test SSL context lookup failure"); - - return GridNioSslSelfTest.sslCtx; - } + return sslCtx; }; GridNioServer srv = GridNioServer.builder() @@ -78,14 +72,14 @@ public void testContextLookupFailure() throws Exception { .listener(new GridNioServerListenerAdapter<>() { /** {@inheritDoc} */ @Override public void onConnected(GridNioSession ses) { - sesConnected.countDown(); + serverConnected.countDown(); } /** {@inheritDoc} */ @Override public void onDisconnected(GridNioSession ses, @Nullable Exception e) { // The close notification follows SSL filter cleanup; the error notification precedes it. if (e == null) - sesClosed.countDown(); + failedSesClosed.countDown(); } /** {@inheritDoc} */ @@ -99,12 +93,12 @@ public void testContextLookupFailure() throws Exception { .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, mreg)) .build(); - IntMetric rejected = mreg.findMetric(GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME); + IntMetric rejectedSessions = mreg.findMetric(GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME); srv.start(); try { - failLookup.set(true); + failNextLookup.set(true); InetSocketAddress addr = new InetSocketAddress(U.getLocalHost(), srv.port()); @@ -113,25 +107,25 @@ public void testContextLookupFailure() throws Exception { sock.setSoTimeout(5_000); assertEquals(-1, sock.getInputStream().read()); - assertFalse(failLookup.get()); + assertFalse(failNextLookup.get()); } - assertTrue(sesClosed.await(5, TimeUnit.SECONDS)); - assertEquals(1, rejected.value()); + assertTrue(failedSesClosed.await(5, TimeUnit.SECONDS)); + assertEquals(1, rejectedSessions.value()); try (SSLSocket sock = (SSLSocket)createSocket()) { sock.connect(addr, 5_000); sock.setSoTimeout(5_000); sock.startHandshake(); - assertTrue(sesConnected.await(5, TimeUnit.SECONDS)); + assertTrue(serverConnected.await(5, TimeUnit.SECONDS)); } } finally { srv.stop(); } - assertEquals(1, rejected.value()); + assertEquals(1, rejectedSessions.value()); } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index de3a49d5dbd32..5ed9d22c01b8f 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -34,17 +34,15 @@ import org.junit.Test; import static org.apache.ignite.testframework.GridTestUtils.assertThrowsAnyCause; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; -import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertThrows; -/** Tests discovery after individual TLS setup failures. */ +/** Tests discovery TLS startup and connection failures. */ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest { /** */ private static final int SOCK_TIMEOUT = 10_000; - /** Discovery addresses shared within one test. */ + /** */ private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); /** {@inheritDoc} */ @@ -52,39 +50,57 @@ public class TcpDiscoverySslConnectionFailureTest extends GridCommonAbstractTest stopAllGrids(); } + /** */ + @Test + public void testInvalidProtocolPreventsFirstNodeStartup() throws Exception { + SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + factory.setProtocols("TestUnsupportedProtocol"); + + assertThrowsAnyCause(log, () -> startNode(factory), IllegalArgumentException.class, "TestUnsupportedProtocol"); + } + /** */ @Test public void testContextLookupFailureKeepsAccepting() throws Exception { - FailingSslFactory factory = new FailingSslFactory(); - InetSocketAddress addr = address(startNode(0, factory)); + Factory validFactory = sslTrustedFactory("node01", "trustone"); + AtomicReference> nextFactory = new AtomicReference<>(validFactory); + InetSocketAddress addr = discoveryAddress(startNode(() -> nextFactory.getAndSet(validFactory).create())); - assertRejectsOneConnection(addr, factory, () -> { + nextFactory.set(() -> { throw new IgniteException("Test failure looking up the SSL context"); }); - assertAcceptsTls(addr, factory.create()); + + assertConnectionClosed(addr); + + assertSame(validFactory, nextFactory.get()); + + assertAcceptsTls(addr, validFactory.create()); } /** */ @Test - public void testSocketWrapFailureKeepsAccepting() throws Exception { - FailingSslFactory factory = new FailingSslFactory(); - InetSocketAddress addr = address(startNode(0, factory)); - SSLContext ctx = factory.create(); + public void testSocketSetupFailureKeepsAccepting() throws Exception { + SSLContext validCtx = sslTrustedFactory("node01", "trustone").create(); + AtomicReference nextCtx = new AtomicReference<>(validCtx); + InetSocketAddress addr = discoveryAddress(startNode(() -> nextCtx.getAndSet(validCtx))); + SSLParameters invalidParams = new SSLParameters(); + invalidParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); - SSLParameters badParams = new SSLParameters(); - badParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); + nextCtx.set(new SSLContextWrapper(validCtx, invalidParams)); - // This context can be obtained, but applying its parameters to the layered socket fails. - assertRejectsOneConnection(addr, factory, () -> new SSLContextWrapper(ctx, badParams)); - assertAcceptsTls(addr, ctx); + assertConnectionClosed(addr); + + assertSame(validCtx, nextCtx.get()); + + assertAcceptsTls(addr, validCtx); } /** */ @Test - public void testOutgoingSocketSetupFailure() throws Exception { + public void testOutgoingSocketSetupFailureIsReportedAsIoException() throws Exception { SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); AtomicReference curCtx = new AtomicReference<>(ctx); - IgniteEx node = startNode(0, curCtx::get); + IgniteEx node = startNode(curCtx::get); TcpDiscoverySpi spi = (TcpDiscoverySpi)node.configuration().getDiscoverySpi(); SSLParameters badParams = new SSLParameters(); @@ -103,43 +119,27 @@ public void testOutgoingSocketSetupFailure() throws Exception { } /** */ - @Test - public void testInvalidProtocolPreventsFirstNodeStartup() throws Exception { - SslContextFactory factory = (SslContextFactory)sslTrustedFactory("node01", "trustone"); - factory.setProtocols("TestUnsupportedProtocol"); - - assertThrowsAnyCause(log, () -> startNode(0, factory), IllegalArgumentException.class, "TestUnsupportedProtocol"); - } - - /** */ - private IgniteEx startNode(int idx, Factory factory) throws Exception { - return startGrid(idx, cfg -> { + private IgniteEx startNode(Factory factory) throws Exception { + return startGrid(0, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); cfg.setSslContextFactory(factory); }); } /** */ - private static InetSocketAddress address(IgniteEx node) { + private static InetSocketAddress discoveryAddress(IgniteEx node) { int port = ((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).getLocalPort(); return new InetSocketAddress("127.0.0.1", port); } - /** Injects a TLS failure for one accepted socket. */ - private static void assertRejectsOneConnection( - InetSocketAddress addr, - FailingSslFactory factory, - Factory nextCtx - ) throws Exception { - assertTrue(factory.nextCtx.compareAndSet(null, nextCtx)); - + /** */ + private static void assertConnectionClosed(InetSocketAddress addr) throws Exception { try (Socket sock = new Socket()) { sock.connect(addr, SOCK_TIMEOUT); sock.setSoTimeout(SOCK_TIMEOUT); assertEquals("Discovery must close the socket after TLS setup fails", -1, sock.getInputStream().read()); - assertNull(factory.nextCtx.get()); } } @@ -151,35 +151,4 @@ private static void assertAcceptsTls(InetSocketAddress addr, SSLContext ctx) thr sock.startHandshake(); } } - - /** One-shot context override on the discovery accept thread. */ - private static class FailingSslFactory extends SslContextFactory { - /** */ - private static final long serialVersionUID = 0L; - - /** One-shot context supplier. */ - private final AtomicReference> nextCtx = new AtomicReference<>(); - - /** */ - private FailingSslFactory() { - char[] pwd = keyStorePassword().toCharArray(); - - setKeyStoreFilePath(keyStorePath("node01")); - setKeyStorePassword(pwd); - setTrustStoreFilePath(keyStorePath("trustone")); - setTrustStorePassword(pwd); - } - - /** {@inheritDoc} */ - @Override public SSLContext create() { - if (Thread.currentThread().getName().startsWith("tcp-disco-srvr-")) { - Factory next = nextCtx.getAndSet(null); - - if (next != null) - return next.create(); - } - - return super.create(); - } - } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 5c7d245350053..0629e29b250b5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -71,81 +71,138 @@ public class SslContextReloadTest extends GridCommonAbstractTest { stopAllGrids(); } - /** Port retries do not repeat SSL context initialization. */ + /** */ @Test - public void testRestInitializesContextOnceOnPortRetry() throws Exception { - SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); - AtomicInteger calls = new AtomicInteger(); + public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); + IgniteEx node = startNode(0, factory::create); + int[] ports = tlsPorts(node); - for (int attempt = 0; attempt < 10; attempt++) { - try (ServerSocket occupied = new ServerSocket()) { - occupied.bind(new InetSocketAddress("127.0.0.1", 0)); + for (int port : ports) + assertServerCertificate(port, "node01", "trustone", "CN=node01"); - if (occupied.getLocalPort() > 65_525) - continue; + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory("node01", "trustone")); - IgniteEx node = startGrid(0, cfg -> { - ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + try (IgniteClient clientWithOldTrust = Ignition.startClient(clientCfg)) { + clientWithOldTrust.cacheNames(); - cfg.setClientConnectorConfiguration(null) - .setConnectorConfiguration(new ConnectorConfiguration() - .setHost("127.0.0.1") - .setPort(occupied.getLocalPort()) - .setPortRange(10) - .setSslEnabled(true) - .setSslClientAuth(true) - .setSslFactory(() -> { - calls.incrementAndGet(); + factory.reload("node02", "trusttwo"); + + for (int port : ports) { + assertServerCertificate(port, "node03", "trusttwo", "CN=node02"); + assertOldClientCertificateRejected(port); + } - return ctx; - })); - }); + clientWithOldTrust.cacheNames(); + } - int port = node.localNode().attribute(ATTR_REST_TCP_PORT); + IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory("node03", "trusttwo")); - assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); - assertEquals("SSL context must be initialized once before the first connection", 1, calls.get()); - assertCertificate(port, "node01", "trustone", "CN=node01"); + assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peerWithNewTrust.localNode().id())); - return; - } - } + int result = node.compute(node.cluster().forNodeId(peerWithNewTrust.localNode().id())).call(() -> 1); - fail("Could not allocate a port with room for REST retries"); + assertEquals(1, result); } - /** A plain factory supplies rotated contexts while an established thin client survives rotation. */ + /** */ @Test - public void testRotateKeyAndTrust() throws Exception { - ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); - IgniteEx node = startNode(0, factory::create); + public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { + String replacementKeyStore = keyStorePath("node02"); + CountDownLatch reloadStarted = new CountDownLatch(1); + CountDownLatch allowReload = new CountDownLatch(1); + + ReloadableSslContextFactory factory = new ReloadableSslContextFactory() { + /** */ + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + if (replacementKeyStore.equals(path)) { + reloadStarted.countDown(); + + try { + if (!allowReload.await(60, TimeUnit.SECONDS)) + throw new IOException("Timed out waiting to open the replacement key store"); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + + throw new IOException(e); + } + } + + return super.openFileInputStream(path); + } + }; + + IgniteEx node = startNode(0, factory); int[] ports = tlsPorts(node); - for (int port : ports) - assertCertificate(port, "node01", "trustone", "CN=node01"); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory("node01", "trustone")); - // This client trusts only the original CA, so it cannot reconnect after the server rotates. - try (IgniteClient client = thinClient(node)) { + try (IgniteClient client = Ignition.startClient(clientCfg)) { client.cacheNames(); - factory.reload("node02", "trusttwo"); + factory.setKeyStorePassword("wrong-password".toCharArray()); - for (int port : ports) { - assertCertificate(port, "node03", "trusttwo", "CN=node02"); - assertClientCertificateRejected(port); + IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { + assertThrows(IgniteException.class, () -> factory.reload("node02", "trusttwo")); + }); + + try { + assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); + + for (int port : ports) + assertServerCertificate(port, "node01", "trustone", "CN=node01"); + + client.cacheNames(); } + finally { + allowReload.countDown(); + } + + reload.get(30, TimeUnit.SECONDS); + + for (int port : ports) + assertServerCertificate(port, "node01", "trustone", "CN=node01"); client.cacheNames(); } + } - // The joining server trusts only the new CA. It did not exist before the reload. - IgniteEx peer = startNode(1, sslTrustedFactory("node03", "trusttwo")); - - assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peer.localNode().id())); + /** */ + @Test + public void testRestInitializesContextOnceOnPortRetry() throws Exception { + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + AtomicInteger factoryCalls = new AtomicInteger(); + + try (ServerSocket occupied = new ServerSocket()) { + occupied.bind(new InetSocketAddress("127.0.0.1", GridTestUtils.getFreeCommPort())); + + IgniteEx node = startGrid(0, cfg -> { + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); + + cfg.setClientConnectorConfiguration(null) + .setConnectorConfiguration(new ConnectorConfiguration() + .setHost("127.0.0.1") + .setPort(occupied.getLocalPort()) + .setPortRange(10) + .setSslEnabled(true) + .setSslClientAuth(true) + .setSslFactory(() -> { + factoryCalls.incrementAndGet(); + + return ctx; + })); + }); - int result = node.compute(node.cluster().forNodeId(peer.localNode().id())).call(() -> 1); + int port = node.localNode().attribute(ATTR_REST_TCP_PORT); - assertEquals(1, result); + assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); + assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); + assertServerCertificate(port, "node01", "trustone", "CN=node01"); + } } /** */ @@ -155,14 +212,7 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(); IgniteEx node = startNode(0, serverFactory); - ClientConfiguration cfg = new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setTimeout(SOCKET_TIMEOUT) - .setSslMode(SslMode.REQUIRED) - .setSslContextFactory(clientFactory::create) - .setRetryPolicy(new ClientRetryNonePolicy()) - .setPartitionAwarenessEnabled(false) - .setClusterDiscoveryEnabled(false); + ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); try (IgniteClient client = Ignition.startClient(cfg)) { client.cacheNames(); @@ -182,51 +232,26 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { /** */ @Test - public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); - IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); - - ClientConfiguration cfg = new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setHandshakeTimeout(SOCKET_TIMEOUT) - .setSslMode(SslMode.REQUIRED) - .setSslContextFactory(() -> { - throw failure; - }); - - ClientConnectionException err = assertThrows(ClientConnectionException.class, () -> Ignition.startClient(cfg).close()); - - assertSame(failure, X.cause(err, IllegalStateException.class)); - } - - /** */ - @Test - public void testThinClientReconnectDoesNotReloadFiles() throws Exception { - ReloadableSslContextFactory serverFactory = new ReloadableSslContextFactory(); + public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { + Factory serverFactory = sslTrustedFactory("node01", "trustone"); IgniteEx node = startNode(0, serverFactory); - Path keys = Files.createTempFile("thin-client-keys", ".jks"); + Path keyStore = Files.createTempFile("thin-client-keys", ".jks"); try { - Files.copy(Path.of(keyStorePath("node01")), keys, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Path.of(keyStorePath("node01")), keyStore, StandardCopyOption.REPLACE_EXISTING); - ClientConfiguration cfg = new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setTimeout(SOCKET_TIMEOUT) - .setSslMode(SslMode.REQUIRED) - .setSslClientCertificateKeyStorePath(keys.toString()) + ClientConfiguration cfg = clientConfiguration(node) + .setSslClientCertificateKeyStorePath(keyStore.toString()) .setSslClientCertificateKeyStoreType("JKS") .setSslClientCertificateKeyStorePassword(keyStorePassword()) .setSslTrustCertificateKeyStorePath(keyStorePath("trustone")) .setSslTrustCertificateKeyStoreType("JKS") - .setSslTrustCertificateKeyStorePassword(keyStorePassword()) - .setRetryPolicy(new ClientRetryNonePolicy()) - .setPartitionAwarenessEnabled(false) - .setClusterDiscoveryEnabled(false); + .setSslTrustCertificateKeyStorePassword(keyStorePassword()); try (IgniteClient client = Ignition.startClient(cfg)) { client.cacheNames(); - Files.delete(keys); + Files.delete(keyStore); stopGrid(0); assertThrows(ClientConnectionException.class, client::cacheNames); @@ -237,75 +262,30 @@ public void testThinClientReconnectDoesNotReloadFiles() throws Exception { } } finally { - Files.deleteIfExists(keys); + Files.deleteIfExists(keyStore); } } - /** Checks both new and established connections. */ + /** */ @Test - public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { - String replacement = keyStorePath("node02"); - CountDownLatch opening = new CountDownLatch(1); - CountDownLatch resume = new CountDownLatch(1); - - ReloadableSslContextFactory factory = new ReloadableSslContextFactory() { - /** */ - private static final long serialVersionUID = 0L; - - /** {@inheritDoc} */ - @Override protected InputStream openFileInputStream(String path) throws IOException { - if (replacement.equals(path)) { - opening.countDown(); - - try { - if (!resume.await(60, TimeUnit.SECONDS)) - throw new IOException("Timed out waiting to open the replacement key store"); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - - throw new IOException(e); - } - } - - return super.openFileInputStream(path); - } - }; - - IgniteEx node = startNode(0, factory); - int[] ports = tlsPorts(node); - - try (IgniteClient client = thinClient(node)) { - client.cacheNames(); - - factory.setKeyStorePassword("wrong-password".toCharArray()); + public void testThinClientReportsContextFactoryFailure() throws Exception { + IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); + IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); - IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { - assertThrows(IgniteException.class, () -> factory.reload("node02", "trusttwo")); + ClientConfiguration cfg = new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setHandshakeTimeout(SOCKET_TIMEOUT) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(() -> { + throw failure; }); - try { - assertTrue("Reload did not reach the replacement key store", opening.await(30, TimeUnit.SECONDS)); - - for (int port : ports) - assertCertificate(port, "node01", "trustone", "CN=node01"); - - client.cacheNames(); - } - finally { - resume.countDown(); - } - - reload.get(30, TimeUnit.SECONDS); - - for (int port : ports) - assertCertificate(port, "node01", "trustone", "CN=node01"); + ClientConnectionException err = assertThrows(ClientConnectionException.class, () -> Ignition.startClient(cfg).close()); - client.cacheNames(); - } + assertSame(failure, X.cause(err, IllegalStateException.class)); } - /** All four listeners share one factory. */ + /** */ private IgniteEx startNode(int idx, Factory factory) throws Exception { return startGrid(idx, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); @@ -332,30 +312,29 @@ private static int[] tlsPorts(IgniteEx node) { }; } - /** Thin client that trusts only the original CA. */ - private static IgniteClient thinClient(IgniteEx node) { - return Ignition.startClient(new ClientConfiguration() + /** */ + private static ClientConfiguration clientConfiguration(IgniteEx node) { + return new ClientConfiguration() .setAddresses("127.0.0.1:" + node.context().clientListener().port()) .setTimeout(SOCKET_TIMEOUT) .setSslMode(SslMode.REQUIRED) - .setSslContextFactory(sslTrustedFactory("node01", "trustone")) .setRetryPolicy(new ClientRetryNonePolicy()) .setPartitionAwarenessEnabled(false) - .setClusterDiscoveryEnabled(false)); + .setClusterDiscoveryEnabled(false); } - /** Checks the server certificate after mutual TLS. */ - private static void assertCertificate(int port, String keys, String trust, String subject) throws IOException { - try (SSLSocket sock = probeSocket(port, keys, trust)) { + /** */ + private static void assertServerCertificate(int port, String keys, String trust, String expectedSubject) throws IOException { + try (SSLSocket sock = connectTlsSocket(port, keys, trust)) { sock.startHandshake(); - assertEquals("Peer certificate on port " + port, subject, sock.getSession().getPeerPrincipal().getName()); + assertEquals("Peer certificate on port " + port, expectedSubject, sock.getSession().getPeerPrincipal().getName()); } } /** The probe trusts the replacement server certificate. */ - private static void assertClientCertificateRejected(int port) throws IOException { - try (SSLSocket sock = probeSocket(port, "node01", "trustboth")) { + private static void assertOldClientCertificateRejected(int port) throws IOException { + try (SSLSocket sock = connectTlsSocket(port, "node01", "trustboth")) { try { sock.startHandshake(); @@ -368,7 +347,7 @@ private static void assertClientCertificateRejected(int port) throws IOException } /** A fresh TLS context prevents session resumption. */ - private static SSLSocket probeSocket(int port, String keys, String trust) throws IOException { + private static SSLSocket connectTlsSocket(int port, String keys, String trust) throws IOException { SSLContext ctx = sslTrustedFactory(keys, trust).create(); SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket(); @@ -387,7 +366,7 @@ private static SSLSocket probeSocket(int port, String keys, String trust) throws } } - /** Test factory with access to {@code init()}. */ + /** */ private static class ReloadableSslContextFactory extends SslContextFactory { /** */ private static final long serialVersionUID = 0L; From f6cefca13a18e1a29023c4147dbab0d835199e76 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 18:38:51 +0300 Subject: [PATCH 171/242] IGNITE-29256 Use current timeout API in SSL tests --- .../test/java/org/apache/ignite/ssl/SslContextReloadTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 0629e29b250b5..c9627f1fdf0b5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -274,7 +274,6 @@ public void testThinClientReportsContextFactoryFailure() throws Exception { ClientConfiguration cfg = new ClientConfiguration() .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setHandshakeTimeout(SOCKET_TIMEOUT) .setSslMode(SslMode.REQUIRED) .setSslContextFactory(() -> { throw failure; @@ -316,7 +315,7 @@ private static int[] tlsPorts(IgniteEx node) { private static ClientConfiguration clientConfiguration(IgniteEx node) { return new ClientConfiguration() .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setTimeout(SOCKET_TIMEOUT) + .setRequestTimeout(SOCKET_TIMEOUT) .setSslMode(SslMode.REQUIRED) .setRetryPolicy(new ClientRetryNonePolicy()) .setPartitionAwarenessEnabled(false) From 97559207cec13a863fcd5a46275834b3897608f1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 18:51:33 +0300 Subject: [PATCH 172/242] IGNITE-29256 Drop unnecessary SSL test settings --- .../apache/ignite/internal/util/nio/GridNioSslSelfTest.java | 1 - .../java/org/apache/ignite/ssl/SslContextReloadTest.java | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 580a74b44599f..dd8d23eb76f7a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -89,7 +89,6 @@ public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() th }) .logger(log) .selectorCount(1) - .byteOrder(ByteOrder.nativeOrder()) .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, mreg)) .build(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index c9627f1fdf0b5..88d8c5c66eaa3 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -185,11 +185,8 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { cfg.setClientConnectorConfiguration(null) .setConnectorConfiguration(new ConnectorConfiguration() - .setHost("127.0.0.1") .setPort(occupied.getLocalPort()) - .setPortRange(10) .setSslEnabled(true) - .setSslClientAuth(true) .setSslFactory(() -> { factoryCalls.incrementAndGet(); @@ -295,8 +292,7 @@ private IgniteEx startNode(int idx, Factory factory) throws Exceptio cfg.setSslContextFactory(factory) .setClientConnectorConfiguration(new ClientConnectorConfiguration() .setSslEnabled(true) - .setSslClientAuth(true) - .setUseIgniteSslContextFactory(true)) + .setSslClientAuth(true)) .setConnectorConfiguration(new ConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true)); }); } From ea65b2d00ef3d0dc5f929fd623190cfb25e12394 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 19:07:39 +0300 Subject: [PATCH 173/242] IGNITE-29256 Separate NIO recovery and rejection tests --- .../internal/util/nio/GridNioSslSelfTest.java | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index dd8d23eb76f7a..bb20d994ff7f4 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -29,14 +29,17 @@ import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; +import org.apache.ignite.internal.util.nio.impl.MockNioSession; import org.apache.ignite.internal.util.nio.ssl.GridNioSslFilter; import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.spi.metric.IntMetric; import org.apache.ignite.testframework.GridTestUtils; import org.jetbrains.annotations.Nullable; import org.junit.Test; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + /** * Tests for new NIO server with SSL enabled. */ @@ -53,11 +56,27 @@ public class GridNioSslSelfTest extends GridNioSelfTest { /** */ @Test - public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() throws Exception { + public void testContextLookupFailureCountsOneRejection() throws Exception { + Runnable rejectedSessions = mock(Runnable.class); + GridNioSslFilter filter = new GridNioSslFilter(() -> { + throw new IgniteException("Test SSL context lookup failure"); + }, false, ByteOrder.nativeOrder(), log, null, rejectedSessions); + GridNioSession ses = new MockNioSession(); + + filter.previousFilter(mock(GridNioFilter.class)); + + assertThrows(IgniteCheckedException.class, () -> filter.onSessionOpened(ses)); + + filter.onSessionClosed(ses); + + verify(rejectedSessions).run(); + } + + /** */ + @Test + public void testContextLookupFailureKeepsWorkerRunning() throws Exception { AtomicBoolean failNextLookup = new AtomicBoolean(); - CountDownLatch failedSesClosed = new CountDownLatch(1); CountDownLatch serverConnected = new CountDownLatch(1); - MetricRegistryImpl mreg = new MetricRegistryImpl("nio-ssl", name -> null, name -> null, name -> null, log); Factory sslCtxFactory = () -> { if (failNextLookup.getAndSet(false)) @@ -77,9 +96,7 @@ public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() th /** {@inheritDoc} */ @Override public void onDisconnected(GridNioSession ses, @Nullable Exception e) { - // The close notification follows SSL filter cleanup; the error notification precedes it. - if (e == null) - failedSesClosed.countDown(); + // No-op. } /** {@inheritDoc} */ @@ -89,11 +106,9 @@ public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() th }) .logger(log) .selectorCount(1) - .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, mreg)) + .filters(U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, null)) .build(); - IntMetric rejectedSessions = mreg.findMetric(GridNioSslFilter.SSL_REJECTED_SESSIONS_CNT_METRIC_NAME); - srv.start(); try { @@ -109,9 +124,6 @@ public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() th assertFalse(failNextLookup.get()); } - assertTrue(failedSesClosed.await(5, TimeUnit.SECONDS)); - assertEquals(1, rejectedSessions.value()); - try (SSLSocket sock = (SSLSocket)createSocket()) { sock.connect(addr, 5_000); sock.setSoTimeout(5_000); @@ -123,8 +135,6 @@ public void testContextLookupFailureCountsOneRejectionAndKeepsWorkerRunning() th finally { srv.stop(); } - - assertEquals(1, rejectedSessions.value()); } /** {@inheritDoc} */ From 0cc3ef12902f37bff856f7da39960f2a7272280e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 19:22:04 +0300 Subject: [PATCH 174/242] IGNITE-29256 Clarify SSL factory recovery test name --- .../org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index bb20d994ff7f4..07b93400a2352 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -74,7 +74,7 @@ public void testContextLookupFailureCountsOneRejection() throws Exception { /** */ @Test - public void testContextLookupFailureKeepsWorkerRunning() throws Exception { + public void testServerAcceptsConnectionAfterSslFactoryFailure() throws Exception { AtomicBoolean failNextLookup = new AtomicBoolean(); CountDownLatch serverConnected = new CountDownLatch(1); From d8e6fcb663ad5afb5a3d64061ba4a5124f29edc9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 19:39:55 +0300 Subject: [PATCH 175/242] IGNITE-29256 Test SSL factory failure during filter creation --- .../ignite/internal/util/nio/GridNioSslSelfTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java index 07b93400a2352..73f28c97b2f2d 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/nio/GridNioSslSelfTest.java @@ -54,6 +54,18 @@ public class GridNioSslSelfTest extends GridNioSelfTest { sslCtx = GridTestUtils.sslContext(); } + /** */ + @Test + public void testFilterCreationReportsSslFactoryFailure() { + IgniteException err = new IgniteException("Test SSL context creation failure"); + Factory sslCtxFactory = () -> { + throw err; + }; + + assertSame(err, assertThrows(IgniteException.class, + () -> U.sslFilter(sslCtxFactory, false, ByteOrder.nativeOrder(), log, null))); + } + /** */ @Test public void testContextLookupFailureCountsOneRejection() throws Exception { From d78f07c8810db5d1d18500179c07cf477a02ce71 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 19:57:59 +0300 Subject: [PATCH 176/242] IGNITE-29256 Make discovery test connection actions explicit --- .../tcp/TcpDiscoverySslConnectionFailureTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index 5ed9d22c01b8f..a71d0dc29867b 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -70,11 +70,11 @@ public void testContextLookupFailureKeepsAccepting() throws Exception { throw new IgniteException("Test failure looking up the SSL context"); }); - assertConnectionClosed(addr); + connectAndAssertClosed(addr); assertSame(validFactory, nextFactory.get()); - assertAcceptsTls(addr, validFactory.create()); + connectAndHandshake(addr, validFactory.create()); } /** */ @@ -88,11 +88,11 @@ public void testSocketSetupFailureKeepsAccepting() throws Exception { nextCtx.set(new SSLContextWrapper(validCtx, invalidParams)); - assertConnectionClosed(addr); + connectAndAssertClosed(addr); assertSame(validCtx, nextCtx.get()); - assertAcceptsTls(addr, validCtx); + connectAndHandshake(addr, validCtx); } /** */ @@ -134,7 +134,7 @@ private static InetSocketAddress discoveryAddress(IgniteEx node) { } /** */ - private static void assertConnectionClosed(InetSocketAddress addr) throws Exception { + private static void connectAndAssertClosed(InetSocketAddress addr) throws Exception { try (Socket sock = new Socket()) { sock.connect(addr, SOCK_TIMEOUT); sock.setSoTimeout(SOCK_TIMEOUT); @@ -144,7 +144,7 @@ private static void assertConnectionClosed(InetSocketAddress addr) throws Except } /** */ - private static void assertAcceptsTls(InetSocketAddress addr, SSLContext ctx) throws Exception { + private static void connectAndHandshake(InetSocketAddress addr, SSLContext ctx) throws Exception { try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { sock.connect(addr, SOCK_TIMEOUT); sock.setSoTimeout(SOCK_TIMEOUT); From 36cffbdee65e2d0fd4bd9620377686eb9e392a40 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 20:03:15 +0300 Subject: [PATCH 177/242] IGNITE-29256 Keep one SSL factory in discovery recovery test --- .../TcpDiscoverySslConnectionFailureTest.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index a71d0dc29867b..83aed899e2495 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -61,20 +62,24 @@ public void testInvalidProtocolPreventsFirstNodeStartup() throws Exception { /** */ @Test - public void testContextLookupFailureKeepsAccepting() throws Exception { - Factory validFactory = sslTrustedFactory("node01", "trustone"); - AtomicReference> nextFactory = new AtomicReference<>(validFactory); - InetSocketAddress addr = discoveryAddress(startNode(() -> nextFactory.getAndSet(validFactory).create())); + public void testDiscoveryAcceptsConnectionAfterSslFactoryFailure() throws Exception { + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + AtomicBoolean failNextCreate = new AtomicBoolean(); + Factory sslCtxFactory = () -> { + if (failNextCreate.getAndSet(false)) + throw new IgniteException("Test SSL context creation failure"); - nextFactory.set(() -> { - throw new IgniteException("Test failure looking up the SSL context"); - }); + return ctx; + }; + InetSocketAddress addr = discoveryAddress(startNode(sslCtxFactory)); + + failNextCreate.set(true); connectAndAssertClosed(addr); - assertSame(validFactory, nextFactory.get()); + assertFalse(failNextCreate.get()); - connectAndHandshake(addr, validFactory.create()); + connectAndHandshake(addr, ctx); } /** */ From 6258a8d4c799be28f80f0f59d8c2b20dae6d29b6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 20:09:39 +0300 Subject: [PATCH 178/242] IGNITE-29256 Simplify SSL test setup and recovery scope --- .../TcpDiscoverySslConnectionFailureTest.java | 6 ----- .../ignite/ssl/SslContextReloadTest.java | 22 +++++++++---------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index 83aed899e2495..7af7b3ceb1757 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -115,12 +115,6 @@ public void testOutgoingSocketSetupFailureIsReportedAsIoException() throws Excep IOException err = assertThrows(IOException.class, () -> spi.createSocket().close()); assertTrue(err.getCause() instanceof IllegalArgumentException); - - curCtx.set(ctx); - - try (Socket sock = spi.createSocket()) { - assertTrue(sock.isBound()); - } } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 88d8c5c66eaa3..53efe3fdef33c 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -79,7 +79,7 @@ public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { int[] ports = tlsPorts(node); for (int port : ports) - assertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory("node01", "trustone")); @@ -89,8 +89,8 @@ public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { factory.reload("node02", "trusttwo"); for (int port : ports) { - assertServerCertificate(port, "node03", "trusttwo", "CN=node02"); - assertOldClientCertificateRejected(port); + connectAndAssertServerCertificate(port, "node03", "trusttwo", "CN=node02"); + connectAndAssertOldClientCertificateRejected(port); } clientWithOldTrust.cacheNames(); @@ -154,7 +154,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); for (int port : ports) - assertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); client.cacheNames(); } @@ -165,7 +165,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception reload.get(30, TimeUnit.SECONDS); for (int port : ports) - assertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); client.cacheNames(); } @@ -198,16 +198,15 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); - assertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); } } /** */ @Test public void testThinClientReconnectUsesReloadedContext() throws Exception { - ReloadableSslContextFactory serverFactory = new ReloadableSslContextFactory(); ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(); - IgniteEx node = startNode(0, serverFactory); + IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); @@ -218,10 +217,9 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { assertThrows(ClientConnectionException.class, client::cacheNames); - serverFactory.reload("node02", "trusttwo"); clientFactory.reload("node03", "trusttwo"); - startNode(0, serverFactory); + startNode(0, sslTrustedFactory("node02", "trusttwo")); client.cacheNames(); } @@ -319,7 +317,7 @@ private static ClientConfiguration clientConfiguration(IgniteEx node) { } /** */ - private static void assertServerCertificate(int port, String keys, String trust, String expectedSubject) throws IOException { + private static void connectAndAssertServerCertificate(int port, String keys, String trust, String expectedSubject) throws IOException { try (SSLSocket sock = connectTlsSocket(port, keys, trust)) { sock.startHandshake(); @@ -328,7 +326,7 @@ private static void assertServerCertificate(int port, String keys, String trust, } /** The probe trusts the replacement server certificate. */ - private static void assertOldClientCertificateRejected(int port) throws IOException { + private static void connectAndAssertOldClientCertificateRejected(int port) throws IOException { try (SSLSocket sock = connectTlsSocket(port, "node01", "trustboth")) { try { sock.startHandshake(); From 1eff48f703cc3526a67c49dde5f741978b73434d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 20:29:02 +0300 Subject: [PATCH 179/242] IGNITE-29256 Exercise factory init in discovery protocol tests --- .../TcpDiscoverySslConnectionFailureTest.java | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java index 7af7b3ceb1757..c0dad1b0cdd2e 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslConnectionFailureTest.java @@ -20,21 +20,21 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; +import java.net.SocketTimeoutException; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; -import org.apache.ignite.ssl.SSLContextWrapper; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; import static org.apache.ignite.testframework.GridTestUtils.assertThrowsAnyCause; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertThrows; @@ -84,33 +84,30 @@ public void testDiscoveryAcceptsConnectionAfterSslFactoryFailure() throws Except /** */ @Test - public void testSocketSetupFailureKeepsAccepting() throws Exception { - SSLContext validCtx = sslTrustedFactory("node01", "trustone").create(); - AtomicReference nextCtx = new AtomicReference<>(validCtx); - InetSocketAddress addr = discoveryAddress(startNode(() -> nextCtx.getAndSet(validCtx))); - SSLParameters invalidParams = new SSLParameters(); - invalidParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); + public void testDiscoveryAcceptsConnectionAfterInvalidProtocolReload() throws Exception { + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); + InetSocketAddress addr = discoveryAddress(startNode(factory)); + SSLContext clientCtx = sslTrustedFactory("node01", "trustone").create(); - nextCtx.set(new SSLContextWrapper(validCtx, invalidParams)); + factory.reloadProtocols("TestUnsupportedProtocol"); - connectAndAssertClosed(addr); + IOException err = assertThrows(IOException.class, () -> connectAndHandshake(addr, clientCtx)); + + assertFalse(err instanceof SocketTimeoutException); - assertSame(validCtx, nextCtx.get()); + factory.reloadProtocols("TLSv1.2"); - connectAndHandshake(addr, validCtx); + connectAndHandshake(addr, clientCtx); } /** */ @Test public void testOutgoingSocketSetupFailureIsReportedAsIoException() throws Exception { - SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); - AtomicReference curCtx = new AtomicReference<>(ctx); - IgniteEx node = startNode(curCtx::get); + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); + IgniteEx node = startNode(factory); TcpDiscoverySpi spi = (TcpDiscoverySpi)node.configuration().getDiscoverySpi(); - SSLParameters badParams = new SSLParameters(); - badParams.setProtocols(new String[]{"TestUnsupportedProtocol"}); - curCtx.set(new SSLContextWrapper(ctx, badParams)); + factory.reloadProtocols("TestUnsupportedProtocol"); IOException err = assertThrows(IOException.class, () -> spi.createSocket().close()); @@ -150,4 +147,25 @@ private static void connectAndHandshake(InetSocketAddress addr, SSLContext ctx) sock.startHandshake(); } } + + /** */ + private static class ReloadableSslContextFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0L; + + /** */ + private ReloadableSslContextFactory() { + setKeyStoreFilePath(keyStorePath("node01")); + setKeyStorePassword(keyStorePassword().toCharArray()); + setTrustStoreFilePath(keyStorePath("trustone")); + setTrustStorePassword(keyStorePassword().toCharArray()); + } + + /** */ + private void reloadProtocols(String... protocols) { + setProtocols(protocols); + + init(); + } + } } From fbbf2bb6e66823e40bedffa81037703f1eafb4bd Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 20:43:09 +0300 Subject: [PATCH 180/242] IGNITE-29256 Name shared TLS test data --- .../ignite/ssl/SslContextReloadTest.java | 84 ++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 53efe3fdef33c..25e476c94ac03 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -63,6 +63,33 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** */ private static final int SOCKET_TIMEOUT = 10_000; + /** */ + private static final String OLD_KEYS = "node01"; + + /** */ + private static final String NEW_SERVER_KEYS = "node02"; + + /** */ + private static final String NEW_PEER_KEYS = "node03"; + + /** */ + private static final String OLD_TRUST = "trustone"; + + /** */ + private static final String NEW_TRUST = "trusttwo"; + + /** */ + private static final String TRUST_BOTH_CAS = "trustboth"; + + /** */ + private static final String OLD_SERVER_SUBJECT = "CN=node01"; + + /** */ + private static final String NEW_SERVER_SUBJECT = "CN=node02"; + + /** */ + private static final String LOOPBACK_ADDRESS = "127.0.0.1"; + /** Discovery addresses shared within one test. */ private final TcpDiscoveryVmIpFinder ipFinder = new TcpDiscoveryVmIpFinder(true); @@ -79,24 +106,24 @@ public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { int[] ports = tlsPorts(node); for (int port : ports) - connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory("node01", "trustone")); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(OLD_KEYS, OLD_TRUST)); try (IgniteClient clientWithOldTrust = Ignition.startClient(clientCfg)) { clientWithOldTrust.cacheNames(); - factory.reload("node02", "trusttwo"); + factory.reload(NEW_SERVER_KEYS, NEW_TRUST); for (int port : ports) { - connectAndAssertServerCertificate(port, "node03", "trusttwo", "CN=node02"); + connectAndAssertServerCertificate(port, NEW_PEER_KEYS, NEW_TRUST, NEW_SERVER_SUBJECT); connectAndAssertOldClientCertificateRejected(port); } clientWithOldTrust.cacheNames(); } - IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory("node03", "trusttwo")); + IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory(NEW_PEER_KEYS, NEW_TRUST)); assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peerWithNewTrust.localNode().id())); @@ -108,7 +135,7 @@ public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { /** */ @Test public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { - String replacementKeyStore = keyStorePath("node02"); + String replacementKeyStore = keyStorePath(NEW_SERVER_KEYS); CountDownLatch reloadStarted = new CountDownLatch(1); CountDownLatch allowReload = new CountDownLatch(1); @@ -139,7 +166,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception IgniteEx node = startNode(0, factory); int[] ports = tlsPorts(node); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory("node01", "trustone")); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(OLD_KEYS, OLD_TRUST)); try (IgniteClient client = Ignition.startClient(clientCfg)) { client.cacheNames(); @@ -147,14 +174,14 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception factory.setKeyStorePassword("wrong-password".toCharArray()); IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { - assertThrows(IgniteException.class, () -> factory.reload("node02", "trusttwo")); + assertThrows(IgniteException.class, () -> factory.reload(NEW_SERVER_KEYS, NEW_TRUST)); }); try { assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); for (int port : ports) - connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); client.cacheNames(); } @@ -165,7 +192,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception reload.get(30, TimeUnit.SECONDS); for (int port : ports) - connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); client.cacheNames(); } @@ -174,11 +201,11 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception /** */ @Test public void testRestInitializesContextOnceOnPortRetry() throws Exception { - SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + SSLContext ctx = sslTrustedFactory(OLD_KEYS, OLD_TRUST).create(); AtomicInteger factoryCalls = new AtomicInteger(); try (ServerSocket occupied = new ServerSocket()) { - occupied.bind(new InetSocketAddress("127.0.0.1", GridTestUtils.getFreeCommPort())); + occupied.bind(new InetSocketAddress(LOOPBACK_ADDRESS, GridTestUtils.getFreeCommPort())); IgniteEx node = startGrid(0, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); @@ -198,7 +225,7 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); - connectAndAssertServerCertificate(port, "node01", "trustone", "CN=node01"); + connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); } } @@ -206,7 +233,7 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { @Test public void testThinClientReconnectUsesReloadedContext() throws Exception { ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(); - IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); + IgniteEx node = startNode(0, sslTrustedFactory(OLD_KEYS, OLD_TRUST)); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); @@ -217,9 +244,9 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { assertThrows(ClientConnectionException.class, client::cacheNames); - clientFactory.reload("node03", "trusttwo"); + clientFactory.reload(NEW_PEER_KEYS, NEW_TRUST); - startNode(0, sslTrustedFactory("node02", "trusttwo")); + startNode(0, sslTrustedFactory(NEW_SERVER_KEYS, NEW_TRUST)); client.cacheNames(); } @@ -228,19 +255,20 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { /** */ @Test public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { - Factory serverFactory = sslTrustedFactory("node01", "trustone"); + Factory serverFactory = sslTrustedFactory(OLD_KEYS, OLD_TRUST); IgniteEx node = startNode(0, serverFactory); Path keyStore = Files.createTempFile("thin-client-keys", ".jks"); + final String storeType = "JKS"; try { - Files.copy(Path.of(keyStorePath("node01")), keyStore, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Path.of(keyStorePath(OLD_KEYS)), keyStore, StandardCopyOption.REPLACE_EXISTING); ClientConfiguration cfg = clientConfiguration(node) .setSslClientCertificateKeyStorePath(keyStore.toString()) - .setSslClientCertificateKeyStoreType("JKS") + .setSslClientCertificateKeyStoreType(storeType) .setSslClientCertificateKeyStorePassword(keyStorePassword()) - .setSslTrustCertificateKeyStorePath(keyStorePath("trustone")) - .setSslTrustCertificateKeyStoreType("JKS") + .setSslTrustCertificateKeyStorePath(keyStorePath(OLD_TRUST)) + .setSslTrustCertificateKeyStoreType(storeType) .setSslTrustCertificateKeyStorePassword(keyStorePassword()); try (IgniteClient client = Ignition.startClient(cfg)) { @@ -264,11 +292,11 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { /** */ @Test public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(0, sslTrustedFactory("node01", "trustone")); + IgniteEx node = startNode(0, sslTrustedFactory(OLD_KEYS, OLD_TRUST)); IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); ClientConfiguration cfg = new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setAddresses(LOOPBACK_ADDRESS + ":" + node.context().clientListener().port()) .setSslMode(SslMode.REQUIRED) .setSslContextFactory(() -> { throw failure; @@ -308,7 +336,7 @@ private static int[] tlsPorts(IgniteEx node) { /** */ private static ClientConfiguration clientConfiguration(IgniteEx node) { return new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setAddresses(LOOPBACK_ADDRESS + ":" + node.context().clientListener().port()) .setRequestTimeout(SOCKET_TIMEOUT) .setSslMode(SslMode.REQUIRED) .setRetryPolicy(new ClientRetryNonePolicy()) @@ -327,7 +355,7 @@ private static void connectAndAssertServerCertificate(int port, String keys, Str /** The probe trusts the replacement server certificate. */ private static void connectAndAssertOldClientCertificateRejected(int port) throws IOException { - try (SSLSocket sock = connectTlsSocket(port, "node01", "trustboth")) { + try (SSLSocket sock = connectTlsSocket(port, OLD_KEYS, TRUST_BOTH_CAS)) { try { sock.startHandshake(); @@ -347,7 +375,7 @@ private static SSLSocket connectTlsSocket(int port, String keys, String trust) t try { // TLS 1.2 completes the client handshake only after the server checks the client certificate. sock.setEnabledProtocols(new String[] {"TLSv1.2"}); - sock.connect(new InetSocketAddress("127.0.0.1", port), SOCKET_TIMEOUT); + sock.connect(new InetSocketAddress(LOOPBACK_ADDRESS, port), SOCKET_TIMEOUT); sock.setSoTimeout(SOCKET_TIMEOUT); return sock; @@ -366,9 +394,9 @@ private static class ReloadableSslContextFactory extends SslContextFactory { /** */ private ReloadableSslContextFactory() { - setKeyStoreFilePath(keyStorePath("node01")); + setKeyStoreFilePath(keyStorePath(OLD_KEYS)); setKeyStorePassword(keyStorePassword().toCharArray()); - setTrustStoreFilePath(keyStorePath("trustone")); + setTrustStoreFilePath(keyStorePath(OLD_TRUST)); setTrustStorePassword(keyStorePassword().toCharArray()); } From 7cb02e20ef74c5b56330a078e0892cc85e1955cb Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 20:56:20 +0300 Subject: [PATCH 181/242] IGNITE-29256 Make TLS test inputs and expectations explicit --- .../ignite/ssl/SslContextReloadTest.java | 112 +++++++----------- 1 file changed, 45 insertions(+), 67 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 25e476c94ac03..5a80d52fc1d4b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -25,6 +25,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.KeyStore; +import java.security.cert.Certificate; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -44,7 +46,6 @@ import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.util.typedef.X; -import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; @@ -64,28 +65,22 @@ public class SslContextReloadTest extends GridCommonAbstractTest { private static final int SOCKET_TIMEOUT = 10_000; /** */ - private static final String OLD_KEYS = "node01"; + private static final String KEYS_1 = "node01"; /** */ - private static final String NEW_SERVER_KEYS = "node02"; + private static final String KEYS_2 = "node02"; /** */ - private static final String NEW_PEER_KEYS = "node03"; + private static final String KEYS_3 = "node03"; /** */ - private static final String OLD_TRUST = "trustone"; + private static final String TRUST_1 = "trustone"; /** */ - private static final String NEW_TRUST = "trusttwo"; + private static final String TRUST_2 = "trusttwo"; /** */ - private static final String TRUST_BOTH_CAS = "trustboth"; - - /** */ - private static final String OLD_SERVER_SUBJECT = "CN=node01"; - - /** */ - private static final String NEW_SERVER_SUBJECT = "CN=node02"; + private static final String TRUST_1_AND_2 = "trustboth"; /** */ private static final String LOOPBACK_ADDRESS = "127.0.0.1"; @@ -101,29 +96,29 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** */ @Test public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { - ReloadableSslContextFactory factory = new ReloadableSslContextFactory(); + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(KEYS_1, TRUST_1); IgniteEx node = startNode(0, factory::create); int[] ports = tlsPorts(node); for (int port : ports) - connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(OLD_KEYS, OLD_TRUST)); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(KEYS_1, TRUST_1)); try (IgniteClient clientWithOldTrust = Ignition.startClient(clientCfg)) { clientWithOldTrust.cacheNames(); - factory.reload(NEW_SERVER_KEYS, NEW_TRUST); + factory.reload(KEYS_2, TRUST_2); for (int port : ports) { - connectAndAssertServerCertificate(port, NEW_PEER_KEYS, NEW_TRUST, NEW_SERVER_SUBJECT); - connectAndAssertOldClientCertificateRejected(port); + assertEquals(certificate(KEYS_2), connectAndGetServerCertificate(port, KEYS_3, TRUST_2)); + assertThrows(SSLException.class, () -> connectAndGetServerCertificate(port, KEYS_1, TRUST_1_AND_2)); } clientWithOldTrust.cacheNames(); } - IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory(NEW_PEER_KEYS, NEW_TRUST)); + IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory(KEYS_3, TRUST_2)); assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peerWithNewTrust.localNode().id())); @@ -135,11 +130,11 @@ public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { /** */ @Test public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { - String replacementKeyStore = keyStorePath(NEW_SERVER_KEYS); + String replacementKeyStore = keyStorePath(KEYS_2); CountDownLatch reloadStarted = new CountDownLatch(1); CountDownLatch allowReload = new CountDownLatch(1); - ReloadableSslContextFactory factory = new ReloadableSslContextFactory() { + ReloadableSslContextFactory factory = new ReloadableSslContextFactory(KEYS_1, TRUST_1) { /** */ private static final long serialVersionUID = 0L; @@ -166,7 +161,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception IgniteEx node = startNode(0, factory); int[] ports = tlsPorts(node); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(OLD_KEYS, OLD_TRUST)); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(KEYS_1, TRUST_1)); try (IgniteClient client = Ignition.startClient(clientCfg)) { client.cacheNames(); @@ -174,14 +169,14 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception factory.setKeyStorePassword("wrong-password".toCharArray()); IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { - assertThrows(IgniteException.class, () -> factory.reload(NEW_SERVER_KEYS, NEW_TRUST)); + assertThrows(IgniteException.class, () -> factory.reload(KEYS_2, TRUST_2)); }); try { assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); for (int port : ports) - connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); client.cacheNames(); } @@ -192,7 +187,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception reload.get(30, TimeUnit.SECONDS); for (int port : ports) - connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); client.cacheNames(); } @@ -201,7 +196,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception /** */ @Test public void testRestInitializesContextOnceOnPortRetry() throws Exception { - SSLContext ctx = sslTrustedFactory(OLD_KEYS, OLD_TRUST).create(); + SSLContext ctx = sslTrustedFactory(KEYS_1, TRUST_1).create(); AtomicInteger factoryCalls = new AtomicInteger(); try (ServerSocket occupied = new ServerSocket()) { @@ -225,15 +220,15 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); - connectAndAssertServerCertificate(port, OLD_KEYS, OLD_TRUST, OLD_SERVER_SUBJECT); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); } } /** */ @Test public void testThinClientReconnectUsesReloadedContext() throws Exception { - ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(); - IgniteEx node = startNode(0, sslTrustedFactory(OLD_KEYS, OLD_TRUST)); + ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(KEYS_1, TRUST_1); + IgniteEx node = startNode(0, sslTrustedFactory(KEYS_1, TRUST_1)); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); @@ -244,9 +239,9 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { assertThrows(ClientConnectionException.class, client::cacheNames); - clientFactory.reload(NEW_PEER_KEYS, NEW_TRUST); + clientFactory.reload(KEYS_3, TRUST_2); - startNode(0, sslTrustedFactory(NEW_SERVER_KEYS, NEW_TRUST)); + startNode(0, sslTrustedFactory(KEYS_2, TRUST_2)); client.cacheNames(); } @@ -255,19 +250,19 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { /** */ @Test public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { - Factory serverFactory = sslTrustedFactory(OLD_KEYS, OLD_TRUST); + Factory serverFactory = sslTrustedFactory(KEYS_1, TRUST_1); IgniteEx node = startNode(0, serverFactory); Path keyStore = Files.createTempFile("thin-client-keys", ".jks"); final String storeType = "JKS"; try { - Files.copy(Path.of(keyStorePath(OLD_KEYS)), keyStore, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Path.of(keyStorePath(KEYS_1)), keyStore, StandardCopyOption.REPLACE_EXISTING); ClientConfiguration cfg = clientConfiguration(node) .setSslClientCertificateKeyStorePath(keyStore.toString()) .setSslClientCertificateKeyStoreType(storeType) .setSslClientCertificateKeyStorePassword(keyStorePassword()) - .setSslTrustCertificateKeyStorePath(keyStorePath(OLD_TRUST)) + .setSslTrustCertificateKeyStorePath(keyStorePath(TRUST_1)) .setSslTrustCertificateKeyStoreType(storeType) .setSslTrustCertificateKeyStorePassword(keyStorePassword()); @@ -292,7 +287,7 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { /** */ @Test public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(0, sslTrustedFactory(OLD_KEYS, OLD_TRUST)); + IgniteEx node = startNode(0, sslTrustedFactory(KEYS_1, TRUST_1)); IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); ClientConfiguration cfg = new ClientConfiguration() @@ -345,45 +340,28 @@ private static ClientConfiguration clientConfiguration(IgniteEx node) { } /** */ - private static void connectAndAssertServerCertificate(int port, String keys, String trust, String expectedSubject) throws IOException { - try (SSLSocket sock = connectTlsSocket(port, keys, trust)) { - sock.startHandshake(); - - assertEquals("Peer certificate on port " + port, expectedSubject, sock.getSession().getPeerPrincipal().getName()); - } - } - - /** The probe trusts the replacement server certificate. */ - private static void connectAndAssertOldClientCertificateRejected(int port) throws IOException { - try (SSLSocket sock = connectTlsSocket(port, OLD_KEYS, TRUST_BOTH_CAS)) { - try { - sock.startHandshake(); - - fail("Port " + port + " accepted a client from the old CA"); - } - catch (SSLException | SocketException expected) { - // A TLS alert or connection reset can report a rejected client certificate. - } - } + private static Certificate certificate(String keys) throws Exception { + return KeyStore.getInstance(Path.of(keyStorePath(keys)).toFile(), keyStorePassword().toCharArray()).getCertificate(keys); } /** A fresh TLS context prevents session resumption. */ - private static SSLSocket connectTlsSocket(int port, String keys, String trust) throws IOException { + private static Certificate connectAndGetServerCertificate(int port, String keys, String trust) throws IOException { SSLContext ctx = sslTrustedFactory(keys, trust).create(); - SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket(); - try { + try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { // TLS 1.2 completes the client handshake only after the server checks the client certificate. sock.setEnabledProtocols(new String[] {"TLSv1.2"}); sock.connect(new InetSocketAddress(LOOPBACK_ADDRESS, port), SOCKET_TIMEOUT); sock.setSoTimeout(SOCKET_TIMEOUT); - return sock; - } - catch (IOException | RuntimeException e) { - U.closeQuiet(sock); + try { + sock.startHandshake(); + } + catch (SocketException e) { + throw new SSLException(e); + } - throw e; + return sock.getSession().getPeerCertificates()[0]; } } @@ -393,10 +371,10 @@ private static class ReloadableSslContextFactory extends SslContextFactory { private static final long serialVersionUID = 0L; /** */ - private ReloadableSslContextFactory() { - setKeyStoreFilePath(keyStorePath(OLD_KEYS)); + private ReloadableSslContextFactory(String keys, String trust) { + setKeyStoreFilePath(keyStorePath(keys)); setKeyStorePassword(keyStorePassword().toCharArray()); - setTrustStoreFilePath(keyStorePath(OLD_TRUST)); + setTrustStoreFilePath(keyStorePath(trust)); setTrustStorePassword(keyStorePassword().toCharArray()); } From d3135f8d76402fac34d274339ed90d8b02101654 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 21:24:59 +0300 Subject: [PATCH 182/242] IGNITE-29256 Separate TLS reload test scenarios --- .../ignite/ssl/SslContextReloadTest.java | 177 +++++++++++------- 1 file changed, 112 insertions(+), 65 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 5a80d52fc1d4b..23913a2703fb3 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -34,6 +34,8 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; import org.apache.ignite.IgniteException; import org.apache.ignite.Ignition; import org.apache.ignite.client.ClientConnectionException; @@ -56,7 +58,6 @@ import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; -import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.junit.Assert.assertThrows; /** Tests TLS reload across TCP transports. */ @@ -70,18 +71,6 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** */ private static final String KEYS_2 = "node02"; - /** */ - private static final String KEYS_3 = "node03"; - - /** */ - private static final String TRUST_1 = "trustone"; - - /** */ - private static final String TRUST_2 = "trusttwo"; - - /** */ - private static final String TRUST_1_AND_2 = "trustboth"; - /** */ private static final String LOOPBACK_ADDRESS = "127.0.0.1"; @@ -95,34 +84,73 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** */ @Test - public void testNewConnectionsUseReloadedKeysAndTrust() throws Exception { - ReloadableSslContextFactory factory = new ReloadableSslContextFactory(KEYS_1, TRUST_1); - IgniteEx node = startNode(0, factory::create); - int[] ports = tlsPorts(node); + public void testNewConnectionsUseReloadedCertificate() throws Exception { + TrustManager[] trustedCertificates = trustIssuersOf(KEYS_1, KEYS_2); + SslContextFactory serverFactory = sslFactory(KEYS_1, trustedCertificates); + IgniteEx node = startNode(0, serverFactory::create); + + serverFactory.setKeyStoreFilePath(keyStorePath(KEYS_2)); + serverFactory.init(); + + SSLContext client = sslFactory(KEYS_1, trustedCertificates).create(); + + for (int port : tlsPorts(node)) + assertEquals(certificate(KEYS_2), connectAndGetServerCertificate(port, client)); + } + + /** */ + @Test + public void testNewConnectionsUseReloadedTrust() throws Exception { + TrustManager[] trustedServer = trustIssuersOf(KEYS_1); + SslContextFactory serverFactory = sslFactory(KEYS_1, trustedServer); + IgniteEx node = startNode(0, serverFactory); + + serverFactory.setTrustManagers(trustIssuersOf(KEYS_2)); + serverFactory.init(); - for (int port : ports) - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); + SSLContext client1 = sslFactory(KEYS_1, trustedServer).create(); + SSLContext client2 = sslFactory(KEYS_2, trustedServer).create(); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(KEYS_1, TRUST_1)); + for (int port : tlsPorts(node)) { + connectAndGetServerCertificate(port, client2); + assertThrows(SSLException.class, () -> connectAndGetServerCertificate(port, client1)); + } + } - try (IgniteClient clientWithOldTrust = Ignition.startClient(clientCfg)) { - clientWithOldTrust.cacheNames(); + /** */ + @Test + public void testExistingConnectionSurvivesReload() throws Exception { + SslContextFactory serverFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); + IgniteEx node = startNode(0, serverFactory); - factory.reload(KEYS_2, TRUST_2); + ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); - for (int port : ports) { - assertEquals(certificate(KEYS_2), connectAndGetServerCertificate(port, KEYS_3, TRUST_2)); - assertThrows(SSLException.class, () -> connectAndGetServerCertificate(port, KEYS_1, TRUST_1_AND_2)); - } + try (IgniteClient client = Ignition.startClient(cfg)) { + client.cacheNames(); + + serverFactory.setKeyStoreFilePath(keyStorePath(KEYS_2)); + serverFactory.setTrustManagers(trustIssuersOf(KEYS_2)); + serverFactory.init(); - clientWithOldTrust.cacheNames(); + client.cacheNames(); } + } + + /** */ + @Test + public void testOutgoingConnectionsUseReloadedContext() throws Exception { + SslContextFactory factory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); + IgniteEx node = startNode(0, factory); - IgniteEx peerWithNewTrust = startNode(1, sslTrustedFactory(KEYS_3, TRUST_2)); + factory.setKeyStoreFilePath(keyStorePath(KEYS_2)); + factory.setTrustManagers(trustIssuersOf(KEYS_2)); + factory.init(); - assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peerWithNewTrust.localNode().id())); + IgniteEx peer = startNode(1, sslFactory(KEYS_2, trustIssuersOf(KEYS_2))); - int result = node.compute(node.cluster().forNodeId(peerWithNewTrust.localNode().id())).call(() -> 1); + assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peer.localNode().id())); + + int result = node.compute(node.cluster().forNodeId(peer.localNode().id())).call(() -> 1); assertEquals(1, result); } @@ -134,7 +162,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception CountDownLatch reloadStarted = new CountDownLatch(1); CountDownLatch allowReload = new CountDownLatch(1); - ReloadableSslContextFactory factory = new ReloadableSslContextFactory(KEYS_1, TRUST_1) { + SslContextFactory factory = new SslContextFactory() { /** */ private static final long serialVersionUID = 0L; @@ -158,25 +186,33 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception } }; + TrustManager[] trustedCertificates = trustIssuersOf(KEYS_1); + + factory.setKeyStoreFilePath(keyStorePath(KEYS_1)); + factory.setKeyStorePassword(keyStorePassword().toCharArray()); + factory.setTrustManagers(trustedCertificates); + IgniteEx node = startNode(0, factory); int[] ports = tlsPorts(node); - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslTrustedFactory(KEYS_1, TRUST_1)); + ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslFactory(KEYS_1, trustedCertificates)); try (IgniteClient client = Ignition.startClient(clientCfg)) { client.cacheNames(); + factory.setKeyStoreFilePath(replacementKeyStore); factory.setKeyStorePassword("wrong-password".toCharArray()); IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { - assertThrows(IgniteException.class, () -> factory.reload(KEYS_2, TRUST_2)); + assertThrows(IgniteException.class, factory::init); }); try { assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); for (int port : ports) - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); + assertEquals(certificate(KEYS_1), + connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); client.cacheNames(); } @@ -187,7 +223,7 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception reload.get(30, TimeUnit.SECONDS); for (int port : ports) - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); client.cacheNames(); } @@ -196,7 +232,8 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception /** */ @Test public void testRestInitializesContextOnceOnPortRetry() throws Exception { - SSLContext ctx = sslTrustedFactory(KEYS_1, TRUST_1).create(); + TrustManager[] trustedCertificates = trustIssuersOf(KEYS_1); + SSLContext ctx = sslFactory(KEYS_1, trustedCertificates).create(); AtomicInteger factoryCalls = new AtomicInteger(); try (ServerSocket occupied = new ServerSocket()) { @@ -220,15 +257,15 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, KEYS_1, TRUST_1)); + assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); } } /** */ @Test public void testThinClientReconnectUsesReloadedContext() throws Exception { - ReloadableSslContextFactory clientFactory = new ReloadableSslContextFactory(KEYS_1, TRUST_1); - IgniteEx node = startNode(0, sslTrustedFactory(KEYS_1, TRUST_1)); + SslContextFactory clientFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); + IgniteEx node = startNode(0, sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); @@ -239,9 +276,11 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { assertThrows(ClientConnectionException.class, client::cacheNames); - clientFactory.reload(KEYS_3, TRUST_2); + clientFactory.setKeyStoreFilePath(keyStorePath(KEYS_2)); + clientFactory.setTrustManagers(trustIssuersOf(KEYS_2)); + clientFactory.init(); - startNode(0, sslTrustedFactory(KEYS_2, TRUST_2)); + startNode(0, sslFactory(KEYS_2, trustIssuersOf(KEYS_2))); client.cacheNames(); } @@ -250,7 +289,7 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { /** */ @Test public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { - Factory serverFactory = sslTrustedFactory(KEYS_1, TRUST_1); + Factory serverFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); IgniteEx node = startNode(0, serverFactory); Path keyStore = Files.createTempFile("thin-client-keys", ".jks"); final String storeType = "JKS"; @@ -262,7 +301,7 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { .setSslClientCertificateKeyStorePath(keyStore.toString()) .setSslClientCertificateKeyStoreType(storeType) .setSslClientCertificateKeyStorePassword(keyStorePassword()) - .setSslTrustCertificateKeyStorePath(keyStorePath(TRUST_1)) + .setSslTrustCertificateKeyStorePath(keyStorePath(KEYS_1)) .setSslTrustCertificateKeyStoreType(storeType) .setSslTrustCertificateKeyStorePassword(keyStorePassword()); @@ -287,7 +326,7 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { /** */ @Test public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(0, sslTrustedFactory(KEYS_1, TRUST_1)); + IgniteEx node = startNode(0, sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); ClientConfiguration cfg = new ClientConfiguration() @@ -344,10 +383,8 @@ private static Certificate certificate(String keys) throws Exception { return KeyStore.getInstance(Path.of(keyStorePath(keys)).toFile(), keyStorePassword().toCharArray()).getCertificate(keys); } - /** A fresh TLS context prevents session resumption. */ - private static Certificate connectAndGetServerCertificate(int port, String keys, String trust) throws IOException { - SSLContext ctx = sslTrustedFactory(keys, trust).create(); - + /** */ + private static Certificate connectAndGetServerCertificate(int port, SSLContext ctx) throws IOException { try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { // TLS 1.2 completes the client handshake only after the server checks the client certificate. sock.setEnabledProtocols(new String[] {"TLSv1.2"}); @@ -366,24 +403,34 @@ private static Certificate connectAndGetServerCertificate(int port, String keys, } /** */ - private static class ReloadableSslContextFactory extends SslContextFactory { - /** */ - private static final long serialVersionUID = 0L; - - /** */ - private ReloadableSslContextFactory(String keys, String trust) { - setKeyStoreFilePath(keyStorePath(keys)); - setKeyStorePassword(keyStorePassword().toCharArray()); - setTrustStoreFilePath(keyStorePath(trust)); - setTrustStorePassword(keyStorePassword().toCharArray()); - } + private static SslContextFactory sslFactory(String keys, TrustManager... trustManagers) { + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreFilePath(keyStorePath(keys)); + factory.setKeyStorePassword(keyStorePassword().toCharArray()); + factory.setTrustManagers(trustManagers); + + return factory; + } - /** */ - private void reload(String keys, String trust) { - setKeyStoreFilePath(keyStorePath(keys)); - setTrustStoreFilePath(keyStorePath(trust)); + /** */ + private static TrustManager[] trustIssuersOf(String... keys) throws Exception { + KeyStore trustStore = KeyStore.getInstance("JKS"); + + trustStore.load(null, null); + + for (String key : keys) { + KeyStore keyStore = KeyStore.getInstance(Path.of(keyStorePath(key)).toFile(), keyStorePassword().toCharArray()); + Certificate[] chain = keyStore.getCertificateChain(key); - init(); + for (int i = 1; i < chain.length; i++) + trustStore.setCertificateEntry(key + i, chain[i]); } + + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + factory.init(trustStore); + + return factory.getTrustManagers(); } } From 22430eafddcc90ebf982fcf7bcf9d54df4300073 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 21:57:23 +0300 Subject: [PATCH 183/242] IGNITE-29256 Focus TLS tests on transport guarantees --- .../ignite/ssl/SslContextReloadTest.java | 163 +++++------------- 1 file changed, 47 insertions(+), 116 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 23913a2703fb3..58d5488108703 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -21,7 +21,6 @@ import java.io.InputStream; import java.net.InetSocketAddress; import java.net.ServerSocket; -import java.net.SocketException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -32,11 +31,9 @@ import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; -import org.apache.ignite.IgniteException; import org.apache.ignite.Ignition; import org.apache.ignite.client.ClientConnectionException; import org.apache.ignite.client.ClientRetryNonePolicy; @@ -47,6 +44,7 @@ import org.apache.ignite.configuration.ConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; @@ -58,6 +56,7 @@ import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertThrows; /** Tests TLS reload across TCP transports. */ @@ -84,44 +83,25 @@ public class SslContextReloadTest extends GridCommonAbstractTest { /** */ @Test - public void testNewConnectionsUseReloadedCertificate() throws Exception { - TrustManager[] trustedCertificates = trustIssuersOf(KEYS_1, KEYS_2); - SslContextFactory serverFactory = sslFactory(KEYS_1, trustedCertificates); - IgniteEx node = startNode(0, serverFactory::create); + public void testNewConnectionsUseReloadedContext() throws Exception { + SslContextFactory serverFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); + IgniteEx node = startNode(serverFactory::create); serverFactory.setKeyStoreFilePath(keyStorePath(KEYS_2)); - serverFactory.init(); - - SSLContext client = sslFactory(KEYS_1, trustedCertificates).create(); - - for (int port : tlsPorts(node)) - assertEquals(certificate(KEYS_2), connectAndGetServerCertificate(port, client)); - } - - /** */ - @Test - public void testNewConnectionsUseReloadedTrust() throws Exception { - TrustManager[] trustedServer = trustIssuersOf(KEYS_1); - SslContextFactory serverFactory = sslFactory(KEYS_1, trustedServer); - IgniteEx node = startNode(0, serverFactory); - serverFactory.setTrustManagers(trustIssuersOf(KEYS_2)); serverFactory.init(); - SSLContext client1 = sslFactory(KEYS_1, trustedServer).create(); - SSLContext client2 = sslFactory(KEYS_2, trustedServer).create(); + SSLContext client = sslFactory(KEYS_2, trustIssuersOf(KEYS_2)).create(); - for (int port : tlsPorts(node)) { - connectAndGetServerCertificate(port, client2); - assertThrows(SSLException.class, () -> connectAndGetServerCertificate(port, client1)); - } + for (int port : tlsPorts(node)) + connectAndHandshake(port, client); } /** */ @Test public void testExistingConnectionSurvivesReload() throws Exception { SslContextFactory serverFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); - IgniteEx node = startNode(0, serverFactory); + IgniteEx node = startNode(serverFactory); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); @@ -138,26 +118,7 @@ public void testExistingConnectionSurvivesReload() throws Exception { /** */ @Test - public void testOutgoingConnectionsUseReloadedContext() throws Exception { - SslContextFactory factory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); - IgniteEx node = startNode(0, factory); - - factory.setKeyStoreFilePath(keyStorePath(KEYS_2)); - factory.setTrustManagers(trustIssuersOf(KEYS_2)); - factory.init(); - - IgniteEx peer = startNode(1, sslFactory(KEYS_2, trustIssuersOf(KEYS_2))); - - assertTrue(((TcpDiscoverySpi)node.configuration().getDiscoverySpi()).pingNode(peer.localNode().id())); - - int result = node.compute(node.cluster().forNodeId(peer.localNode().id())).call(() -> 1); - - assertEquals(1, result); - } - - /** */ - @Test - public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception { + public void testNewConnectionsDoNotWaitForReload() throws Exception { String replacementKeyStore = keyStorePath(KEYS_2); CountDownLatch reloadStarted = new CountDownLatch(1); CountDownLatch allowReload = new CountDownLatch(1); @@ -192,48 +153,29 @@ public void testUsePreviousContextDuringAndAfterFailedReload() throws Exception factory.setKeyStorePassword(keyStorePassword().toCharArray()); factory.setTrustManagers(trustedCertificates); - IgniteEx node = startNode(0, factory); - int[] ports = tlsPorts(node); - - ClientConfiguration clientCfg = clientConfiguration(node).setSslContextFactory(sslFactory(KEYS_1, trustedCertificates)); - - try (IgniteClient client = Ignition.startClient(clientCfg)) { - client.cacheNames(); - - factory.setKeyStoreFilePath(replacementKeyStore); - factory.setKeyStorePassword("wrong-password".toCharArray()); - - IgniteInternalFuture reload = GridTestUtils.runAsync(() -> { - assertThrows(IgniteException.class, factory::init); - }); + IgniteEx node = startNode(factory); + SSLContext client = sslFactory(KEYS_1, trustedCertificates).create(); - try { - assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); + factory.setKeyStoreFilePath(replacementKeyStore); - for (int port : ports) - assertEquals(certificate(KEYS_1), - connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); + IgniteInternalFuture reload = GridTestUtils.runAsync(factory::init); - client.cacheNames(); - } - finally { - allowReload.countDown(); - } + try { + assertTrue("Reload did not reach the replacement key store", reloadStarted.await(30, TimeUnit.SECONDS)); + for (int port : tlsPorts(node)) + connectAndHandshake(port, client); + } + finally { + allowReload.countDown(); reload.get(30, TimeUnit.SECONDS); - - for (int port : ports) - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); - - client.cacheNames(); } } /** */ @Test public void testRestInitializesContextOnceOnPortRetry() throws Exception { - TrustManager[] trustedCertificates = trustIssuersOf(KEYS_1); - SSLContext ctx = sslFactory(KEYS_1, trustedCertificates).create(); + SSLContext ctx = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)).create(); AtomicInteger factoryCalls = new AtomicInteger(); try (ServerSocket occupied = new ServerSocket()) { @@ -257,7 +199,16 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); - assertEquals(certificate(KEYS_1), connectAndGetServerCertificate(port, sslFactory(KEYS_1, trustedCertificates).create())); + + try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { + sock.connect(new InetSocketAddress(LOOPBACK_ADDRESS, port), SOCKET_TIMEOUT); + sock.setSoTimeout(SOCKET_TIMEOUT); + sock.startHandshake(); + sock.getOutputStream().write(GridClientPingPacket.PING_PACKET); + + assertArrayEquals(GridClientPingPacket.PING_PACKET, + sock.getInputStream().readNBytes(GridClientPingPacket.PING_PACKET.length)); + } } } @@ -265,7 +216,7 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { @Test public void testThinClientReconnectUsesReloadedContext() throws Exception { SslContextFactory clientFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); - IgniteEx node = startNode(0, sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); + IgniteEx node = startNode(sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); ClientConfiguration cfg = clientConfiguration(node).setSslContextFactory(clientFactory::create); @@ -280,7 +231,7 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { clientFactory.setTrustManagers(trustIssuersOf(KEYS_2)); clientFactory.init(); - startNode(0, sslFactory(KEYS_2, trustIssuersOf(KEYS_2))); + startNode(sslFactory(KEYS_2, trustIssuersOf(KEYS_2))); client.cacheNames(); } @@ -290,7 +241,7 @@ public void testThinClientReconnectUsesReloadedContext() throws Exception { @Test public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { Factory serverFactory = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)); - IgniteEx node = startNode(0, serverFactory); + IgniteEx node = startNode(serverFactory); Path keyStore = Files.createTempFile("thin-client-keys", ".jks"); final String storeType = "JKS"; @@ -313,7 +264,7 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { assertThrows(ClientConnectionException.class, client::cacheNames); - startNode(0, serverFactory); + startNode(serverFactory); client.cacheNames(); } @@ -326,7 +277,7 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { /** */ @Test public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(0, sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); + IgniteEx node = startNode(sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); ClientConfiguration cfg = new ClientConfiguration() @@ -342,13 +293,10 @@ public void testThinClientReportsContextFactoryFailure() throws Exception { } /** */ - private IgniteEx startNode(int idx, Factory factory) throws Exception { - return startGrid(idx, cfg -> { + private IgniteEx startNode(Factory factory) throws Exception { + return startGrid(0, cfg -> { ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(ipFinder); - // Paired connections ensure the compute request opens or uses an outgoing connection. - ((TcpCommunicationSpi)cfg.getCommunicationSpi()).setUsePairedConnections(true); - cfg.setSslContextFactory(factory) .setClientConnectorConfiguration(new ClientConnectorConfiguration() .setSslEnabled(true) @@ -379,31 +327,16 @@ private static ClientConfiguration clientConfiguration(IgniteEx node) { } /** */ - private static Certificate certificate(String keys) throws Exception { - return KeyStore.getInstance(Path.of(keyStorePath(keys)).toFile(), keyStorePassword().toCharArray()).getCertificate(keys); - } - - /** */ - private static Certificate connectAndGetServerCertificate(int port, SSLContext ctx) throws IOException { + private static void connectAndHandshake(int port, SSLContext ctx) throws IOException { try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { - // TLS 1.2 completes the client handshake only after the server checks the client certificate. - sock.setEnabledProtocols(new String[] {"TLSv1.2"}); sock.connect(new InetSocketAddress(LOOPBACK_ADDRESS, port), SOCKET_TIMEOUT); sock.setSoTimeout(SOCKET_TIMEOUT); - - try { - sock.startHandshake(); - } - catch (SocketException e) { - throw new SSLException(e); - } - - return sock.getSession().getPeerCertificates()[0]; + sock.startHandshake(); } } /** */ - private static SslContextFactory sslFactory(String keys, TrustManager... trustManagers) { + private static SslContextFactory sslFactory(String keys, TrustManager[] trustManagers) { SslContextFactory factory = new SslContextFactory(); factory.setKeyStoreFilePath(keyStorePath(keys)); @@ -414,18 +347,16 @@ private static SslContextFactory sslFactory(String keys, TrustManager... trustMa } /** */ - private static TrustManager[] trustIssuersOf(String... keys) throws Exception { + private static TrustManager[] trustIssuersOf(String keys) throws Exception { KeyStore trustStore = KeyStore.getInstance("JKS"); trustStore.load(null, null); - for (String key : keys) { - KeyStore keyStore = KeyStore.getInstance(Path.of(keyStorePath(key)).toFile(), keyStorePassword().toCharArray()); - Certificate[] chain = keyStore.getCertificateChain(key); + KeyStore keyStore = KeyStore.getInstance(Path.of(keyStorePath(keys)).toFile(), keyStorePassword().toCharArray()); + Certificate[] chain = keyStore.getCertificateChain(keys); - for (int i = 1; i < chain.length; i++) - trustStore.setCertificateEntry(key + i, chain[i]); - } + for (int i = 1; i < chain.length; i++) + trustStore.setCertificateEntry(keys + i, chain[i]); TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); From 1c357c3c293461ac8dc229fdc980e8f5956b7f24 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 22:42:34 +0300 Subject: [PATCH 184/242] IGNITE-29256 Test REST recovery after occupied port --- .../org/apache/ignite/ssl/SslContextReloadTest.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 58d5488108703..68d6d0c66a124 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -28,7 +28,6 @@ import java.security.cert.Certificate; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; @@ -174,9 +173,8 @@ public void testNewConnectionsDoNotWaitForReload() throws Exception { /** */ @Test - public void testRestInitializesContextOnceOnPortRetry() throws Exception { + public void testRestAcceptsConnectionAfterPortRetry() throws Exception { SSLContext ctx = sslFactory(KEYS_1, trustIssuersOf(KEYS_1)).create(); - AtomicInteger factoryCalls = new AtomicInteger(); try (ServerSocket occupied = new ServerSocket()) { occupied.bind(new InetSocketAddress(LOOPBACK_ADDRESS, GridTestUtils.getFreeCommPort())); @@ -188,17 +186,12 @@ public void testRestInitializesContextOnceOnPortRetry() throws Exception { .setConnectorConfiguration(new ConnectorConfiguration() .setPort(occupied.getLocalPort()) .setSslEnabled(true) - .setSslFactory(() -> { - factoryCalls.incrementAndGet(); - - return ctx; - })); + .setSslFactory(() -> ctx)); }); int port = node.localNode().attribute(ATTR_REST_TCP_PORT); assertTrue("REST did not retry the occupied port", port > occupied.getLocalPort()); - assertEquals("SSL context must be initialized once before the first connection", 1, factoryCalls.get()); try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { sock.connect(new InetSocketAddress(LOOPBACK_ADDRESS, port), SOCKET_TIMEOUT); From 983c2382443f56aae75229d7cabcffc699277d15 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Sat, 7 Nov 2026 23:06:21 +0300 Subject: [PATCH 185/242] IGNITE-29256 Handle discovery TLS errors at connection boundary --- .../apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index d59d613faefd1..de6d0ed66920c 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -1680,13 +1680,8 @@ Socket createSocket() throws IOException { } /** */ - SSLSocketFactory sslSocketFactory() throws IOException { - try { - return sslCtxFactory.create().getSocketFactory(); - } - catch (RuntimeException e) { - throw new IOException("Failed to obtain the SSL socket factory for a discovery connection.", e); - } + SSLSocketFactory sslSocketFactory() { + return sslCtxFactory.create().getSocketFactory(); } /** From b0562e9fbd50869baba2355b9970c19ded9d0b5a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 12:39:41 +0300 Subject: [PATCH 186/242] IGNITE-29256 Explain eager SSL context initialization --- .../main/java/org/apache/ignite/internal/util/IgniteUtils.java | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index f0a026e988eab..a60d3d7513139 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -7745,6 +7745,7 @@ public static GridNioSslFilter sslFilter( IgniteLogger log, @Nullable MetricRegistryImpl mreg ) { + // Initialize caching factories before NIO threads start, and fail fast if the context cannot be created. sslCtxFactory.create(); LongConsumer handshakeDuration = mreg == null ? null : mreg.histogram( From a00342f1b0bb46463b53f080fb525fdf5649080d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 13:15:21 +0300 Subject: [PATCH 187/242] IGNITE-29256 Preserve thin client SSL startup failures --- .../client/thin/ClientSslUtilsTest.java | 44 +++++++++++++++++++ .../ignite/ssl/SslContextReloadTest.java | 19 -------- .../ignite/testsuites/SecurityTestSuite.java | 2 + .../internal/client/thin/ClientSslUtils.java | 10 ++++- 4 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/client/thin/ClientSslUtilsTest.java diff --git a/modules/core/src/test/java/org/apache/ignite/internal/client/thin/ClientSslUtilsTest.java b/modules/core/src/test/java/org/apache/ignite/internal/client/thin/ClientSslUtilsTest.java new file mode 100644 index 0000000000000..7896f40818b6e --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/client/thin/ClientSslUtilsTest.java @@ -0,0 +1,44 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.client.thin; + +import org.apache.ignite.client.SslMode; +import org.apache.ignite.configuration.ClientConfiguration; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +/** */ +public class ClientSslUtilsTest { + /** */ + @Test + public void testContextFactoryFailureIsReportedBeforeConnecting() { + IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); + + ClientConfiguration cfg = new ClientConfiguration() + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(() -> { + throw failure; + }); + + ClientError err = assertThrows(ClientError.class, () -> ClientSslUtils.getSslContextFactory(cfg)); + + assertSame(failure, err.getCause()); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java index 68d6d0c66a124..c339b4fcdf828 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReloadTest.java @@ -44,7 +44,6 @@ import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; -import org.apache.ignite.internal.util.typedef.X; import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi; import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; @@ -267,24 +266,6 @@ public void testThinClientReconnectDoesNotReloadKeyStore() throws Exception { } } - /** */ - @Test - public void testThinClientReportsContextFactoryFailure() throws Exception { - IgniteEx node = startNode(sslFactory(KEYS_1, trustIssuersOf(KEYS_1))); - IllegalStateException failure = new IllegalStateException("Cannot load client TLS credentials"); - - ClientConfiguration cfg = new ClientConfiguration() - .setAddresses(LOOPBACK_ADDRESS + ":" + node.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED) - .setSslContextFactory(() -> { - throw failure; - }); - - ClientConnectionException err = assertThrows(ClientConnectionException.class, () -> Ignition.startClient(cfg).close()); - - assertSame(failure, X.cause(err, IllegalStateException.class)); - } - /** */ private IgniteEx startNode(Factory factory) throws Exception { return startGrid(0, cfg -> { diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index d8e935eaf8b58..22fec30c574b0 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -17,6 +17,7 @@ package org.apache.ignite.testsuites; +import org.apache.ignite.internal.client.thin.ClientSslUtilsTest; import org.apache.ignite.internal.processors.security.IgniteSecurityProcessorTest; import org.apache.ignite.internal.processors.security.InvalidServerTest; import org.apache.ignite.internal.processors.security.NodeConnectionCertificateCapturingTest; @@ -147,6 +148,7 @@ SSLContextWrapperTest.class, SslContextFactoryInitTest.class, SslContextReloadTest.class, + ClientSslUtilsTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, diff --git a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java index 11d4404df0720..44a1fe9f5ce6c 100644 --- a/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java +++ b/modules/thin-client/impl/src/main/java/org/apache/ignite/internal/client/thin/ClientSslUtils.java @@ -82,8 +82,16 @@ public static Factory getSslContextFactory(ClientConfiguration cfg) Factory sslCtxFactory = cfg.getSslContextFactory(); - if (sslCtxFactory != null) + if (sslCtxFactory != null) { + try { + sslCtxFactory.create(); + } + catch (Exception e) { + throw new ClientError("SSL Context Factory failed", e); + } + return sslCtxFactory; + } BiFunction or = (val, dflt) -> val == null || val.isEmpty() ? dflt : val; From a5e18ba41177a83bd768b5dfc70fc4900921eb01 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 14:11:05 +0300 Subject: [PATCH 188/242] IGNITE-29210 Add manual TLS reload command --- docs/_docs/security/ssl-tls.adoc | 45 +-- docs/_docs/tools/control-script.adoc | 32 ++ .../ignite/ssl/AbstractSslContextFactory.java | 4 +- .../apache/ignite/ssl/SslContextFactory.java | 1 + ...SecurityCommandHandlerPermissionsTest.java | 40 +++ .../IgniteControlUtilityTestSuite.java | 2 + .../util/GridCommandHandlerSslReloadTest.java | 293 ++++++++++++++++++ .../ignite/internal/GridKernalContext.java | 4 + .../internal/GridKernalContextImpl.java | 11 + .../apache/ignite/internal/IgniteKernal.java | 2 + .../management/IgniteCommandRegistry.java | 4 +- .../internal/management/ssl/SslCommand.java | 29 ++ .../management/ssl/SslReloadCommand.java | 58 ++++ .../management/ssl/SslReloadTask.java | 218 +++++++++++++ .../managers/communication/GridIoManager.java | 3 + .../discovery/GridDiscoveryManager.java | 4 + .../reader/StandaloneGridKernalContext.java | 6 + .../odbc/ClientListenerProcessor.java | 11 +- .../protocols/tcp/GridTcpRestProtocol.java | 15 +- .../processors/ssl/GridSslProcessor.java | 49 +++ .../ignite/ssl/SslContextFactoryInitTest.java | 67 ++++ ...mmandHandlerClusterByClassTest_help.output | 3 + ...ndlerClusterByClassWithSSLTest_help.output | 3 + 23 files changed, 876 insertions(+), 28 deletions(-) create mode 100644 modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java create mode 100644 modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b56b2094d586c..236855c1b820f 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -157,27 +157,32 @@ include::{javaFile}[tag=disable-validation,indent=0] -- == Upgrading Certificates -If your SSL certificates are about to expire or have been compromised, you can install new certificates without shutting down the whole cluster. -The following is a procedure for updating certificate. - -. First of all, make sure the new certificates are trusted by all cluster nodes. -This step may not be necessary if your trusted stores contain the root certificate and the new certificates are signed by the same CA. -+ --- -Repeat the following procedure for the nodes where the certificate is not trusted: - -.. Import the new certificate to the trusted store of the node. -.. Gracefully restart the node. -.. Repeat these steps for all server nodes. - -Now all nodes trust the new certificates. --- - -. Import the new certificate (including the private key) to the key store of the corresponding node and remove the old certificate. Then gracefully restart the node. Repeat this procedure for all certificates you want to update. - - -//Otherwise, first you have to push the trust store to all nodes one by one. It will contain trusts for both new and old certificates while you transition. +You can replace certificate files and reload them without restarting nodes that use `SslContextFactory`. +The link:tools/control-script#reloading-tls-certificates[`--ssl reload` command] updates TCP transports on all server and thick-client nodes. +It does not reload HTTPS or external client applications. + +. Back up the key and trust store files before replacing them. +Keep their configured paths, store types, and passwords unchanged; changing node configuration requires a restart. +. Make sure every peer trusts both the current and replacement certificates before changing any key store. +This includes cluster nodes and client applications, including the control script. +If the replacement certificates use an already trusted CA, no trust store change is needed. +Otherwise, add the required certificates to the trust stores, replace the files, and run `control.sh --ssl reload`. +Reload trust in external client applications using their own procedures. +Continue only after every affected peer has loaded the updated trust store. +. Replace each key store with the new certificate and its private key, then run `control.sh --ssl reload` again. +Use the link:tools/control-script#connecting-to-cluster[connection parameters] required by your cluster. +The command asks for confirmation; use `--yes` to skip it. +. Check the result for every node and transport, then verify a new connection with the replacement certificates. +Established connections continue using their existing TLS context. + +A failed reload leaves that factory's previous context in use; successful reloads on other factories are not rolled back. +Correct the failed store and run the command again. +To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. + +Custom factories must extend `AbstractSslContextFactory` to support the command. +Factories of other types are reported as unsupported. +If you configured `trustManagers` directly, the command reuses them instead of reloading a trust store. == SslContextFactory Properties diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index ac52f55792d8c..ce9d8ed507f71 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -160,6 +160,38 @@ control.bat --state --ssl-factory client-connector-ssl-factory.xml Enable it in the configuration (`IgniteConfiguration#setClientConnectorConfiguration`). +== Reloading TLS Certificates + +Use `--ssl reload` to load replaced key and trust stores on all server and thick-client nodes in the current topology. +It reloads discovery, communication, client connector, and binary REST transports that started with TLS. +New connections use the replacement context; established connections stay open. +HTTPS is outside the command's scope. + +Follow link:security/ssl-tls#upgrading-certificates[Upgrading Certificates] to prepare peer trust, replace the files, and verify the result. +Add the <> required by your cluster: + +[tabs] +-- +tab:Unix[] +[source,shell] +---- +control.sh --ssl reload [--yes] +---- +tab:Windows[] +[source,shell] +---- +control.bat --ssl reload [--yes] +---- +-- + +The command requires `ADMIN_OPS` permission when security is enabled and asks for confirmation unless `--yes` is given. +Each node logs its reload results and, when security is enabled, the initiating user. + +The report lists results for each node and transport, including nodes without TLS. +Factories that do not extend `AbstractSslContextFactory` are reported as unsupported. +The command continues after individual failures and exits with code `4` if a reload fails, is unsupported, or a target node leaves. +Successful replacements remain in effect; the command does not roll them back. + == Activation, Deactivation and Topology Management You can use the control script to activate or deactivate your cluster, and manage the link:clustering/baseline-topology[Baseline Topology]. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 5139f98bc396b..fe2ea18353a50 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -31,6 +31,8 @@ /** * Base class for SSL context factories that cache their {@link SSLContext}. + * Overrides of {@link #create()} must obtain the context from {@code super.create()} on each call to support reload. + * Key and trust manager loading hooks must read fresh material on each call. */ public abstract class AbstractSslContextFactory implements Factory { /** */ @@ -182,7 +184,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException * * @throws IgniteException If the context cannot be initialized. */ - protected final void init() { + public final synchronized void init() { try { sslCtx.set(createSslContext()); } diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 5db5c511c7b1e..407215a3ddea2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -241,6 +241,7 @@ public TrustManager[] getTrustManagers() { /** * Sets pre-configured trust managers. This is an optional parameter, * however one of the {@link #setTrustStoreFilePath(String)}, {@code #setTrustManagers(TrustManager[])} + * must be set. Supplied managers are reused by {@link #init()}. * * @param trustMgrs Pre-configured trust managers. */ diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java index 902bc9797d750..fbc6a39360e48 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java @@ -52,6 +52,8 @@ import org.apache.ignite.services.ServiceConfiguration; import org.apache.ignite.services.ServiceDescriptor; import org.apache.ignite.spi.systemview.view.ComputeTaskView; +import org.apache.ignite.testframework.ListeningTestLogger; +import org.apache.ignite.testframework.LogListener; import org.apache.ignite.util.GridCommandHandlerAbstractTest; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -67,6 +69,7 @@ import static org.apache.ignite.internal.processors.job.GridJobProcessor.JOBS_VIEW; import static org.apache.ignite.internal.processors.task.GridTaskProcessor.TASKS_VIEW; import static org.apache.ignite.internal.util.IgniteUtils.resolveIgnitePath; +import static org.apache.ignite.plugin.security.SecurityPermission.ADMIN_OPS; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_CREATE; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_DESTROY; import static org.apache.ignite.plugin.security.SecurityPermission.CACHE_READ; @@ -76,6 +79,7 @@ import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.ALL_PERMISSIONS; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.NO_PERMISSIONS; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.systemPermissions; +import static org.apache.ignite.testframework.GridTestUtils.sslFactory; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; /** */ @@ -132,6 +136,42 @@ public void testClusterTag() throws Exception { assertTrue(out.contains("Cluster tag: " + clTag)); } + /** */ + @Test + public void testSslReloadRequiresAdminOps() throws Exception { + startGrid( + 0, + userData(TEST_NO_PERMISSIONS_LOGIN, NO_PERMISSIONS), + userData(TEST_LOGIN, systemPermissions(ADMIN_OPS)) + ); + + List cmdArgs = asList("--ssl", "reload"); + + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, executeOnBehalf(cmdArgs, TEST_NO_PERMISSIONS_LOGIN)); + assertTrue(testOut.toString().contains("Authorization failed")); + + assertEquals(EXIT_CODE_OK, executeOnBehalf(cmdArgs, TEST_LOGIN)); + } + + /** */ + @Test + public void testSslReloadLogsAuthenticatedInitiator() throws Exception { + LogListener reloadLog = LogListener.matches(msg -> msg.contains("TLS reload: ") + && msg.contains(": reloaded [initiator=" + TEST_LOGIN + ", subjectId=")).build(); + + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)); + + cfg.setGridLogger(new ListeningTestLogger(log, reloadLog)); + cfg.setSslContextFactory(sslFactory()); + cfg.setPluginProviders(new TestSecurityPluginProvider(cfg.getIgniteInstanceName(), DEFAULT_PWD, ALL_PERMISSIONS, false, + userData(TEST_LOGIN, systemPermissions(ADMIN_OPS)))); + + startGrid(cfg); + + assertEquals(EXIT_CODE_OK, executeOnBehalf(asList("--ssl", "reload"), TEST_LOGIN)); + assertTrue(reloadLog.check()); + } + /** */ @Test public void testCacheScan() throws Exception { diff --git a/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java b/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java index 6528d919f6105..d5950db85e503 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/testsuites/IgniteControlUtilityTestSuite.java @@ -36,6 +36,7 @@ import org.apache.ignite.util.GridCommandHandlerLegacyClientTest; import org.apache.ignite.util.GridCommandHandlerManagementPoolTest; import org.apache.ignite.util.GridCommandHandlerMetadataTest; +import org.apache.ignite.util.GridCommandHandlerSslReloadTest; import org.apache.ignite.util.GridCommandHandlerSslTest; import org.apache.ignite.util.GridCommandHandlerTest; import org.apache.ignite.util.GridCommandHandlerWalTest; @@ -61,6 +62,7 @@ GridCommandHandlerClusterByClassTest.class, GridCommandHandlerClusterByClassWithSSLTest.class, GridCommandHandlerSslTest.class, + GridCommandHandlerSslReloadTest.class, GridPersistenceCommandsTest.class, GridCommandHandlerSslWithSecurityTest.class, diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java new file mode 100644 index 0000000000000..1a88189597e6d --- /dev/null +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -0,0 +1,293 @@ +/* + * 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. + */ + +package org.apache.ignite.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.GridJobExecuteRequest; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.TestRecordingCommunicationSpi; +import org.apache.ignite.internal.management.ssl.SslReloadTask; +import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; +import org.apache.ignite.ssl.SslContextFactory; +import org.apache.ignite.testframework.GridTestUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static java.util.Arrays.asList; +import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; +import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_OK; +import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_UNEXPECTED_ERROR; +import static org.apache.ignite.testframework.GridTestUtils.assertContains; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assume.assumeTrue; + +/** */ +public class GridCommandHandlerSslReloadTest extends GridCommandHandlerClusterPerMethodAbstractTest { + /** */ + @Rule + public final TemporaryFolder stores = new TemporaryFolder(); + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + super.beforeTest(); + + persistenceEnable(false); + injectTestSystemOut(); + } + + /** */ + @Test + public void testSharedFactoryReloadedOnceOnServersAndClients() throws Exception { + List nodes = new ArrayList<>(); + List factories = new ArrayList<>(); + + for (String name : new String[] {getTestIgniteInstanceName(0), getTestIgniteInstanceName(1), "client"}) { + CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); + IgniteConfiguration cfg = getConfiguration(name).setSslContextFactory(factory); + + cfg.getClientConnectorConfiguration().setSslEnabled(true); + cfg.getConnectorConfiguration().setSslEnabled(true); + + nodes.add(startGrid(cfg)); + factories.add(factory); + } + + int[] loads = factories.stream().mapToInt(factory -> factory.loads.get()).toArray(); + + assertEquals(EXIT_CODE_OK, executeTlsReload()); + + for (int i = 0; i < nodes.size(); i++) { + assertEquals(loads[i] + 1, factories.get(i).loads.get()); + String transports = nodes.get(i).configuration().isClientMode() + ? "communication, client connector, discovery" + : "communication, client connector, binary REST, discovery"; + + assertContains(log, testOut.toString(), nodes.get(i).localNode().id() + ": " + transports + ": reloaded"); + } + } + + /** */ + @Test + public void testReloadedFilesApplyToNewConnections() throws Exception { + Path keys = stores.newFile("keys.jks").toPath(); + Path trust = stores.newFile("trust.jks").toPath(); + + Files.copy(Path.of(keyStorePath("node01")), keys, REPLACE_EXISTING); + Files.copy(Path.of(keyStorePath("trustone")), trust, REPLACE_EXISTING); + + SslContextFactory factory = new SslContextFactory(); + + factory.setKeyStoreFilePath(keys.toString()); + factory.setKeyStorePassword(keyStorePassword().toCharArray()); + factory.setTrustStoreFilePath(trust.toString()); + factory.setTrustStorePassword(keyStorePassword().toCharArray()); + + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)); + + cfg.getConnectorConfiguration().setSslEnabled(true).setSslClientAuth(true).setSslFactory(factory); + + IgniteEx node = startGrid(cfg); + int port = node.localNode().attribute(ATTR_REST_TCP_PORT); + + connectAndPing(port, sslTrustedFactory("node01", "trustone").create()); + + Files.copy(Path.of(keyStorePath("node02")), keys, REPLACE_EXISTING); + Files.copy(Path.of(keyStorePath("trusttwo")), trust, REPLACE_EXISTING); + + assertEquals(EXIT_CODE_OK, execute("--ssl", "reload")); + + connectAndPing(port, sslTrustedFactory("node02", "trusttwo").create()); + } + + /** */ + @Test + public void testPartialFailureReportsEveryFactory() throws Exception { + Path keys = stores.newFile("keys.jks").toPath(); + + Files.copy(Path.of(keyStorePath("node01")), keys, REPLACE_EXISTING); + + CountingSslContextFactory failing = new CountingSslContextFactory(keys.toString()); + CountingSslContextFactory working = new CountingSslContextFactory(keyStorePath("node01")); + SSLContext original = failing.create(); + SSLContext clientCtx = working.create(); + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(failing); + + cfg.getClientConnectorConfiguration().setSslEnabled(true).setUseIgniteSslContextFactory(false).setSslContextFactory(working); + cfg.getConnectorConfiguration().setSslEnabled(true).setSslFactory(() -> clientCtx); + + IgniteEx node = startGrid(cfg); + + Files.delete(keys); + + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, executeTlsReload()); + + String report = testOut.toString(); + String nodeId = node.localNode().id().toString(); + + assertContains(log, report, nodeId + ": communication, discovery: failed:"); + assertContains(log, report, nodeId + ": client connector: reloaded"); + assertContains(log, report, nodeId + ": binary REST: unsupported:"); + assertSame(original, failing.create()); + assertNotSame(clientCtx, working.create()); + } + + /** */ + @Test + public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Exception { + CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); + + try (ServerSocket occupied = new ServerSocket()) { + occupied.bind(new InetSocketAddress("127.0.0.1", 0)); + + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)); + + cfg.getConnectorConfiguration().setPort(occupied.getLocalPort()).setPortRange(0).setSslEnabled(true).setSslFactory(factory); + + IgniteEx node = startGrid(cfg); + int loads = factory.loads.get(); + + assertEquals(EXIT_CODE_OK, execute("--ssl", "reload")); + assertEquals(loads, factory.loads.get()); + assertContains(log, testOut.toString(), node.localNode().id() + ": TLS is not enabled"); + } + } + + /** */ + @Test + public void testReportsNodeLeavingDuringReload() throws Exception { + IgniteEx node = startGrid(0); + UUID departingId = startGrid(1).localNode().id(); + TestRecordingCommunicationSpi comm = TestRecordingCommunicationSpi.spi(node); + + comm.blockMessages((dest, msg) -> dest.id().equals(departingId) && msg instanceof GridJobExecuteRequest + && ((GridJobExecuteRequest)msg).taskClassName().equals(SslReloadTask.class.getName())); + + IgniteInternalFuture cmd = GridTestUtils.runAsync(() -> execute("--ssl", "reload")); + int exitCode; + + try { + assertTrue(comm.waitForBlocked(1, 10_000)); + + stopGrid(1); + } + finally { + comm.stopBlock(); + exitCode = cmd.get(30_000); + } + + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, exitCode); + assertContains(log, testOut.toString(), departingId + ": left the topology"); + assertContains(log, testOut.toString(), node.localNode().id() + ": TLS is not enabled"); + } + + /** */ + @Test + public void testConcurrentCommandsReloadTheSameFactory() throws Exception { + CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); + + startGrid(getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(factory)); + + int loads = factory.loads.get(); + + GridTestUtils.runMultiThreaded(() -> { + assertEquals(EXIT_CODE_OK, + newCommandHandler(createTestLogger()).execute(asList("--ssl", "reload", "--yes"))); + }, 2, "ssl-reload"); + + assertEquals(loads + 2, factory.loads.get()); + } + + /** */ + @Test + public void testDeclinedConfirmationDoesNotReload() throws Exception { + assumeTrue(cliCommandHandler()); + + CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); + + startGrid(getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(factory)); + + int loads = factory.loads.get(); + + autoConfirmation = false; + injectTestSystemIn("n"); + + assertEquals(EXIT_CODE_OK, execute("--ssl", "reload")); + assertContains(log, testOut.toString(), "Make sure that all nodes trust the new certificates before reloading."); + assertEquals(loads, factory.loads.get()); + } + + /** */ + private int executeTlsReload() { + return execute("--ssl", "reload", "--keystore", keyStorePath("node01"), "--keystore-password", keyStorePassword(), + "--truststore", keyStorePath("trustone"), "--truststore-password", keyStorePassword()); + } + + /** */ + private static void connectAndPing(int port, SSLContext ctx) throws Exception { + try (SSLSocket sock = (SSLSocket)ctx.getSocketFactory().createSocket()) { + sock.connect(new InetSocketAddress("127.0.0.1", port), 10_000); + sock.setSoTimeout(10_000); + sock.startHandshake(); + sock.getOutputStream().write(GridClientPingPacket.PING_PACKET); + + assertArrayEquals(GridClientPingPacket.PING_PACKET, + sock.getInputStream().readNBytes(GridClientPingPacket.PING_PACKET.length)); + } + } + + /** */ + private static class CountingSslContextFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0; + + /** */ + private final AtomicInteger loads = new AtomicInteger(); + + /** */ + private CountingSslContextFactory(String path) { + setKeyStoreFilePath(path); + setKeyStorePassword(keyStorePassword().toCharArray()); + setTrustManagers(getDisabledTrustManager()); + } + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + loads.incrementAndGet(); + + return super.openFileInputStream(path); + } + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java index ebc321f96064b..59eba599d570b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java @@ -74,6 +74,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; +import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -298,6 +299,9 @@ public interface GridKernalContext extends Iterable { */ public GridPortProcessor ports(); + /** @return SSL processor. */ + public GridSslProcessor ssl(); + /** * Gets schedule processor. * diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java index 773efe5a99d96..1e28cd1a62fba 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java @@ -96,6 +96,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; +import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -263,6 +264,9 @@ public class GridKernalContextImpl implements GridKernalContext, Externalizable @GridToStringInclude private GridPortProcessor portProc; + /** */ + private GridSslProcessor sslProc; + /** */ @GridToStringInclude private IgniteScheduleProcessorAdapter scheduleProc; @@ -538,6 +542,8 @@ else if (comp instanceof GridTaskSessionProcessor) sesProc = (GridTaskSessionProcessor)comp; else if (comp instanceof GridPortProcessor) portProc = (GridPortProcessor)comp; + else if (comp instanceof GridSslProcessor) + sslProc = (GridSslProcessor)comp; else if (comp instanceof GridClosureProcessor) closProc = (GridClosureProcessor)comp; else if (comp instanceof IgniteServiceProcessor) @@ -743,6 +749,11 @@ else if (!(comp instanceof DiscoveryNodeValidationProcessor return portProc; } + /** {@inheritDoc} */ + @Override public GridSslProcessor ssl() { + return sslProc; + } + /** {@inheritDoc} */ @Override public IgniteScheduleProcessorAdapter schedule() { return scheduleProc; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java index 5d11403186f4e..ddb426bca16e9 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java @@ -165,6 +165,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; +import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -984,6 +985,7 @@ public void start( // Start some other processors (order & place is important). startProcessor(new GridPortProcessor(ctx)); + startProcessor(new GridSslProcessor(ctx)); startProcessor(new GridJobMetricsProcessor(ctx)); // Timeout processor needs to be started before managers, diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java index cfe4fe6deb8f5..c69bdb7b01f99 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/IgniteCommandRegistry.java @@ -38,6 +38,7 @@ import org.apache.ignite.internal.management.persistence.PersistenceCommand; import org.apache.ignite.internal.management.property.PropertyCommand; import org.apache.ignite.internal.management.snapshot.SnapshotCommand; +import org.apache.ignite.internal.management.ssl.SslCommand; import org.apache.ignite.internal.management.tx.TxCommand; import org.apache.ignite.internal.management.wal.WalCommand; import org.apache.ignite.internal.util.typedef.internal.U; @@ -77,7 +78,8 @@ public IgniteCommandRegistry() { new CdcCommand(), new ConsistencyCommand(), new EventCommand(), - new IoTestCommand() + new IoTestCommand(), + new SslCommand() ); U.loadService(CommandsProvider.class).forEach(p -> p.commands().forEach(this::register)); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java new file mode 100644 index 0000000000000..908d5b02b5020 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslCommand.java @@ -0,0 +1,29 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import org.apache.ignite.internal.management.api.CommandRegistryImpl; +import org.apache.ignite.internal.management.api.NoArg; + +/** */ +public class SslCommand extends CommandRegistryImpl { + /** */ + public SslCommand() { + super(new SslReloadCommand()); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java new file mode 100644 index 0000000000000..7ae3d250b123c --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java @@ -0,0 +1,58 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.util.Collection; +import java.util.function.Consumer; +import org.apache.ignite.cluster.ClusterNode; +import org.apache.ignite.internal.management.api.ComputeCommand; +import org.apache.ignite.internal.management.api.NoArg; + +/** */ +public class SslReloadCommand implements ComputeCommand { + /** {@inheritDoc} */ + @Override public String description() { + return "Reload TLS key and trust stores on all server and client nodes"; + } + + /** {@inheritDoc} */ + @Override public Class argClass() { + return NoArg.class; + } + + /** {@inheritDoc} */ + @Override public Class taskClass() { + return SslReloadTask.class; + } + + /** {@inheritDoc} */ + @Override public Collection nodes(Collection nodes, NoArg arg) { + return nodes; + } + + /** {@inheritDoc} */ + @Override public String confirmationPrompt(NoArg arg) { + return "Warning: new connections will use the replaced key and trust stores. " + + "Make sure that all nodes trust the new certificates before reloading."; + } + + /** {@inheritDoc} */ + @Override public void printResult(NoArg arg, String res, Consumer printer) { + printer.accept(res); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java new file mode 100644 index 0000000000000..b9a91d4af93fe --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -0,0 +1,218 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteException; +import org.apache.ignite.cluster.ClusterNode; +import org.apache.ignite.cluster.ClusterTopologyException; +import org.apache.ignite.compute.ComputeJob; +import org.apache.ignite.compute.ComputeJobResult; +import org.apache.ignite.internal.Order; +import org.apache.ignite.internal.dto.IgniteDataTransferObject; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.processors.task.GridInternal; +import org.apache.ignite.internal.util.typedef.X; +import org.apache.ignite.internal.visor.VisorJob; +import org.apache.ignite.internal.visor.VisorMultiNodeTask; +import org.apache.ignite.internal.visor.VisorTaskArgument; +import org.apache.ignite.plugin.security.SecuritySubject; +import org.apache.ignite.ssl.AbstractSslContextFactory; + +import static java.util.stream.Collectors.joining; + +/** */ +@GridInternal +public class SslReloadTask extends VisorMultiNodeTask { + /** */ + private static final long serialVersionUID = 0; + + /** */ + private Collection targetNodes; + + /** {@inheritDoc} */ + @Override protected Map map0(List subgrid, VisorTaskArgument arg) { + targetNodes = new ArrayList<>(arg.getNodes()); + + Map jobs = super.map0(subgrid, arg); + + if (jobs.isEmpty()) + throw new IgniteException(targetNodes.stream().map(id -> id + ": left the topology").collect(joining("\n"))); + + return jobs; + } + + /** {@inheritDoc} */ + @Override protected VisorJob job(NoArg arg) { + return new SslReloadJob(arg, debug); + } + + /** {@inheritDoc} */ + @Override protected String reduce0(List results) { + Map nodeResults = new HashMap<>(); + + for (ComputeJobResult res : results) + nodeResults.put(res.getNode().id(), res); + + List reports = new ArrayList<>(); + boolean failed = false; + + for (UUID id : targetNodes) { + ComputeJobResult res = nodeResults.get(id); + + if (res == null || X.hasCause(res.getException(), ClusterTopologyException.class)) { + reports.add(id + ": left the topology"); + failed = true; + } + else if (res.getException() != null) { + reports.add(id + ": failed: " + errorMessage(res.getException())); + failed = true; + } + else { + SslReloadJobResult nodeRes = res.getData(); + + reports.add(nodeRes.report); + failed |= nodeRes.failed; + } + } + + String report = String.join("\n", reports); + + if (failed) + throw new IgniteException(report); + + return report; + } + + /** */ + private static String errorMessage(Throwable err) { + return err.getMessage() == null ? err.getClass().getName() : err.getMessage(); + } + + /** */ + private static class SslReloadJob extends VisorJob { + /** */ + private static final long serialVersionUID = 0; + + /** */ + private SslReloadJob(NoArg arg, boolean debug) { + super(arg, debug); + } + + /** {@inheritDoc} */ + @Override protected SslReloadJobResult run(NoArg arg) { + Map> factories = ignite.context().ssl().factories(); + UUID nodeId = ignite.localNode().id(); + String initiator = ""; + + if (ignite.context().security().enabled()) { + SecuritySubject subj = ignite.context().security().securityContext().subject(); + + initiator = " [initiator=" + subj.login() + ", subjectId=" + subj.id() + ']'; + } + + if (factories.isEmpty()) { + String report = nodeId + ": TLS is not enabled"; + + ignite.log().info("TLS reload: " + report + initiator); + + return new SslReloadJobResult(report, false); + } + + Set> processed = Collections.newSetFromMap(new IdentityHashMap<>()); + List reports = new ArrayList<>(); + boolean failed = false; + + for (Factory factory : factories.values()) { + if (!processed.add(factory)) + continue; + + String transports = factories.entrySet().stream() + .filter(entry -> entry.getValue() == factory) + .map(Map.Entry::getKey) + .collect(joining(", ")); + + String outcome; + boolean factoryFailed = false; + + if (!(factory instanceof AbstractSslContextFactory)) { + outcome = "unsupported: " + factory.getClass().getName(); + factoryFailed = true; + } + else { + try { + ((AbstractSslContextFactory)factory).init(); + + outcome = "reloaded"; + } + catch (Exception e) { + outcome = "failed: " + errorMessage(e); + factoryFailed = true; + } + } + + String report = nodeId + ": " + transports + ": " + outcome; + + reports.add(report); + failed |= factoryFailed; + + if (factoryFailed) + ignite.log().warning("TLS reload: " + report + initiator); + else + ignite.log().info("TLS reload: " + report + initiator); + } + + return new SslReloadJobResult(String.join("\n", reports), failed); + } + } + + /** */ + public static class SslReloadJobResult extends IgniteDataTransferObject { + /** */ + private static final long serialVersionUID = 0; + + /** */ + @Order(0) + String report; + + /** */ + @Order(1) + boolean failed; + + /** */ + public SslReloadJobResult() { + // No-op. + } + + /** */ + public SslReloadJobResult(String report, boolean failed) { + this.report = report; + this.failed = failed; + } + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java b/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java index 90fd123fc3310..93b7f4e6b7958 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java @@ -418,6 +418,9 @@ public void resetMetrics() { startSpi(); + if ((CommunicationSpi)spi instanceof TcpCommunicationSpi && ctx.config().getSslContextFactory() != null) + ctx.ssl().register("communication", ctx.config().getSslContextFactory()); + MetricRegistry ioMetric = ctx.metric().registry(COMM_METRICS); ioMetric.register(OUTBOUND_MSG_QUEUE_CNT, spi::getOutboundMessagesQueueSize, diff --git a/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java b/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java index d8f9a4e428f49..05b48e1d9aab8 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java @@ -141,6 +141,7 @@ import org.apache.ignite.spi.discovery.DiscoverySpiNodeAuthenticator; import org.apache.ignite.spi.discovery.DiscoverySpiOrderSupport; import org.apache.ignite.spi.discovery.IgniteDiscoveryThread; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; import org.apache.ignite.spi.discovery.tcp.internal.TcpDiscoveryNode; import org.apache.ignite.spi.systemview.view.ClusterNodeView; import org.apache.ignite.spi.systemview.view.NodeAttributeView; @@ -1039,6 +1040,9 @@ private void waitForLastStateChangeEventFuture() { startSpi(); + if (spi instanceof TcpDiscoverySpi && ctx.config().getSslContextFactory() != null) + ctx.ssl().register("discovery", ctx.config().getSslContextFactory()); + registeredDiscoSpi = true; try { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java index 8f11ca68b96fd..07168ef84cf31 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java @@ -98,6 +98,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; +import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -443,6 +444,11 @@ private void setField(IgniteEx kernal, String name, Object val) throws NoSuchFie return null; } + /** {@inheritDoc} */ + @Override public GridSslProcessor ssl() { + return null; + } + /** {@inheritDoc} */ @Override public IgniteScheduleProcessorAdapter schedule() { return null; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java index 2ec17e552c165..61fd90fa90c5c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/odbc/ClientListenerProcessor.java @@ -271,6 +271,9 @@ public ClientListenerProcessor(GridKernalContext ctx) { distrThinCfg = new DistributedThinClientConfiguration(ctx); srv.start(); + + if (cliConnCfg.isSslEnabled()) + ctx.ssl().register("client connector", sslContextFactory(cliConnCfg)); } catch (Exception e) { throw new IgniteCheckedException("Failed to start client connector processor.", e); @@ -530,8 +533,7 @@ else if (connCtx.managementClient()) { GridNioFilter codecFilter = new GridNioCodecFilter(new ClientListenerNioMessageParser(log), log, true); if (cliConnCfg.isSslEnabled()) { - Factory sslCtxFactory = cliConnCfg.isUseIgniteSslContextFactory() ? - ctx.config().getSslContextFactory() : cliConnCfg.getSslContextFactory(); + Factory sslCtxFactory = sslContextFactory(cliConnCfg); if (sslCtxFactory == null) throw new IgniteCheckedException("Failed to create client listener " + @@ -561,6 +563,11 @@ else if (connCtx.managementClient()) { } } + /** */ + private Factory sslContextFactory(ClientConnectorConfiguration cfg) { + return cfg.isUseIgniteSslContextFactory() ? ctx.config().getSslContextFactory() : cfg.getSslContextFactory(); + } + /** {@inheritDoc} */ @Override public void onKernalStop(boolean cancel) { if (srv != null) { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java index bd36d384b82b2..d7e7328adca83 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/protocols/tcp/GridTcpRestProtocol.java @@ -168,10 +168,7 @@ private GridNioFilter[] makeFilters(ConnectorConfiguration cfg) throws SSLExcept if (!cfg.isSslEnabled()) return new GridNioFilter[] { codec }; - Factory sslCtxFactory = cfg.getSslFactory(); - - if (sslCtxFactory == null) - sslCtxFactory = ctx.config().getSslContextFactory(); + Factory sslCtxFactory = sslContextFactory(cfg); if (sslCtxFactory == null) // Thrown SSL exception instead of IgniteCheckedException for writing correct warning message into log. @@ -190,6 +187,13 @@ private GridNioFilter[] makeFilters(ConnectorConfiguration cfg) throws SSLExcept return new GridNioFilter[] { codec, sslFilter }; } + /** */ + private Factory sslContextFactory(ConnectorConfiguration cfg) { + Factory factory = cfg.getSslFactory(); + + return factory != null ? factory : ctx.config().getSslContextFactory(); + } + /** * Tries to start server with given parameters. * @@ -233,6 +237,9 @@ private boolean startTcpServer(InetAddress hostAddr, int port, GridNioServerList ctx.ports().registerPort(port, IgnitePortProtocol.TCP, getClass()); + if (cfg.isSslEnabled()) + ctx.ssl().register("binary REST", sslContextFactory(cfg)); + return true; } catch (IgniteCheckedException e) { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java new file mode 100644 index 0000000000000..6931c70beb002 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java @@ -0,0 +1,49 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.processors.ssl; + +import java.util.LinkedHashMap; +import java.util.Map; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.processors.GridProcessorAdapter; + +/** SSL factories used by running transports. */ +public class GridSslProcessor extends GridProcessorAdapter { + /** */ + private final Map> factories = new LinkedHashMap<>(); + + /** @param ctx Kernal context. */ + public GridSslProcessor(GridKernalContext ctx) { + super(ctx); + } + + /** + * @param transport Transport name. + * @param factory SSL context factory. + */ + public synchronized void register(String transport, Factory factory) { + factories.put(transport, factory); + } + + /** @return Snapshot of active transport factories. */ + public synchronized Map> factories() { + return new LinkedHashMap<>(factories); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 5c3b9564b0916..2b8fdcf5c33e0 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -17,14 +17,28 @@ package org.apache.ignite.ssl; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.testframework.GridTestThread; import org.junit.Test; +import static java.lang.Thread.State.BLOCKED; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; +import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; /** */ public class SslContextFactoryInitTest { @@ -53,4 +67,57 @@ public void testFailedInitKeepsContext() { assertThrows(IgniteException.class, factory::init); assertSame(original, factory.create()); } + + /** */ + @Test + public void testConcurrentInitLoadsOneKeyStoreAtATime() throws Exception { + CountDownLatch firstLoadStarted = new CountDownLatch(1); + CountDownLatch allowFirstLoad = new CountDownLatch(1); + AtomicInteger loads = new AtomicInteger(); + + SslContextFactory factory = new SslContextFactory() { + /** */ + private static final long serialVersionUID = 0; + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + if (loads.incrementAndGet() == 1) { + firstLoadStarted.countDown(); + + U.awaitQuiet(allowFirstLoad); + } + + return super.openFileInputStream(path); + } + }; + + factory.setKeyStoreFilePath(keyStorePath("node01")); + factory.setKeyStorePassword(keyStorePassword().toCharArray()); + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + + GridTestThread first = new GridTestThread(factory::init); + GridTestThread second = new GridTestThread(factory::init); + + first.start(); + + try { + assertTrue(firstLoadStarted.await(10, SECONDS)); + + second.start(); + + assertTrue(waitForCondition(() -> second.getState() == BLOCKED || loads.get() > 1, 10_000)); + assertEquals(1, loads.get()); + } + finally { + allowFirstLoad.countDown(); + first.join(10_000); + second.join(10_000); + } + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + first.checkError(); + second.checkError(); + assertEquals(2, loads.get()); + } } diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output index fcae8c518d034..3827ef20dab58 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output @@ -490,6 +490,9 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). + Reload TLS key and trust stores on all server and client nodes: + control.(sh|bat) --ssl reload + By default commands affecting the cluster require interactive confirmation. Use --yes option to disable it. diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output index 301b5563e656f..ff7a4165b9de0 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output @@ -490,6 +490,9 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). + Reload TLS key and trust stores on all server and client nodes: + control.(sh|bat) --ssl reload + By default commands affecting the cluster require interactive confirmation. Use --yes option to disable it. From 27077f6522735c5ffa861aa18d46fdd1144323e2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 14:41:17 +0300 Subject: [PATCH 189/242] IGNITE-29210 Cover missing targets and clarify reload guidance --- docs/_docs/security/ssl-tls.adoc | 1 + docs/_docs/tools/control-script.adoc | 4 +- .../management/ssl/SslReloadTaskTest.java | 69 +++++++++++++++++++ .../ignite/testsuites/SecurityTestSuite.java | 2 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 236855c1b820f..2b8d92b05c26c 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -182,6 +182,7 @@ To restore the previous certificates, restore the backed-up files and reload aga Custom factories must extend `AbstractSslContextFactory` to support the command. Factories of other types are reported as unsupported. +For these factories, follow the same trust-first sequence and restart affected nodes one at a time instead of running reload. If you configured `trustManagers` directly, the command reuses them instead of reloading a trust store. == SslContextFactory Properties diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index ce9d8ed507f71..b18a4be0482d2 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -185,7 +185,9 @@ control.bat --ssl reload [--yes] -- The command requires `ADMIN_OPS` permission when security is enabled and asks for confirmation unless `--yes` is given. -Each node logs its reload results and, when security is enabled, the initiating user. +Each node logs its reload results. +With security enabled, logs identify the authenticated control-script user. +For JMX calls, they identify the node that accepts the call. The report lists results for each node and transport, including nodes without TLS. Factories that do not extend `AbstractSslContextFactory` are reported as unsupported. diff --git a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java new file mode 100644 index 0000000000000..0ea253ab85787 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java @@ -0,0 +1,69 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.management.ssl; + +import java.util.List; +import java.util.UUID; +import org.apache.ignite.GridTestJobResult; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.management.api.NoArg; +import org.apache.ignite.internal.management.ssl.SslReloadTask.SslReloadJobResult; +import org.apache.ignite.internal.visor.VisorTaskArgument; +import org.apache.ignite.testframework.GridTestNode; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +/** */ +public class SslReloadTaskTest { + /** */ + @Test + public void testReportsTargetThatLeftBeforeMapping() { + GridTestNode node = new GridTestNode(UUID.randomUUID()); + UUID departedId = UUID.randomUUID(); + SslReloadTask task = new SslReloadTask(); + + task.map(List.of(node), new VisorTaskArgument<>(List.of(node.id(), departedId), new NoArg(), false)); + + String nodeReport = node.id() + ": TLS is not enabled"; + GridTestJobResult res = new GridTestJobResult(new SslReloadJobResult(nodeReport, false), null, null, node, null); + + IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(res)).result()); + + assertEquals(nodeReport + '\n' + departedId + ": left the topology", err.getMessage()); + } + + /** */ + @Test + public void testJobFailureDoesNotExposeArgumentErrorCause() { + GridTestNode node = new GridTestNode(UUID.randomUUID()); + SslReloadTask task = new SslReloadTask(); + + task.map(List.of(node), new VisorTaskArgument<>(List.of(node.id()), new NoArg(), false)); + + IgniteException jobErr = new IgniteException(new IllegalArgumentException("Invalid source setting")); + GridTestJobResult res = new GridTestJobResult(null, jobErr, null, node, null); + + IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(res)).result()); + + assertEquals(node.id() + ": failed: " + jobErr.getMessage(), err.getMessage()); + assertNull(err.getCause()); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 22fec30c574b0..1b0b1851be622 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -18,6 +18,7 @@ package org.apache.ignite.testsuites; import org.apache.ignite.internal.client.thin.ClientSslUtilsTest; +import org.apache.ignite.internal.management.ssl.SslReloadTaskTest; import org.apache.ignite.internal.processors.security.IgniteSecurityProcessorTest; import org.apache.ignite.internal.processors.security.InvalidServerTest; import org.apache.ignite.internal.processors.security.NodeConnectionCertificateCapturingTest; @@ -147,6 +148,7 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextFactoryInitTest.class, + SslReloadTaskTest.class, SslContextReloadTest.class, ClientSslUtilsTest.class, MaintenanceModeNodeSecurityTest.class, From 542e17d3ac279b24998a9117c62f9706684382a3 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 14:48:44 +0300 Subject: [PATCH 190/242] IGNITE-29210 Clarify completed reload results --- docs/_docs/tools/control-script.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index b18a4be0482d2..08d5b1ff0f777 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -191,7 +191,8 @@ For JMX calls, they identify the node that accepts the call. The report lists results for each node and transport, including nodes without TLS. Factories that do not extend `AbstractSslContextFactory` are reported as unsupported. -The command continues after individual failures and exits with code `4` if a reload fails, is unsupported, or a target node leaves. +The command continues after individual failures and exits with code `4` if a reload fails, is unsupported, or a target node leaves before responding. +A node leaving after its response does not change that result. Successful replacements remain in effect; the command does not roll them back. == Activation, Deactivation and Topology Management From 4a9b887d1925016b0a8f4fe92808e876df1055a7 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 15:02:43 +0300 Subject: [PATCH 191/242] IGNITE-29210 Remove redundant test import --- .../commandline/SecurityCommandHandlerPermissionsTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java index fbc6a39360e48..d9f5a37b71415 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/SecurityCommandHandlerPermissionsTest.java @@ -79,7 +79,6 @@ import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.ALL_PERMISSIONS; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.NO_PERMISSIONS; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.systemPermissions; -import static org.apache.ignite.testframework.GridTestUtils.sslFactory; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; /** */ From 374c3edf8f302dbf6193a8bbf3ba8939bcc7649c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 15:57:27 +0300 Subject: [PATCH 192/242] IGNITE-29210 Register TLS factories in TCP SPIs --- .../internal/managers/communication/GridIoManager.java | 3 --- .../managers/discovery/GridDiscoveryManager.java | 4 ---- .../spi/communication/tcp/TcpCommunicationSpi.java | 3 +++ .../apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java | 3 +++ .../apache/ignite/testframework/junits/IgniteMock.java | 9 +++++++++ 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java b/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java index 93b7f4e6b7958..90fd123fc3310 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/managers/communication/GridIoManager.java @@ -418,9 +418,6 @@ public void resetMetrics() { startSpi(); - if ((CommunicationSpi)spi instanceof TcpCommunicationSpi && ctx.config().getSslContextFactory() != null) - ctx.ssl().register("communication", ctx.config().getSslContextFactory()); - MetricRegistry ioMetric = ctx.metric().registry(COMM_METRICS); ioMetric.register(OUTBOUND_MSG_QUEUE_CNT, spi::getOutboundMessagesQueueSize, diff --git a/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java b/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java index 05b48e1d9aab8..d8f9a4e428f49 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/managers/discovery/GridDiscoveryManager.java @@ -141,7 +141,6 @@ import org.apache.ignite.spi.discovery.DiscoverySpiNodeAuthenticator; import org.apache.ignite.spi.discovery.DiscoverySpiOrderSupport; import org.apache.ignite.spi.discovery.IgniteDiscoveryThread; -import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; import org.apache.ignite.spi.discovery.tcp.internal.TcpDiscoveryNode; import org.apache.ignite.spi.systemview.view.ClusterNodeView; import org.apache.ignite.spi.systemview.view.NodeAttributeView; @@ -1040,9 +1039,6 @@ private void waitForLastStateChangeEventFuture() { startSpi(); - if (spi instanceof TcpDiscoverySpi && ctx.config().getSslContextFactory() != null) - ctx.ssl().register("discovery", ctx.config().getSslContextFactory()); - registeredDiscoSpi = true; try { diff --git a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/TcpCommunicationSpi.java b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/TcpCommunicationSpi.java index b73d65497a8d8..f3040330420d4 100755 --- a/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/TcpCommunicationSpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/communication/tcp/TcpCommunicationSpi.java @@ -777,6 +777,9 @@ private void dumpInfo(StringBuilder sb, UUID dstNodeId) { conStateHnd.start(); + if (stateProvider.isSslEnabled()) + ignite.context().ssl().register("communication", ignite.configuration().getSslContextFactory()); + // Ack start. if (log.isDebugEnabled()) log.debug(startInfo()); diff --git a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java index de6d0ed66920c..a0d7fbdfcbe04 100644 --- a/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java +++ b/modules/core/src/main/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySpi.java @@ -2105,6 +2105,9 @@ protected void onExchange(DiscoveryDataPacket dataPacket, ClassLoader clsLdr) { registerMBean(igniteInstanceName, new TcpDiscoverySpiMBeanImpl(this), TcpDiscoverySpiMBean.class); impl.spiStart(igniteInstanceName); + + if (isSslEnabled()) + ignite.context().ssl().register("discovery", sslCtxFactory); } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java index 19e63c8a78595..799a70066eb6a 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java @@ -76,6 +76,7 @@ import org.apache.ignite.internal.processors.cache.IgniteInternalCache; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; import org.apache.ignite.internal.processors.cacheobject.NoOpBinary; +import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.tracing.configuration.NoopTracingConfigurationManager; import org.apache.ignite.internal.util.typedef.internal.CU; @@ -157,6 +158,14 @@ public IgniteMock(String name, String locHost, UUID nodeId, Marshaller marshalle try { kernalCtx = new StandaloneGridKernalContext(new GridTestLog4jLogger(), null) { + /** */ + private final GridSslProcessor ssl = new GridSslProcessor(this); + + /** {@inheritDoc} */ + @Override public GridSslProcessor ssl() { + return ssl; + } + @Override public GridInternalSubscriptionProcessor internalSubscriptionProcessor() { return new GridInternalSubscriptionProcessor(this); } From cd315d0781f4b97456eec826ad22cf1f7c35a2d0 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 16:14:34 +0300 Subject: [PATCH 193/242] IGNITE-29210 Use a plain SSL factory registry --- .../org/apache/ignite/internal/GridKernalContext.java | 6 +++--- .../apache/ignite/internal/GridKernalContextImpl.java | 11 +++++------ .../java/org/apache/ignite/internal/IgniteKernal.java | 2 -- .../wal/reader/StandaloneGridKernalContext.java | 4 ++-- ...{GridSslProcessor.java => SslFactoryRegistry.java} | 9 +-------- .../ignite/testframework/junits/IgniteMock.java | 6 +++--- 6 files changed, 14 insertions(+), 24 deletions(-) rename modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/{GridSslProcessor.java => SslFactoryRegistry.java} (83%) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java index 59eba599d570b..2fd7901e425d7 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java @@ -74,7 +74,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; -import org.apache.ignite.internal.processors.ssl.GridSslProcessor; +import org.apache.ignite.internal.processors.ssl.SslFactoryRegistry; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -299,8 +299,8 @@ public interface GridKernalContext extends Iterable { */ public GridPortProcessor ports(); - /** @return SSL processor. */ - public GridSslProcessor ssl(); + /** @return SSL factory registry. */ + public SslFactoryRegistry ssl(); /** * Gets schedule processor. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java index 1e28cd1a62fba..8718e51a2313c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContextImpl.java @@ -96,7 +96,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; -import org.apache.ignite.internal.processors.ssl.GridSslProcessor; +import org.apache.ignite.internal.processors.ssl.SslFactoryRegistry; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -265,7 +265,8 @@ public class GridKernalContextImpl implements GridKernalContext, Externalizable private GridPortProcessor portProc; /** */ - private GridSslProcessor sslProc; + @GridToStringExclude + private final SslFactoryRegistry sslFactories = new SslFactoryRegistry(); /** */ @GridToStringInclude @@ -542,8 +543,6 @@ else if (comp instanceof GridTaskSessionProcessor) sesProc = (GridTaskSessionProcessor)comp; else if (comp instanceof GridPortProcessor) portProc = (GridPortProcessor)comp; - else if (comp instanceof GridSslProcessor) - sslProc = (GridSslProcessor)comp; else if (comp instanceof GridClosureProcessor) closProc = (GridClosureProcessor)comp; else if (comp instanceof IgniteServiceProcessor) @@ -750,8 +749,8 @@ else if (!(comp instanceof DiscoveryNodeValidationProcessor } /** {@inheritDoc} */ - @Override public GridSslProcessor ssl() { - return sslProc; + @Override public SslFactoryRegistry ssl() { + return sslFactories; } /** {@inheritDoc} */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java index ddb426bca16e9..5d11403186f4e 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java @@ -165,7 +165,6 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; -import org.apache.ignite.internal.processors.ssl.GridSslProcessor; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -985,7 +984,6 @@ public void start( // Start some other processors (order & place is important). startProcessor(new GridPortProcessor(ctx)); - startProcessor(new GridSslProcessor(ctx)); startProcessor(new GridJobMetricsProcessor(ctx)); // Timeout processor needs to be started before managers, diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java index 07168ef84cf31..07950c553ad27 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/cache/persistence/wal/reader/StandaloneGridKernalContext.java @@ -98,7 +98,7 @@ import org.apache.ignite.internal.processors.segmentation.GridSegmentationProcessor; import org.apache.ignite.internal.processors.service.IgniteServiceProcessor; import org.apache.ignite.internal.processors.session.GridTaskSessionProcessor; -import org.apache.ignite.internal.processors.ssl.GridSslProcessor; +import org.apache.ignite.internal.processors.ssl.SslFactoryRegistry; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.task.GridTaskProcessor; import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor; @@ -445,7 +445,7 @@ private void setField(IgniteEx kernal, String name, Object val) throws NoSuchFie } /** {@inheritDoc} */ - @Override public GridSslProcessor ssl() { + @Override public SslFactoryRegistry ssl() { return null; } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java similarity index 83% rename from modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java rename to modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 6931c70beb002..e7a0cf129e15b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/GridSslProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -21,19 +21,12 @@ import java.util.Map; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import org.apache.ignite.internal.GridKernalContext; -import org.apache.ignite.internal.processors.GridProcessorAdapter; /** SSL factories used by running transports. */ -public class GridSslProcessor extends GridProcessorAdapter { +public class SslFactoryRegistry { /** */ private final Map> factories = new LinkedHashMap<>(); - /** @param ctx Kernal context. */ - public GridSslProcessor(GridKernalContext ctx) { - super(ctx); - } - /** * @param transport Transport name. * @param factory SSL context factory. diff --git a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java index 799a70066eb6a..6e75075e75ce0 100644 --- a/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java +++ b/modules/core/src/test/java/org/apache/ignite/testframework/junits/IgniteMock.java @@ -76,7 +76,7 @@ import org.apache.ignite.internal.processors.cache.IgniteInternalCache; import org.apache.ignite.internal.processors.cache.persistence.wal.reader.StandaloneGridKernalContext; import org.apache.ignite.internal.processors.cacheobject.NoOpBinary; -import org.apache.ignite.internal.processors.ssl.GridSslProcessor; +import org.apache.ignite.internal.processors.ssl.SslFactoryRegistry; import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor; import org.apache.ignite.internal.processors.tracing.configuration.NoopTracingConfigurationManager; import org.apache.ignite.internal.util.typedef.internal.CU; @@ -159,10 +159,10 @@ public IgniteMock(String name, String locHost, UUID nodeId, Marshaller marshalle try { kernalCtx = new StandaloneGridKernalContext(new GridTestLog4jLogger(), null) { /** */ - private final GridSslProcessor ssl = new GridSslProcessor(this); + private final SslFactoryRegistry ssl = new SslFactoryRegistry(); /** {@inheritDoc} */ - @Override public GridSslProcessor ssl() { + @Override public SslFactoryRegistry ssl() { return ssl; } From 2bf7dc1204b2257c4127c53377180f366813b2cd Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 17:42:59 +0300 Subject: [PATCH 194/242] IGNITE-29210 Add SSL reload result wire description --- ...al.management.ssl.SslReloadTask$SslReloadJobResult.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json diff --git a/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json b/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json new file mode 100644 index 0000000000000..fce9a3c5da5b7 --- /dev/null +++ b/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json @@ -0,0 +1,7 @@ +{ + "class": "org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult", + "fields": [ + {"order": 0, "type": "java.lang.String", "name": "report"}, + {"order": 1, "type": "boolean", "name": "failed"} + ] +} From 2beeb77d4e38910473bc0e152a0ae3110d619307 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 18:20:13 +0300 Subject: [PATCH 195/242] IGNITE-29210 Improve reload diagnostics and tests --- docs/_docs/security/ssl-tls.adoc | 4 ++- .../util/GridCommandHandlerSslReloadTest.java | 30 +++++----------- .../management/ssl/SslReloadCommand.java | 2 +- .../management/ssl/SslReloadTask.java | 8 +++-- .../management/ssl/SslReloadTaskTest.java | 34 ++++++++++++++++--- ...mmandHandlerClusterByClassTest_help.output | 2 +- ...ndlerClusterByClassWithSSLTest_help.output | 2 +- 7 files changed, 49 insertions(+), 33 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 2b8d92b05c26c..145d30bfc7126 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -177,10 +177,12 @@ The command asks for confirmation; use `--yes` to skip it. Established connections continue using their existing TLS context. A failed reload leaves that factory's previous context in use; successful reloads on other factories are not rolled back. +Check the affected node's log for error details. Correct the failed store and run the command again. To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. -Custom factories must extend `AbstractSslContextFactory` to support the command. +Custom factories must extend javadoc:org.apache.ignite.ssl.AbstractSslContextFactory[] and follow its reload contract. +They must reread key and trust data from their sources on reload and supply the updated context for new connections. Factories of other types are reported as unsupported. For these factories, follow the same trust-first sequence and restart affected nodes one at a time instead of running reload. If you configured `trustManagers` directly, the command reuses them instead of reloading a trust store. diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 1a88189597e6d..b12fe53e4421c 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -17,6 +17,7 @@ package org.apache.ignite.util; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; @@ -38,12 +39,13 @@ import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.ListeningTestLogger; +import org.apache.ignite.testframework.LogListener; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; -import static java.util.Arrays.asList; import static org.apache.ignite.internal.IgniteNodeAttributes.ATTR_REST_TCP_PORT; import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_OK; import static org.apache.ignite.internal.commandline.CommandHandler.EXIT_CODE_UNEXPECTED_ERROR; @@ -136,6 +138,7 @@ public void testReloadedFilesApplyToNewConnections() throws Exception { @Test public void testPartialFailureReportsEveryFactory() throws Exception { Path keys = stores.newFile("keys.jks").toPath(); + LogListener loadFailure = LogListener.matches(EOFException.class.getName()).build(); Files.copy(Path.of(keyStorePath("node01")), keys, REPLACE_EXISTING); @@ -145,12 +148,13 @@ public void testPartialFailureReportsEveryFactory() throws Exception { SSLContext clientCtx = working.create(); IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(failing); + cfg.setGridLogger(new ListeningTestLogger(log, loadFailure)); cfg.getClientConnectorConfiguration().setSslEnabled(true).setUseIgniteSslContextFactory(false).setSslContextFactory(working); cfg.getConnectorConfiguration().setSslEnabled(true).setSslFactory(() -> clientCtx); IgniteEx node = startGrid(cfg); - Files.delete(keys); + Files.write(keys, new byte[0]); assertEquals(EXIT_CODE_UNEXPECTED_ERROR, executeTlsReload()); @@ -160,6 +164,7 @@ public void testPartialFailureReportsEveryFactory() throws Exception { assertContains(log, report, nodeId + ": communication, discovery: failed:"); assertContains(log, report, nodeId + ": client connector: reloaded"); assertContains(log, report, nodeId + ": binary REST: unsupported:"); + assertTrue(loadFailure.check()); assertSame(original, failing.create()); assertNotSame(clientCtx, working.create()); } @@ -181,7 +186,7 @@ public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Excep assertEquals(EXIT_CODE_OK, execute("--ssl", "reload")); assertEquals(loads, factory.loads.get()); - assertContains(log, testOut.toString(), node.localNode().id() + ": TLS is not enabled"); + assertContains(log, testOut.toString(), node.localNode().id() + ": no TCP transports started with TLS"); } } @@ -210,24 +215,7 @@ public void testReportsNodeLeavingDuringReload() throws Exception { assertEquals(EXIT_CODE_UNEXPECTED_ERROR, exitCode); assertContains(log, testOut.toString(), departingId + ": left the topology"); - assertContains(log, testOut.toString(), node.localNode().id() + ": TLS is not enabled"); - } - - /** */ - @Test - public void testConcurrentCommandsReloadTheSameFactory() throws Exception { - CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); - - startGrid(getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(factory)); - - int loads = factory.loads.get(); - - GridTestUtils.runMultiThreaded(() -> { - assertEquals(EXIT_CODE_OK, - newCommandHandler(createTestLogger()).execute(asList("--ssl", "reload", "--yes"))); - }, 2, "ssl-reload"); - - assertEquals(loads + 2, factory.loads.get()); + assertContains(log, testOut.toString(), node.localNode().id() + ": no TCP transports started with TLS"); } /** */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java index 7ae3d250b123c..f2363a43ca7b4 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java @@ -27,7 +27,7 @@ public class SslReloadCommand implements ComputeCommand { /** {@inheritDoc} */ @Override public String description() { - return "Reload TLS key and trust stores on all server and client nodes"; + return "Reload TLS key and trust stores on all server and thick-client nodes"; } /** {@inheritDoc} */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index b9a91d4af93fe..1de6aefa6ade6 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -57,7 +57,7 @@ public class SslReloadTask extends VisorMultiNodeTask map0(List subgrid, VisorTaskArgument arg) { - targetNodes = new ArrayList<>(arg.getNodes()); + targetNodes = arg.getNodes(); Map jobs = super.map0(subgrid, arg); @@ -137,7 +137,7 @@ private SslReloadJob(NoArg arg, boolean debug) { } if (factories.isEmpty()) { - String report = nodeId + ": TLS is not enabled"; + String report = nodeId + ": no TCP transports started with TLS"; ignite.log().info("TLS reload: " + report + initiator); @@ -159,6 +159,7 @@ private SslReloadJob(NoArg arg, boolean debug) { String outcome; boolean factoryFailed = false; + Exception err = null; if (!(factory instanceof AbstractSslContextFactory)) { outcome = "unsupported: " + factory.getClass().getName(); @@ -173,6 +174,7 @@ private SslReloadJob(NoArg arg, boolean debug) { catch (Exception e) { outcome = "failed: " + errorMessage(e); factoryFailed = true; + err = e; } } @@ -182,7 +184,7 @@ private SslReloadJob(NoArg arg, boolean debug) { failed |= factoryFailed; if (factoryFailed) - ignite.log().warning("TLS reload: " + report + initiator); + ignite.log().warning("TLS reload: " + report + initiator, err); else ignite.log().info("TLS reload: " + report + initiator); } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java index 0ea253ab85787..235d601186137 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java @@ -21,15 +21,21 @@ import java.util.UUID; import org.apache.ignite.GridTestJobResult; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.cluster.IgniteClusterEx; import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.management.ssl.SslReloadTask.SslReloadJobResult; +import org.apache.ignite.internal.visor.VisorMultiNodeTask; import org.apache.ignite.internal.visor.VisorTaskArgument; +import org.apache.ignite.logger.NullLogger; import org.apache.ignite.testframework.GridTestNode; import org.junit.Test; +import static org.apache.ignite.testframework.GridTestUtils.setFieldValue; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** */ public class SslReloadTaskTest { @@ -42,7 +48,7 @@ public void testReportsTargetThatLeftBeforeMapping() { task.map(List.of(node), new VisorTaskArgument<>(List.of(node.id(), departedId), new NoArg(), false)); - String nodeReport = node.id() + ": TLS is not enabled"; + String nodeReport = node.id() + ": no TCP transports started with TLS"; GridTestJobResult res = new GridTestJobResult(new SslReloadJobResult(nodeReport, false), null, null, node, null); IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(res)).result()); @@ -52,18 +58,36 @@ public void testReportsTargetThatLeftBeforeMapping() { /** */ @Test - public void testJobFailureDoesNotExposeArgumentErrorCause() { + public void testReportsAllTargetsThatLeftBeforeMapping() { + GridTestNode replacement = new GridTestNode(UUID.randomUUID()); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + SslReloadTask task = new SslReloadTask(); + IgniteEx ignite = mock(IgniteEx.class); + + when(ignite.log()).thenReturn(NullLogger.INSTANCE); + when(ignite.cluster()).thenReturn(mock(IgniteClusterEx.class)); + setFieldValue(task, VisorMultiNodeTask.class, "ignite", ignite); + + IgniteException err = assertThrows(IgniteException.class, () -> task.map(List.of(replacement), + new VisorTaskArgument<>(List.of(first, second), new NoArg(), false))); + + assertEquals(first + ": left the topology\n" + second + ": left the topology", err.getMessage()); + } + + /** */ + @Test + public void testReportsJobFailure() { GridTestNode node = new GridTestNode(UUID.randomUUID()); SslReloadTask task = new SslReloadTask(); task.map(List.of(node), new VisorTaskArgument<>(List.of(node.id()), new NoArg(), false)); - IgniteException jobErr = new IgniteException(new IllegalArgumentException("Invalid source setting")); + IgniteException jobErr = new IgniteException("Job failed"); GridTestJobResult res = new GridTestJobResult(null, jobErr, null, node, null); IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(res)).result()); assertEquals(node.id() + ": failed: " + jobErr.getMessage(), err.getMessage()); - assertNull(err.getCause()); } } diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output index 3827ef20dab58..4a6557c3a84ba 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output @@ -490,7 +490,7 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). - Reload TLS key and trust stores on all server and client nodes: + Reload TLS key and trust stores on all server and thick-client nodes: control.(sh|bat) --ssl reload By default commands affecting the cluster require interactive confirmation. diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output index ff7a4165b9de0..5252500474fa9 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output @@ -490,7 +490,7 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). - Reload TLS key and trust stores on all server and client nodes: + Reload TLS key and trust stores on all server and thick-client nodes: control.(sh|bat) --ssl reload By default commands affecting the cluster require interactive confirmation. From 9f2c2d9099988b74bfacb0cbedd4eff8505be519 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 18:45:02 +0300 Subject: [PATCH 196/242] IGNITE-29210 Simplify reload command tests --- .../util/GridCommandHandlerSslReloadTest.java | 43 ++----------------- .../management/ssl/SslReloadCommand.java | 2 +- .../management/ssl/SslReloadTaskTest.java | 20 +++++++++ 3 files changed, 25 insertions(+), 40 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index b12fe53e4421c..26a7d84f34264 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -26,19 +26,13 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import org.apache.ignite.configuration.IgniteConfiguration; -import org.apache.ignite.internal.GridJobExecuteRequest; import org.apache.ignite.internal.IgniteEx; -import org.apache.ignite.internal.IgniteInternalFuture; -import org.apache.ignite.internal.TestRecordingCommunicationSpi; -import org.apache.ignite.internal.management.ssl.SslReloadTask; import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; import org.apache.ignite.ssl.SslContextFactory; -import org.apache.ignite.testframework.GridTestUtils; import org.apache.ignite.testframework.ListeningTestLogger; import org.apache.ignite.testframework.LogListener; import org.junit.Rule; @@ -72,11 +66,11 @@ public class GridCommandHandlerSslReloadTest extends GridCommandHandlerClusterPe /** */ @Test - public void testSharedFactoryReloadedOnceOnServersAndClients() throws Exception { + public void testSharedFactoryReloadedOnceOnServerAndClient() throws Exception { List nodes = new ArrayList<>(); List factories = new ArrayList<>(); - for (String name : new String[] {getTestIgniteInstanceName(0), getTestIgniteInstanceName(1), "client"}) { + for (String name : new String[] {getTestIgniteInstanceName(0), "client"}) { CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); IgniteConfiguration cfg = getConfiguration(name).setSslContextFactory(factory); @@ -144,7 +138,6 @@ public void testPartialFailureReportsEveryFactory() throws Exception { CountingSslContextFactory failing = new CountingSslContextFactory(keys.toString()); CountingSslContextFactory working = new CountingSslContextFactory(keyStorePath("node01")); - SSLContext original = failing.create(); SSLContext clientCtx = working.create(); IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(failing); @@ -165,7 +158,6 @@ public void testPartialFailureReportsEveryFactory() throws Exception { assertContains(log, report, nodeId + ": client connector: reloaded"); assertContains(log, report, nodeId + ": binary REST: unsupported:"); assertTrue(loadFailure.check()); - assertSame(original, failing.create()); assertNotSame(clientCtx, working.create()); } @@ -190,34 +182,6 @@ public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Excep } } - /** */ - @Test - public void testReportsNodeLeavingDuringReload() throws Exception { - IgniteEx node = startGrid(0); - UUID departingId = startGrid(1).localNode().id(); - TestRecordingCommunicationSpi comm = TestRecordingCommunicationSpi.spi(node); - - comm.blockMessages((dest, msg) -> dest.id().equals(departingId) && msg instanceof GridJobExecuteRequest - && ((GridJobExecuteRequest)msg).taskClassName().equals(SslReloadTask.class.getName())); - - IgniteInternalFuture cmd = GridTestUtils.runAsync(() -> execute("--ssl", "reload")); - int exitCode; - - try { - assertTrue(comm.waitForBlocked(1, 10_000)); - - stopGrid(1); - } - finally { - comm.stopBlock(); - exitCode = cmd.get(30_000); - } - - assertEquals(EXIT_CODE_UNEXPECTED_ERROR, exitCode); - assertContains(log, testOut.toString(), departingId + ": left the topology"); - assertContains(log, testOut.toString(), node.localNode().id() + ": no TCP transports started with TLS"); - } - /** */ @Test public void testDeclinedConfirmationDoesNotReload() throws Exception { @@ -233,7 +197,8 @@ public void testDeclinedConfirmationDoesNotReload() throws Exception { injectTestSystemIn("n"); assertEquals(EXIT_CODE_OK, execute("--ssl", "reload")); - assertContains(log, testOut.toString(), "Make sure that all nodes trust the new certificates before reloading."); + assertContains(log, testOut.toString(), + "Make sure that all nodes and client applications trust the new certificates before reloading."); assertEquals(loads, factory.loads.get()); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java index f2363a43ca7b4..5d27aeeb69065 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java @@ -48,7 +48,7 @@ public class SslReloadCommand implements ComputeCommand { /** {@inheritDoc} */ @Override public String confirmationPrompt(NoArg arg) { return "Warning: new connections will use the replaced key and trust stores. " - + "Make sure that all nodes trust the new certificates before reloading."; + + "Make sure that all nodes and client applications trust the new certificates before reloading."; } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java index 235d601186137..ed8f294ea5a8a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java @@ -21,6 +21,7 @@ import java.util.UUID; import org.apache.ignite.GridTestJobResult; import org.apache.ignite.IgniteException; +import org.apache.ignite.cluster.ClusterTopologyException; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.cluster.IgniteClusterEx; import org.apache.ignite.internal.management.api.NoArg; @@ -75,6 +76,25 @@ public void testReportsAllTargetsThatLeftBeforeMapping() { assertEquals(first + ": left the topology\n" + second + ": left the topology", err.getMessage()); } + /** */ + @Test + public void testReportsTargetThatLeftAfterMapping() { + GridTestNode node = new GridTestNode(UUID.randomUUID()); + GridTestNode departed = new GridTestNode(UUID.randomUUID()); + SslReloadTask task = new SslReloadTask(); + + task.map(List.of(node, departed), new VisorTaskArgument<>(List.of(node.id(), departed.id()), new NoArg(), false)); + + String nodeReport = node.id() + ": discovery: reloaded"; + GridTestJobResult nodeRes = new GridTestJobResult(new SslReloadJobResult(nodeReport, false), null, null, node, null); + GridTestJobResult departedRes = new GridTestJobResult(null, new ClusterTopologyException("Node has left grid"), + null, departed, null); + + IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(nodeRes, departedRes)).result()); + + assertEquals(nodeReport + '\n' + departed.id() + ": left the topology", err.getMessage()); + } + /** */ @Test public void testReportsJobFailure() { From 3588e69d390f68b8df8bd8ef6843f6584c03f55b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 19:32:02 +0300 Subject: [PATCH 197/242] IGNITE-29210 Clarify HTTP REST reload scope --- docs/_docs/security/ssl-tls.adoc | 2 +- docs/_docs/tools/control-script.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 145d30bfc7126..f43118f9492b5 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -160,7 +160,7 @@ include::{javaFile}[tag=disable-validation,indent=0] You can replace certificate files and reload them without restarting nodes that use `SslContextFactory`. The link:tools/control-script#reloading-tls-certificates[`--ssl reload` command] updates TCP transports on all server and thick-client nodes. -It does not reload HTTPS or external client applications. +It does not reload TLS for the HTTP REST endpoint (Jetty) or external client applications. . Back up the key and trust store files before replacing them. Keep their configured paths, store types, and passwords unchanged; changing node configuration requires a restart. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 08d5b1ff0f777..b33373e9270e4 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -165,7 +165,7 @@ Enable it in the configuration (`IgniteConfiguration#setClientConnectorConfigura Use `--ssl reload` to load replaced key and trust stores on all server and thick-client nodes in the current topology. It reloads discovery, communication, client connector, and binary REST transports that started with TLS. New connections use the replacement context; established connections stay open. -HTTPS is outside the command's scope. +The command does not reload TLS for the HTTP REST endpoint (Jetty). Follow link:security/ssl-tls#upgrading-certificates[Upgrading Certificates] to prepare peer trust, replace the files, and verify the result. Add the <> required by your cluster: From fa21b1ba40cb844c4d42b392cbfdf048280f6748 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 19:43:11 +0300 Subject: [PATCH 198/242] IGNITE-29210 Clarify reload documentation --- docs/_docs/security/ssl-tls.adoc | 3 ++- docs/_docs/tools/control-script.adoc | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index f43118f9492b5..06578096c2838 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -176,7 +176,8 @@ The command asks for confirmation; use `--yes` to skip it. . Check the result for every node and transport, then verify a new connection with the replacement certificates. Established connections continue using their existing TLS context. -A failed reload leaves that factory's previous context in use; successful reloads on other factories are not rolled back. +If a factory fails to reload, it keeps its previous context. +Successful reloads on other factories remain in effect. Check the affected node's log for error details. Correct the failed store and run the command again. To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index b33373e9270e4..47e4d2006aa71 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -186,14 +186,16 @@ control.bat --ssl reload [--yes] The command requires `ADMIN_OPS` permission when security is enabled and asks for confirmation unless `--yes` is given. Each node logs its reload results. -With security enabled, logs identify the authenticated control-script user. -For JMX calls, they identify the node that accepts the call. +When security is enabled, logs identify the initiator by login and subject ID. +This is the control-script user, or the node receiving a JMX call. -The report lists results for each node and transport, including nodes without TLS. +The report lists every target node, even if none of its TCP transports use TLS. +It shows reload results for TCP transports that started with TLS. Factories that do not extend `AbstractSslContextFactory` are reported as unsupported. -The command continues after individual failures and exits with code `4` if a reload fails, is unsupported, or a target node leaves before responding. -A node leaving after its response does not change that result. -Successful replacements remain in effect; the command does not roll them back. +The command continues after individual failures. +It exits with code `4` if any reload fails, any factory is unsupported, or a target node leaves before responding. +A result received from a node stays in the report even if the node leaves later. +Successful reloads remain in effect even if others fail. == Activation, Deactivation and Topology Management From 2faa3bb5f3a7c7117466fe914455074775bbf127 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 19:51:25 +0300 Subject: [PATCH 199/242] IGNITE-29210 Keep factory Javadoc focused on behavior --- .../java/org/apache/ignite/ssl/AbstractSslContextFactory.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index fe2ea18353a50..001962c313df9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -31,8 +31,6 @@ /** * Base class for SSL context factories that cache their {@link SSLContext}. - * Overrides of {@link #create()} must obtain the context from {@code super.create()} on each call to support reload. - * Key and trust manager loading hooks must read fresh material on each call. */ public abstract class AbstractSslContextFactory implements Factory { /** */ From 11dcd399cf8301b01ee276a93f7c46189c531883 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 20:05:32 +0300 Subject: [PATCH 200/242] IGNITE-29210 Drop unrelated trust manager Javadoc change --- .../src/main/java/org/apache/ignite/ssl/SslContextFactory.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 407215a3ddea2..5db5c511c7b1e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -241,7 +241,6 @@ public TrustManager[] getTrustManagers() { /** * Sets pre-configured trust managers. This is an optional parameter, * however one of the {@link #setTrustStoreFilePath(String)}, {@code #setTrustManagers(TrustManager[])} - * must be set. Supplied managers are reused by {@link #init()}. * * @param trustMgrs Pre-configured trust managers. */ From 0cb438a6ca0f2c594781bd2347cfa8e7787a3348 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 20:20:18 +0300 Subject: [PATCH 201/242] IGNITE-29210 Remove redundant comments --- .../java/org/apache/ignite/internal/GridKernalContext.java | 2 +- .../apache/ignite/internal/management/ssl/SslReloadTask.java | 1 - .../ignite/internal/processors/ssl/SslFactoryRegistry.java | 5 +---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java index 2fd7901e425d7..734222d4e8983 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/GridKernalContext.java @@ -299,7 +299,7 @@ public interface GridKernalContext extends Iterable { */ public GridPortProcessor ports(); - /** @return SSL factory registry. */ + /** */ public SslFactoryRegistry ssl(); /** diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index 1de6aefa6ade6..bbc5345709171 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -208,7 +208,6 @@ public static class SslReloadJobResult extends IgniteDataTransferObject { /** */ public SslReloadJobResult() { - // No-op. } /** */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index e7a0cf129e15b..824f031ada22e 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -27,10 +27,7 @@ public class SslFactoryRegistry { /** */ private final Map> factories = new LinkedHashMap<>(); - /** - * @param transport Transport name. - * @param factory SSL context factory. - */ + /** */ public synchronized void register(String transport, Factory factory) { factories.put(transport, factory); } From bcc52e5d809689e1039146da2a320938be006548 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 9 Nov 2026 21:07:02 +0300 Subject: [PATCH 202/242] IGNITE-29210 Test TLS reload when a node leaves before replying --- .../util/GridCommandHandlerSslReloadTest.java | 40 +++++++++++++++++++ .../management/ssl/SslReloadTaskTest.java | 20 ---------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 26a7d84f34264..2de19edda1e56 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -26,11 +26,15 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.GridJobExecuteResponse; import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.TestRecordingCommunicationSpi; import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.ListeningTestLogger; @@ -46,7 +50,9 @@ import static org.apache.ignite.testframework.GridTestUtils.assertContains; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assume.assumeTrue; @@ -182,6 +188,40 @@ public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Excep } } + /** */ + @Test + public void testReportsNodeLeavingBeforeReloadResponse() throws Exception { + IgniteEx first = startGrid(getConfiguration(getTestIgniteInstanceName(0)) + .setSslContextFactory(sslTrustedFactory("node01", "trustone"))); + IgniteEx second = startGrid(getConfiguration(getTestIgniteInstanceName(1)) + .setSslContextFactory(sslTrustedFactory("node01", "trustone"))); + TestRecordingCommunicationSpi firstSpi = TestRecordingCommunicationSpi.spi(first); + TestRecordingCommunicationSpi secondSpi = TestRecordingCommunicationSpi.spi(second); + + firstSpi.blockMessages((dest, msg) -> msg instanceof GridJobExecuteResponse); + secondSpi.blockMessages((dest, msg) -> msg instanceof GridJobExecuteResponse); + + IgniteInternalFuture cmd = runAsync(() -> execute("--ssl", "reload")); + + try { + assertTrue(waitForCondition(() -> firstSpi.hasBlockedMessages() || secondSpi.hasBlockedMessages(), 10_000)); + + IgniteEx departing = firstSpi.hasBlockedMessages() ? first : second; + IgniteEx remaining = departing == first ? second : first; + UUID departingId = departing.localNode().id(); + + departing.close(); + + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, cmd.get(30_000).intValue()); + assertContains(log, testOut.toString(), departingId + ": left the topology"); + assertContains(log, testOut.toString(), remaining.localNode().id() + ": communication, discovery: reloaded"); + } + finally { + firstSpi.stopBlock(false); + secondSpi.stopBlock(false); + } + } + /** */ @Test public void testDeclinedConfirmationDoesNotReload() throws Exception { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java index ed8f294ea5a8a..235d601186137 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java @@ -21,7 +21,6 @@ import java.util.UUID; import org.apache.ignite.GridTestJobResult; import org.apache.ignite.IgniteException; -import org.apache.ignite.cluster.ClusterTopologyException; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.cluster.IgniteClusterEx; import org.apache.ignite.internal.management.api.NoArg; @@ -76,25 +75,6 @@ public void testReportsAllTargetsThatLeftBeforeMapping() { assertEquals(first + ": left the topology\n" + second + ": left the topology", err.getMessage()); } - /** */ - @Test - public void testReportsTargetThatLeftAfterMapping() { - GridTestNode node = new GridTestNode(UUID.randomUUID()); - GridTestNode departed = new GridTestNode(UUID.randomUUID()); - SslReloadTask task = new SslReloadTask(); - - task.map(List.of(node, departed), new VisorTaskArgument<>(List.of(node.id(), departed.id()), new NoArg(), false)); - - String nodeReport = node.id() + ": discovery: reloaded"; - GridTestJobResult nodeRes = new GridTestJobResult(new SslReloadJobResult(nodeReport, false), null, null, node, null); - GridTestJobResult departedRes = new GridTestJobResult(null, new ClusterTopologyException("Node has left grid"), - null, departed, null); - - IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(nodeRes, departedRes)).result()); - - assertEquals(nodeReport + '\n' + departed.id() + ": left the topology", err.getMessage()); - } - /** */ @Test public void testReportsJobFailure() { From f979ddeb6079e59ad842ffa60bfa2403597eaec7 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 10 Nov 2026 18:43:55 +0300 Subject: [PATCH 203/242] IGNITE-29210 Simplify node departure test and reload documentation --- docs/_docs/security/ssl-tls.adoc | 2 +- .../util/GridCommandHandlerSslReloadTest.java | 34 ++++++------------- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 06578096c2838..55107bed04bbc 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -182,7 +182,7 @@ Check the affected node's log for error details. Correct the failed store and run the command again. To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. -Custom factories must extend javadoc:org.apache.ignite.ssl.AbstractSslContextFactory[] and follow its reload contract. +Custom factories must extend javadoc:org.apache.ignite.ssl.AbstractSslContextFactory[]. They must reread key and trust data from their sources on reload and supply the updated context for new connections. Factories of other types are reported as unsupported. For these factories, follow the same trust-first sequence and restart affected nodes one at a time instead of running reload. diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 2de19edda1e56..575a1853cff8b 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -52,7 +52,6 @@ import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; -import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assume.assumeTrue; @@ -191,35 +190,22 @@ public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Excep /** */ @Test public void testReportsNodeLeavingBeforeReloadResponse() throws Exception { - IgniteEx first = startGrid(getConfiguration(getTestIgniteInstanceName(0)) - .setSslContextFactory(sslTrustedFactory("node01", "trustone"))); - IgniteEx second = startGrid(getConfiguration(getTestIgniteInstanceName(1)) - .setSslContextFactory(sslTrustedFactory("node01", "trustone"))); - TestRecordingCommunicationSpi firstSpi = TestRecordingCommunicationSpi.spi(first); - TestRecordingCommunicationSpi secondSpi = TestRecordingCommunicationSpi.spi(second); + IgniteEx node = startGrid(0); + IgniteEx departing = startGrid(1); + UUID departingId = departing.localNode().id(); + TestRecordingCommunicationSpi departingSpi = TestRecordingCommunicationSpi.spi(departing); - firstSpi.blockMessages((dest, msg) -> msg instanceof GridJobExecuteResponse); - secondSpi.blockMessages((dest, msg) -> msg instanceof GridJobExecuteResponse); + departingSpi.blockMessages((dest, msg) -> msg instanceof GridJobExecuteResponse); IgniteInternalFuture cmd = runAsync(() -> execute("--ssl", "reload")); - try { - assertTrue(waitForCondition(() -> firstSpi.hasBlockedMessages() || secondSpi.hasBlockedMessages(), 10_000)); + assertTrue(departingSpi.waitForBlocked(1, 10_000)); - IgniteEx departing = firstSpi.hasBlockedMessages() ? first : second; - IgniteEx remaining = departing == first ? second : first; - UUID departingId = departing.localNode().id(); + departing.close(); - departing.close(); - - assertEquals(EXIT_CODE_UNEXPECTED_ERROR, cmd.get(30_000).intValue()); - assertContains(log, testOut.toString(), departingId + ": left the topology"); - assertContains(log, testOut.toString(), remaining.localNode().id() + ": communication, discovery: reloaded"); - } - finally { - firstSpi.stopBlock(false); - secondSpi.stopBlock(false); - } + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, cmd.get(30_000).intValue()); + assertContains(log, testOut.toString(), departingId + ": left the topology"); + assertContains(log, testOut.toString(), node.localNode().id() + ": no TCP transports started with TLS"); } /** */ From 405b249a2b0351b11f41f356f65650d364cb2d8d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 10 Nov 2026 19:06:58 +0300 Subject: [PATCH 204/242] IGNITE-29210 Separate unsupported factory and reload failure tests --- .../util/GridCommandHandlerSslReloadTest.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 575a1853cff8b..392ef572d5a67 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -135,7 +135,7 @@ public void testReloadedFilesApplyToNewConnections() throws Exception { /** */ @Test - public void testPartialFailureReportsEveryFactory() throws Exception { + public void testReloadsOtherFactoriesAfterFailure() throws Exception { Path keys = stores.newFile("keys.jks").toPath(); LogListener loadFailure = LogListener.matches(EOFException.class.getName()).build(); @@ -148,7 +148,6 @@ public void testPartialFailureReportsEveryFactory() throws Exception { cfg.setGridLogger(new ListeningTestLogger(log, loadFailure)); cfg.getClientConnectorConfiguration().setSslEnabled(true).setUseIgniteSslContextFactory(false).setSslContextFactory(working); - cfg.getConnectorConfiguration().setSslEnabled(true).setSslFactory(() -> clientCtx); IgniteEx node = startGrid(cfg); @@ -161,11 +160,24 @@ public void testPartialFailureReportsEveryFactory() throws Exception { assertContains(log, report, nodeId + ": communication, discovery: failed:"); assertContains(log, report, nodeId + ": client connector: reloaded"); - assertContains(log, report, nodeId + ": binary REST: unsupported:"); assertTrue(loadFailure.check()); assertNotSame(clientCtx, working.create()); } + /** */ + @Test + public void testReportsUnsupportedFactory() throws Exception { + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)); + + cfg.getConnectorConfiguration().setSslEnabled(true).setSslFactory(() -> ctx); + + IgniteEx node = startGrid(cfg); + + assertEquals(EXIT_CODE_UNEXPECTED_ERROR, execute("--ssl", "reload")); + assertContains(log, testOut.toString(), node.localNode().id() + ": binary REST: unsupported:"); + } + /** */ @Test public void testDoesNotReloadRestFactoryWhenListenerFailedToStart() throws Exception { From 86035cebcca56557c1e018497ac424ee3c57161f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 10 Nov 2026 20:33:27 +0300 Subject: [PATCH 205/242] IGNITE-29210 Coordinate initial SSL loading with reload --- .../ignite/ssl/AbstractSslContextFactory.java | 18 ++- .../ignite/ssl/SslContextFactoryInitTest.java | 118 ++++++++++++++---- 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 001962c313df9..15306f1d2b916 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -195,18 +195,14 @@ public final synchronized void init() { @Override public SSLContext create() { SSLContext ctx = sslCtx.get(); - if (ctx == null) { - try { - ctx = createSslContext(); + if (ctx != null) + return ctx; - if (!sslCtx.compareAndSet(null, ctx)) - ctx = sslCtx.get(); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } + synchronized (this) { + if (sslCtx.get() == null) + init(); - return ctx; + return sslCtx.get(); + } } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index 2b8fdcf5c33e0..fd4ad8456e5c8 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -70,46 +70,83 @@ public void testFailedInitKeepsContext() { /** */ @Test - public void testConcurrentInitLoadsOneKeyStoreAtATime() throws Exception { - CountDownLatch firstLoadStarted = new CountDownLatch(1); - CountDownLatch allowFirstLoad = new CountDownLatch(1); - AtomicInteger loads = new AtomicInteger(); + public void testConcurrentCreateLoadsKeyStoreOnce() throws Exception { + BlockingSslContextFactory factory = new BlockingSslContextFactory(); + GridTestThread first = new GridTestThread(factory::create); + GridTestThread second = new GridTestThread(factory::create); - SslContextFactory factory = new SslContextFactory() { - /** */ - private static final long serialVersionUID = 0; + first.start(); - /** {@inheritDoc} */ - @Override protected InputStream openFileInputStream(String path) throws IOException { - if (loads.incrementAndGet() == 1) { - firstLoadStarted.countDown(); + try { + assertTrue(factory.firstLoadStarted.await(10, SECONDS)); - U.awaitQuiet(allowFirstLoad); - } + second.start(); - return super.openFileInputStream(path); - } - }; + assertTrue(waitForCondition(() -> second.getState() == BLOCKED || factory.loads.get() > 1, 10_000)); + assertEquals(1, factory.loads.get()); + } + finally { + factory.allowFirstLoad.countDown(); + first.join(10_000); + second.join(10_000); + } - factory.setKeyStoreFilePath(keyStorePath("node01")); - factory.setKeyStorePassword(keyStorePassword().toCharArray()); - factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + first.checkError(); + second.checkError(); + assertEquals(1, factory.loads.get()); + } + + /** */ + @Test + public void testInitWaitsForInitialContextLoad() throws Exception { + BlockingSslContextFactory factory = new BlockingSslContextFactory(); + GridTestThread first = new GridTestThread(factory::create); + GridTestThread second = new GridTestThread(factory::init); + + first.start(); + try { + assertTrue(factory.firstLoadStarted.await(10, SECONDS)); + + second.start(); + + assertTrue(waitForCondition(() -> second.getState() == BLOCKED || factory.loads.get() > 1, 10_000)); + assertEquals(1, factory.loads.get()); + } + finally { + factory.allowFirstLoad.countDown(); + first.join(10_000); + second.join(10_000); + } + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + first.checkError(); + second.checkError(); + assertEquals(2, factory.loads.get()); + } + + /** */ + @Test + public void testConcurrentInitLoadsOneKeyStoreAtATime() throws Exception { + BlockingSslContextFactory factory = new BlockingSslContextFactory(); GridTestThread first = new GridTestThread(factory::init); GridTestThread second = new GridTestThread(factory::init); first.start(); try { - assertTrue(firstLoadStarted.await(10, SECONDS)); + assertTrue(factory.firstLoadStarted.await(10, SECONDS)); second.start(); - assertTrue(waitForCondition(() -> second.getState() == BLOCKED || loads.get() > 1, 10_000)); - assertEquals(1, loads.get()); + assertTrue(waitForCondition(() -> second.getState() == BLOCKED || factory.loads.get() > 1, 10_000)); + assertEquals(1, factory.loads.get()); } finally { - allowFirstLoad.countDown(); + factory.allowFirstLoad.countDown(); first.join(10_000); second.join(10_000); } @@ -118,6 +155,39 @@ public void testConcurrentInitLoadsOneKeyStoreAtATime() throws Exception { assertFalse(second.isAlive()); first.checkError(); second.checkError(); - assertEquals(2, loads.get()); + assertEquals(2, factory.loads.get()); + } + + /** */ + private static class BlockingSslContextFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0; + + /** */ + private final CountDownLatch firstLoadStarted = new CountDownLatch(1); + + /** */ + private final CountDownLatch allowFirstLoad = new CountDownLatch(1); + + /** */ + private final AtomicInteger loads = new AtomicInteger(); + + /** */ + private BlockingSslContextFactory() { + setKeyStoreFilePath(keyStorePath("node01")); + setKeyStorePassword(keyStorePassword().toCharArray()); + setTrustManagers(getDisabledTrustManager()); + } + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + if (loads.incrementAndGet() == 1) { + firstLoadStarted.countDown(); + + U.awaitQuiet(allowFirstLoad); + } + + return super.openFileInputStream(path); + } } } From eeb4aa5016861552f39907b37df1549e383ea494 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 13:32:20 +0300 Subject: [PATCH 206/242] IGNITE-29210 Stop node TLS reload on first failure --- docs/_docs/security/ssl-tls.adoc | 8 +- docs/_docs/tools/control-script.adoc | 9 +- .../CommandHandlerParsingTest.java | 4 +- .../util/GridCommandHandlerSslReloadTest.java | 17 ++-- .../management/ssl/SslReloadCommand.java | 2 +- .../management/ssl/SslReloadTask.java | 90 ++++++------------- .../processors/ssl/SslFactoryRegistry.java | 3 +- .../management/ssl/SslReloadTaskTest.java | 3 +- .../ssl/SslFactoryRegistryTest.java | 39 ++++++++ .../ignite/testsuites/SecurityTestSuite.java | 2 + ...mmandHandlerClusterByClassTest_help.output | 2 +- ...ndlerClusterByClassWithSSLTest_help.output | 2 +- ....ssl.SslReloadTask$SslReloadJobResult.json | 7 -- 13 files changed, 94 insertions(+), 94 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java delete mode 100644 modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 55107bed04bbc..9ede647e3ed99 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -173,18 +173,18 @@ Continue only after every affected peer has loaded the updated trust store. . Replace each key store with the new certificate and its private key, then run `control.sh --ssl reload` again. Use the link:tools/control-script#connecting-to-cluster[connection parameters] required by your cluster. The command asks for confirmation; use `--yes` to skip it. -. Check the result for every node and transport, then verify a new connection with the replacement certificates. +. Check the command result and the reload logs of any failed nodes, then verify a new connection with the replacement certificates. Established connections continue using their existing TLS context. -If a factory fails to reload, it keeps its previous context. -Successful reloads on other factories remain in effect. +If a factory fails to reload, it keeps its previous context, and that node stops reloading its remaining factories. +Successful reloads remain in effect; other nodes continue independently. Check the affected node's log for error details. Correct the failed store and run the command again. To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. Custom factories must extend javadoc:org.apache.ignite.ssl.AbstractSslContextFactory[]. They must reread key and trust data from their sources on reload and supply the updated context for new connections. -Factories of other types are reported as unsupported. +If any factory on a node has another type, the command reports it as unsupported and reloads nothing on that node. For these factories, follow the same trust-first sequence and restart affected nodes one at a time instead of running reload. If you configured `trustManagers` directly, the command reuses them instead of reloading a trust store. diff --git a/docs/_docs/tools/control-script.adoc b/docs/_docs/tools/control-script.adoc index 47e4d2006aa71..54fff9048bea8 100644 --- a/docs/_docs/tools/control-script.adoc +++ b/docs/_docs/tools/control-script.adoc @@ -190,12 +190,13 @@ When security is enabled, logs identify the initiator by login and subject ID. This is the control-script user, or the node receiving a JMX call. The report lists every target node, even if none of its TCP transports use TLS. -It shows reload results for TCP transports that started with TLS. -Factories that do not extend `AbstractSslContextFactory` are reported as unsupported. -The command continues after individual failures. +Each node first checks that all registered factories extend `AbstractSslContextFactory`. +If any factory is unsupported, that node reloads none of its factories. +Otherwise, it reloads them until the first failure and reports the error. +Other nodes continue independently. It exits with code `4` if any reload fails, any factory is unsupported, or a target node leaves before responding. A result received from a node stays in the report even if the node leaves later. -Successful reloads remain in effect even if others fail. +Successful reloads remain in effect even if a later reload fails; their results are recorded in the node logs. == Activation, Deactivation and Topology Management diff --git a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java index 9ed84b06acd5a..c170f8acc1fd5 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/internal/commandline/CommandHandlerParsingTest.java @@ -86,6 +86,7 @@ import org.apache.ignite.internal.management.snapshot.SnapshotCommand; import org.apache.ignite.internal.management.snapshot.SnapshotDeleteCommand; import org.apache.ignite.internal.management.snapshot.SnapshotRestoreCommand; +import org.apache.ignite.internal.management.ssl.SslCommand; import org.apache.ignite.internal.management.tx.TxCommand; import org.apache.ignite.internal.management.tx.TxCommandArg; import org.apache.ignite.internal.management.tx.TxSortOrder; @@ -1278,6 +1279,7 @@ private boolean requireArgs(Class cmd) { cmd == ConsistencyCommand.class || cmd == CdcCommand.class || cmd == IoTestCommand.class || - cmd == EventCommand.class; + cmd == EventCommand.class || + cmd == SslCommand.class; } } diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 392ef572d5a67..81809a7fc91c5 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -135,7 +135,7 @@ public void testReloadedFilesApplyToNewConnections() throws Exception { /** */ @Test - public void testReloadsOtherFactoriesAfterFailure() throws Exception { + public void testStopsReloadingNodeAfterFailure() throws Exception { Path keys = stores.newFile("keys.jks").toPath(); LogListener loadFailure = LogListener.matches(EOFException.class.getName()).build(); @@ -158,24 +158,25 @@ public void testReloadsOtherFactoriesAfterFailure() throws Exception { String report = testOut.toString(); String nodeId = node.localNode().id().toString(); - assertContains(log, report, nodeId + ": communication, discovery: failed:"); - assertContains(log, report, nodeId + ": client connector: reloaded"); + assertSame(clientCtx, working.create()); + assertContains(log, report, nodeId + ": failed: communication, discovery:"); assertTrue(loadFailure.check()); - assertNotSame(clientCtx, working.create()); } /** */ @Test - public void testReportsUnsupportedFactory() throws Exception { - SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); - IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)); + public void testUnsupportedFactoryPreventsNodeReload() throws Exception { + CountingSslContextFactory factory = new CountingSslContextFactory(keyStorePath("node01")); + SSLContext ctx = factory.create(); + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(factory); cfg.getConnectorConfiguration().setSslEnabled(true).setSslFactory(() -> ctx); IgniteEx node = startGrid(cfg); assertEquals(EXIT_CODE_UNEXPECTED_ERROR, execute("--ssl", "reload")); - assertContains(log, testOut.toString(), node.localNode().id() + ": binary REST: unsupported:"); + assertSame(ctx, factory.create()); + assertContains(log, testOut.toString(), node.localNode().id() + ": failed: binary REST: unsupported:"); } /** */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java index 5d27aeeb69065..d3d750a0b9678 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadCommand.java @@ -27,7 +27,7 @@ public class SslReloadCommand implements ComputeCommand { /** {@inheritDoc} */ @Override public String description() { - return "Reload TLS key and trust stores on all server and thick-client nodes"; + return "Reload TLS key and trust stores on all nodes"; } /** {@inheritDoc} */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index bbc5345709171..1731563527170 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -33,8 +33,6 @@ import org.apache.ignite.cluster.ClusterTopologyException; import org.apache.ignite.compute.ComputeJob; import org.apache.ignite.compute.ComputeJobResult; -import org.apache.ignite.internal.Order; -import org.apache.ignite.internal.dto.IgniteDataTransferObject; import org.apache.ignite.internal.management.api.NoArg; import org.apache.ignite.internal.processors.task.GridInternal; import org.apache.ignite.internal.util.typedef.X; @@ -48,7 +46,7 @@ /** */ @GridInternal -public class SslReloadTask extends VisorMultiNodeTask { +public class SslReloadTask extends VisorMultiNodeTask { /** */ private static final long serialVersionUID = 0; @@ -68,7 +66,7 @@ public class SslReloadTask extends VisorMultiNodeTask job(NoArg arg) { + @Override protected VisorJob job(NoArg arg) { return new SslReloadJob(arg, debug); } @@ -93,12 +91,8 @@ else if (res.getException() != null) { reports.add(id + ": failed: " + errorMessage(res.getException())); failed = true; } - else { - SslReloadJobResult nodeRes = res.getData(); - - reports.add(nodeRes.report); - failed |= nodeRes.failed; - } + else + reports.add(res.getData()); } String report = String.join("\n", reports); @@ -115,7 +109,7 @@ private static String errorMessage(Throwable err) { } /** */ - private static class SslReloadJob extends VisorJob { + private static class SslReloadJob extends VisorJob { /** */ private static final long serialVersionUID = 0; @@ -125,7 +119,7 @@ private SslReloadJob(NoArg arg, boolean debug) { } /** {@inheritDoc} */ - @Override protected SslReloadJobResult run(NoArg arg) { + @Override protected String run(NoArg arg) { Map> factories = ignite.context().ssl().factories(); UUID nodeId = ignite.localNode().id(); String initiator = ""; @@ -141,12 +135,21 @@ private SslReloadJob(NoArg arg, boolean debug) { ignite.log().info("TLS reload: " + report + initiator); - return new SslReloadJobResult(report, false); + return report; + } + + for (Map.Entry> entry : factories.entrySet()) { + if (!(entry.getValue() instanceof AbstractSslContextFactory)) { + String report = entry.getKey() + ": unsupported: " + entry.getValue().getClass().getName(); + + ignite.log().warning("TLS reload: " + nodeId + ": " + report + initiator); + + throw new IgniteException(report); + } } Set> processed = Collections.newSetFromMap(new IdentityHashMap<>()); List reports = new ArrayList<>(); - boolean failed = false; for (Factory factory : factories.values()) { if (!processed.add(factory)) @@ -157,63 +160,22 @@ private SslReloadJob(NoArg arg, boolean debug) { .map(Map.Entry::getKey) .collect(joining(", ")); - String outcome; - boolean factoryFailed = false; - Exception err = null; - - if (!(factory instanceof AbstractSslContextFactory)) { - outcome = "unsupported: " + factory.getClass().getName(); - factoryFailed = true; + try { + ((AbstractSslContextFactory)factory).init(); } - else { - try { - ((AbstractSslContextFactory)factory).init(); - - outcome = "reloaded"; - } - catch (Exception e) { - outcome = "failed: " + errorMessage(e); - factoryFailed = true; - err = e; - } + catch (Exception e) { + ignite.log().warning("TLS reload: " + nodeId + ": " + transports + ": failed" + initiator, e); + + throw new IgniteException(transports + ": " + errorMessage(e), e); } - String report = nodeId + ": " + transports + ": " + outcome; + String report = nodeId + ": " + transports + ": reloaded"; reports.add(report); - failed |= factoryFailed; - - if (factoryFailed) - ignite.log().warning("TLS reload: " + report + initiator, err); - else - ignite.log().info("TLS reload: " + report + initiator); + ignite.log().info("TLS reload: " + report + initiator); } - return new SslReloadJobResult(String.join("\n", reports), failed); - } - } - - /** */ - public static class SslReloadJobResult extends IgniteDataTransferObject { - /** */ - private static final long serialVersionUID = 0; - - /** */ - @Order(0) - String report; - - /** */ - @Order(1) - boolean failed; - - /** */ - public SslReloadJobResult() { - } - - /** */ - public SslReloadJobResult(String report, boolean failed) { - this.report = report; - this.failed = failed; + return String.join("\n", reports); } } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 824f031ada22e..ae3db72a6640c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -29,7 +29,8 @@ public class SslFactoryRegistry { /** */ public synchronized void register(String transport, Factory factory) { - factories.put(transport, factory); + if (factories.putIfAbsent(transport, factory) != null) + throw new IllegalStateException("SSL factory is already registered [transport=" + transport + ']'); } /** @return Snapshot of active transport factories. */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java index 235d601186137..3908d717721c8 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/management/ssl/SslReloadTaskTest.java @@ -24,7 +24,6 @@ import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.cluster.IgniteClusterEx; import org.apache.ignite.internal.management.api.NoArg; -import org.apache.ignite.internal.management.ssl.SslReloadTask.SslReloadJobResult; import org.apache.ignite.internal.visor.VisorMultiNodeTask; import org.apache.ignite.internal.visor.VisorTaskArgument; import org.apache.ignite.logger.NullLogger; @@ -49,7 +48,7 @@ public void testReportsTargetThatLeftBeforeMapping() { task.map(List.of(node), new VisorTaskArgument<>(List.of(node.id(), departedId), new NoArg(), false)); String nodeReport = node.id() + ": no TCP transports started with TLS"; - GridTestJobResult res = new GridTestJobResult(new SslReloadJobResult(nodeReport, false), null, null, node, null); + GridTestJobResult res = new GridTestJobResult(nodeReport, null, null, node, null); IgniteException err = assertThrows(IgniteException.class, () -> task.reduce(List.of(res)).result()); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java new file mode 100644 index 0000000000000..fe517fe19f12a --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -0,0 +1,39 @@ +/* + * 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. + */ + +package org.apache.ignite.internal.processors.ssl; + +import org.apache.ignite.ssl.SslContextFactory; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +/** */ +public class SslFactoryRegistryTest { + /** */ + @Test + public void testDuplicateRegistrationKeepsOriginalFactory() { + SslFactoryRegistry registry = new SslFactoryRegistry(); + SslContextFactory factory = new SslContextFactory(); + + registry.register("communication", factory); + + assertThrows(IllegalStateException.class, () -> registry.register("communication", new SslContextFactory())); + assertSame(factory, registry.factories().get("communication")); + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 1b0b1851be622..7b207d1226a0e 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -76,6 +76,7 @@ import org.apache.ignite.internal.processors.security.service.ServiceAuthorizationTest; import org.apache.ignite.internal.processors.security.service.ServiceStaticConfigTest; import org.apache.ignite.internal.processors.security.snapshot.SnapshotPermissionCheckTest; +import org.apache.ignite.internal.processors.ssl.SslFactoryRegistryTest; import org.apache.ignite.internal.thread.context.ComputeTaskOperationContextPropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributePropagationTest; import org.apache.ignite.internal.thread.context.OperationContextAttributesTest; @@ -148,6 +149,7 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextFactoryInitTest.class, + SslFactoryRegistryTest.class, SslReloadTaskTest.class, SslContextReloadTest.class, ClientSslUtilsTest.class, diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output index 4a6557c3a84ba..91fead434ad99 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassTest_help.output @@ -490,7 +490,7 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). - Reload TLS key and trust stores on all server and thick-client nodes: + Reload TLS key and trust stores on all nodes: control.(sh|bat) --ssl reload By default commands affecting the cluster require interactive confirmation. diff --git a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output index 5252500474fa9..158f8d3b5f4f0 100644 --- a/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output +++ b/modules/core/src/test/resources/org.apache.ignite.util/GridCommandHandlerClusterByClassWithSSLTest_help.output @@ -490,7 +490,7 @@ If the file name isn't specified the output file name is: '.bin': --interval interval - Interval between samples (millis, 10 to 60000, 100 by default). --payload-size payload_size - Payload size (bytes, max 64 KiB, 100 by default). - Reload TLS key and trust stores on all server and thick-client nodes: + Reload TLS key and trust stores on all nodes: control.(sh|bat) --ssl reload By default commands affecting the cluster require interactive confirmation. diff --git a/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json b/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json deleted file mode 100644 index fce9a3c5da5b7..0000000000000 --- a/modules/wire-compatibility-check/wire/idto/org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "class": "org.apache.ignite.internal.management.ssl.SslReloadTask$SslReloadJobResult", - "fields": [ - {"order": 0, "type": "java.lang.String", "name": "report"}, - {"order": 1, "type": "boolean", "name": "failed"} - ] -} From 1c59af4d74cb3ca0cd8869263fc73cd63c8a70af Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 14:38:01 +0300 Subject: [PATCH 207/242] IGNITE-29210 Test cached context access during reload --- .../ignite/ssl/SslContextFactoryInitTest.java | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java index fd4ad8456e5c8..1e58daa79a330 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryInitTest.java @@ -31,6 +31,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.keyStorePassword; import static org.apache.ignite.testframework.GridTestUtils.keyStorePath; +import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.sslTrustedFactory; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertArrayEquals; @@ -130,32 +131,27 @@ public void testInitWaitsForInitialContextLoad() throws Exception { /** */ @Test - public void testConcurrentInitLoadsOneKeyStoreAtATime() throws Exception { + public void testCreateDoesNotWaitForReload() throws Exception { BlockingSslContextFactory factory = new BlockingSslContextFactory(); - GridTestThread first = new GridTestThread(factory::init); - GridTestThread second = new GridTestThread(factory::init); + SSLContext ctx = sslTrustedFactory("node01", "trustone").create(); - first.start(); + factory.sslCtx.set(ctx); - try { - assertTrue(factory.firstLoadStarted.await(10, SECONDS)); + GridTestThread reload = new GridTestThread(factory::init); - second.start(); + reload.start(); - assertTrue(waitForCondition(() -> second.getState() == BLOCKED || factory.loads.get() > 1, 10_000)); - assertEquals(1, factory.loads.get()); + try { + assertTrue(factory.firstLoadStarted.await(10, SECONDS)); + assertSame(ctx, runAsync(factory::create).get(10_000)); } finally { factory.allowFirstLoad.countDown(); - first.join(10_000); - second.join(10_000); + reload.join(10_000); } - assertFalse(first.isAlive()); - assertFalse(second.isAlive()); - first.checkError(); - second.checkError(); - assertEquals(2, factory.loads.get()); + assertFalse(reload.isAlive()); + reload.checkError(); } /** */ From 21b7baa79cb3c5b3f3a8f3dce791a19380e4d617 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 16:54:53 +0300 Subject: [PATCH 208/242] IGNITE-29211 Renew TLS certificates before expiry --- .../_docs/monitoring-metrics/new-metrics.adoc | 13 + docs/_docs/security/ssl-tls.adoc | 42 ++ .../ignite/ssl/AbstractSslContextFactory.java | 2 +- .../apache/ignite/ssl/SslContextFactory.java | 154 +++++++ modules/core/pom.xml | 7 + .../apache/ignite/internal/IgniteKernal.java | 4 + .../management/ssl/SslReloadTask.java | 5 +- .../processors/ssl/SslFactoryRegistry.java | 245 ++++++++++ .../ssl/SslFactoryRegistryTest.java | 346 +++++++++++++- .../ignite/ssl/SslCertificateRenewalTest.java | 154 +++++++ .../ssl/SslContextFactoryRenewalTest.java | 435 ++++++++++++++++++ .../ignite/ssl/TestCertificateAuthority.java | 122 +++++ .../ignite/testsuites/SecurityTestSuite.java | 4 + 13 files changed, 1528 insertions(+), 5 deletions(-) create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java create mode 100644 modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 249bbc8f120f1..7ca470c1fdac5 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -432,6 +432,19 @@ Register name: `communication.tcp.{node_consistent_id}` |sentMessagesToNode | long | Total number of messages sent by current node to the given node. |=== +== Certificate Renewal + +Register name: `ssl` + +[cols="2,1,3",opts="header"] +|=== +|Name | Type | Description +|ConsecutiveRenewalFailures | long | Sum of consecutive automatic renewal failures across the node's SSL factories. Zero when renewal is disabled. +|=== + +A successful renewal, manual reload or skipped attempt clears the corresponding factory's count. +See link:security/ssl-tls#automatic-certificate-renewal[Automatic Certificate Renewal] for configuration and recovery. + == Communication Connection Pool Register name: `communication.tcp.connectionPool` diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 9ede647e3ed99..a644f94c9ba72 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -188,6 +188,44 @@ If any factory on a node has another type, the command reports it as unsupported For these factories, follow the same trust-first sequence and restart affected nodes one at a time instead of running reload. If you configured `trustManagers` directly, the command reuses them instead of reloading a trust store. +== Automatic Certificate Renewal + +To load replacement certificates automatically, enable renewal on each `SslContextFactory` before starting the node: + +[source,java] +---- +factory.setRenewalEnabled(true); +---- + +Changing this setting requires a node restart. +Renewal covers the same TCP transports as the reload command; it does not cover the HTTP REST endpoint or external client applications. +Ignite loads certificates from the configured source; it does not issue them. +Arrange for replacement stores to become available there, and follow the trust-first sequence above when changing certificate authorities. + +The factory reads renewal dates from the key store it actually loads. +It selects the key chain that expires first and starts renewal with 15% of its lifetime remaining. +This lifetime runs from the key certificate's validity start to the earliest expiry in its chain, including issuing certificates. +For chains with the same expiry, it uses the longest lifetime. +Startup fails if renewal is enabled and the store has no private key with an X.509 certificate chain. + +Every certificate in an automatic replacement's key chains must be within its validity dates, and the earliest expiry must move later. +Otherwise, Ignite keeps the current context and retries, even after its certificates expire. +The retry delay starts at one minute, doubles to one hour, and includes a random addition of up to half that delay. +Established connections remain open; new connections use an accepted replacement. + +Check the node log for the next attempt, affected transports and any load or validation errors. +The `ssl.ConsecutiveRenewalFailures` metric adds up consecutive automatic failures across the node's factories. +A successful renewal or manual reload clears that factory's count. +A skipped attempt also clears it, for example when another node sharing the factory has already renewed it. + +If renewal fails, correct the replacement store and run `control.sh --ssl reload` to apply it immediately. +Manual reload requires a key chain but allows unchanged expiry dates, so it can apply a trust-store change alone. +After a successful manual reload, Ignite recalculates the next attempt from the loaded certificates. + +Custom sources can extend javadoc:org.apache.ignite.ssl.SslContextFactory[] and override `loadKeyStore()` or `openFileInputStream()`. +Limit the duration of source requests: a blocked load delays manual reload and can delay node shutdown. +Factories extending only `AbstractSslContextFactory` support manual reload but do not participate in automatic renewal. + == SslContextFactory Properties `SslContextFactory` supports the following properties: @@ -216,6 +254,10 @@ If you configured `trustManagers` directly, the command reuses them instead of r |The protocol for secure transport. https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#sslcontext-algorithms[Supported algorithms,window=_blank]. |`TLS` +|`renewalEnabled` +|Enables automatic certificate renewal. See <>. +|`false` + |`trustStoreFilePath` |The path to the trust store file. |`N/A` diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 15306f1d2b916..6babf0267f3d9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -113,7 +113,7 @@ public void setProtocols(String... protocols) { * @return Initialized SSL context. * @throws SSLException If SSL context could not be created. */ - private SSLContext createSslContext() throws SSLException { + SSLContext createSslContext() throws SSLException { checkParameters(); KeyManager[] keyMgrs = createKeyManagers(); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 5db5c511c7b1e..feac8945781ed 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -24,11 +24,16 @@ import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; +import java.util.Date; +import java.util.Enumeration; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -61,6 +66,9 @@ public class SslContextFactory extends AbstractSslContextFactory { /** Default key manager / trust manager algorithm. Specifying different trust manager algorithm is not supported. */ public static final String DFLT_KEY_ALGORITHM = System.getProperty("ssl.KeyManagerFactory.algorithm", "SunX509"); + /** Fraction of a certificate lifetime reserved for renewal. */ + private static final double RENEWAL_WINDOW_FRACTION = 0.15; + /** Key manager algorithm. */ protected String keyAlgorithm = DFLT_KEY_ALGORITHM; @@ -85,6 +93,88 @@ public class SslContextFactory extends AbstractSslContextFactory { /** Trust managers. */ protected TrustManager[] trustMgrs; + /** */ + private boolean renewalEnabled; + + /** */ + private transient long renewalTime; + + /** */ + private transient long expirationTime; + + /** */ + private transient boolean renewing; + + /** + * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. + */ + public boolean isRenewalEnabled() { + return renewalEnabled; + } + + /** + * @param enabled Whether automatic certificate renewal is enabled. Changes require a node restart. + */ + public void setRenewalEnabled(boolean enabled) { + renewalEnabled = enabled; + } + + /** + * The window covers the last 15% of the earliest-expiring key chain's lifetime. + * Its lifetime runs from the leaf's {@code notBefore} to the earliest {@code notAfter} in the chain. + * Chains with the same expiry use the longest lifetime. + * + * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} when disabled or not initialized. + */ + public synchronized long getRenewalTime() { + return renewalEnabled ? renewalTime : 0; + } + + /** + * Reloads certificates when their renewal window has started. Disabled renewal and a future window are skipped. + * Replacement certificates must be within their validity dates and have a later earliest expiry; + * otherwise the current context is kept. + * + * @throws org.apache.ignite.IgniteException If replacement certificates cannot be loaded or accepted. + */ + public synchronized void renew() { + if (!renewalEnabled || renewalTime == 0 || renewalTime > System.currentTimeMillis()) + return; + + renewing = true; + + try { + init(); + } + finally { + renewing = false; + } + } + + /** {@inheritDoc} */ + @Override SSLContext createSslContext() throws SSLException { + long prevRenewalTime = renewalTime; + long prevExpirationTime = expirationTime; + boolean initialized = false; + + try { + SSLContext ctx = super.createSslContext(); + + if (renewing && expirationTime < System.currentTimeMillis()) + throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + expirationTime + ']'); + + initialized = true; + + return ctx; + } + finally { + if (!initialized) { + renewalTime = prevRenewalTime; + expirationTime = prevExpirationTime; + } + } + } + /** * Gets key store type used for context creation. * @@ -264,6 +354,9 @@ public static TrustManager getDisabledTrustManager() { KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + if (renewalEnabled) + readCertificateDates(keyStore); + keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); @@ -277,6 +370,67 @@ public static TrustManager getDisabledTrustManager() { } } + /** + * @param store Loaded key store. + * @throws GeneralSecurityException If a key or certificate cannot be read. + * @throws SSLException If the certificate chains cannot be used for renewal. + */ + private void readCertificateDates(KeyStore store) throws GeneralSecurityException, SSLException { + long earliestExpiry = Long.MAX_VALUE; + long selectedStart = Long.MAX_VALUE; + Date now = new Date(); + + for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { + String alias = aliases.nextElement(); + + if (!store.isKeyEntry(alias) || !(store.getKey(alias, keyStorePwd) instanceof PrivateKey)) + continue; + + Certificate[] chain = store.getCertificateChain(alias); + + if (chain == null || chain.length == 0) + throw new SSLException("Certificate chain is unavailable [alias=" + alias + "]. Remove the key or add its X.509 chain."); + + long chainExpiry = Long.MAX_VALUE; + + for (Certificate cert : chain) { + if (!(cert instanceof X509Certificate)) + throw new SSLException("Certificate chain is not X.509 [alias=" + alias + "]. Remove the key or add its X.509 chain."); + + X509Certificate x509 = (X509Certificate)cert; + + if (renewing) { + try { + x509.checkValidity(now); + } + catch (CertificateException e) { + throw new SSLException("Certificate is not currently valid [alias=" + alias + ", subject=" + + x509.getSubjectX500Principal() + ']', e); + } + } + + chainExpiry = Math.min(chainExpiry, x509.getNotAfter().getTime()); + } + + long chainStart = ((X509Certificate)chain[0]).getNotBefore().getTime(); + + if (chainExpiry < earliestExpiry || chainExpiry == earliestExpiry && chainStart < selectedStart) { + earliestExpiry = chainExpiry; + selectedStart = chainStart; + } + } + + if (earliestExpiry == Long.MAX_VALUE) + throw new SSLException("Certificate renewal requires a private key with an X.509 certificate chain."); + + if (renewing && earliestExpiry <= expirationTime) + throw new SSLException("Replacement certificates must have a later earliest expiry [current=" + expirationTime + + ", replacement=" + earliestExpiry + ']'); + + expirationTime = earliestExpiry; + renewalTime = earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION); + } + /** {@inheritDoc} */ @Override protected final TrustManager[] createTrustManagers() throws SSLException { if (trustMgrs != null) diff --git a/modules/core/pom.xml b/modules/core/pom.xml index bbdf058dea191..47fd6cc45de8b 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -158,6 +158,13 @@ test + + org.bouncycastle + bcpkix-jdk18on + 1.86 + test + + org.eclipse.jetty.ee11 jetty-ee11-servlets diff --git a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java index 5d11403186f4e..177c23e680e65 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/IgniteKernal.java @@ -1243,6 +1243,8 @@ public void start( } } + ctx.ssl().start(ctx); + // Start plugins. for (PluginProvider provider : ctx.plugins().allProviders()) provider.onIgniteStart(); @@ -1722,6 +1724,8 @@ private void stop0(boolean cancel) { GridKernalGateway gw = this.gw.get(); if (stopGuard.compareAndSet(false, true)) { + ctx.ssl().stop(); + // Only one thread is allowed to perform stop sequence. boolean firstStop = false; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index 1731563527170..b1ff1843edc50 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -161,7 +161,10 @@ private SslReloadJob(NoArg arg, boolean debug) { .collect(joining(", ")); try { - ((AbstractSslContextFactory)factory).init(); + synchronized (factory) { + ((AbstractSslContextFactory)factory).init(); + ignite.context().ssl().onReload(factory); + } } catch (Exception e) { ignite.log().warning("TLS reload: " + nodeId + ": " + transports + ": failed" + initiator, e); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index ae3db72a6640c..fb6251db62747 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -17,16 +17,47 @@ package org.apache.ignite.internal.processors.ssl; +import java.util.Date; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicLong; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteLogger; +import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.ssl.SslContextFactory; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.stream.Collectors.joining; +import static org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor; /** SSL factories used by running transports. */ public class SslFactoryRegistry { /** */ private final Map> factories = new LinkedHashMap<>(); + /** */ + private final Map, Renewal> renewals = new IdentityHashMap<>(); + + /** */ + private volatile ScheduledExecutorService executor; + + /** */ + private GridKernalContext ctx; + + /** */ + private IgniteLogger log; + + /** */ + private final AtomicLong consecutiveFailures = new AtomicLong(); + /** */ public synchronized void register(String transport, Factory factory) { if (factories.putIfAbsent(transport, factory) != null) @@ -37,4 +68,218 @@ public synchronized void register(String transport, Factory factory) public synchronized Map> factories() { return new LinkedHashMap<>(factories); } + + /** */ + public synchronized void start(GridKernalContext ctx) { + this.ctx = ctx; + log = ctx.log(getClass()); + ctx.metric().registry("ssl").register("ConsecutiveRenewalFailures", consecutiveFailures::get, + "Sum of consecutive automatic certificate renewal failures across SSL factories."); + + Map> factories = factories(); + + for (Factory factory : factories.values()) { + if (factory instanceof SslContextFactory && ((SslContextFactory)factory).isRenewalEnabled() && !renewals.containsKey(factory)) { + synchronized (factory) { + if (ctx.isStopping()) + return; + + if (((SslContextFactory)factory).getRenewalTime() == 0) + ((SslContextFactory)factory).init(); + } + + String transports = factories.entrySet().stream() + .filter(entry -> entry.getValue() == factory) + .map(Map.Entry::getKey) + .collect(joining(", ")); + + renewals.put(factory, new Renewal((SslContextFactory)factory, transports)); + } + } + + if (renewals.isEmpty() || ctx.isStopping()) + return; + + executor = newSingleThreadScheduledExecutor("ssl-renewal", ctx.igniteInstanceName()); + executor.execute(() -> renewals.values().forEach(renewal -> renewal.schedule(renewal.factory.getRenewalTime()))); + } + + /** */ + public synchronized void stop() { + ScheduledExecutorService executor = this.executor; + + if (executor == null) + return; + + for (Runnable task : executor.shutdownNow()) { + if (task instanceof Future) + ((Future)task).cancel(false); + } + + boolean interrupted = false; + + while (!executor.isTerminated()) { + try { + executor.awaitTermination(Long.MAX_VALUE, MILLISECONDS); + } + catch (InterruptedException ignored) { + interrupted = true; + } + } + + if (interrupted) + Thread.currentThread().interrupt(); + } + + /** */ + public void onReload(Factory factory) { + ScheduledExecutorService executor = this.executor; + + if (executor == null || stopping()) + return; + + Renewal renewal = renewals.get(factory); + + if (renewal == null) + return; + + assert Thread.holdsLock(factory); + + renewal.manualReloadPending = true; + + try { + executor.execute(renewal::onReload); + } + catch (RejectedExecutionException e) { + if (!stopping()) + throw e; + } + } + + /** */ + private boolean stopping() { + return ctx.isStopping() || executor.isShutdown(); + } + + /** */ + private class Renewal implements Runnable { + /** */ + private final SslContextFactory factory; + + /** */ + private final String transports; + + /** */ + private ScheduledFuture pending; + + /** */ + private int failures; + + /** Guarded by the factory monitor. */ + private boolean manualReloadPending; + + /** */ + private Renewal(SslContextFactory factory, String transports) { + this.factory = factory; + this.transports = transports; + } + + /** {@inheritDoc} */ + @Override public void run() { + if (stopping()) + return; + + boolean manuallyReloaded; + Exception failure = null; + long nextAttempt = 0; + + synchronized (factory) { + if (stopping()) + return; + + manuallyReloaded = manualReloadPending; + manualReloadPending = false; + + try { + factory.renew(); + nextAttempt = factory.getRenewalTime(); + } + catch (Exception e) { + failure = e; + } + } + + if (stopping()) + return; + + if (manuallyReloaded || failure == null) + resetFailures(); + + if (failure != null) { + failures++; + consecutiveFailures.incrementAndGet(); + + long delay = Math.min(MINUTES.toMillis(1) << Math.min(failures - 1, 6), MINUTES.toMillis(60)); + + nextAttempt = System.currentTimeMillis() + delay + ThreadLocalRandom.current().nextLong(delay / 2 + 1); + + log.warning("TLS renewal failed [transports=" + transports + ", nextAttempt=" + new Date(nextAttempt) + ']', failure); + } + + schedule(nextAttempt); + } + + /** */ + private void onReload() { + if (stopping()) + return; + + long nextAttempt; + + synchronized (factory) { + if (stopping() || !manualReloadPending) + return; + + manualReloadPending = false; + nextAttempt = factory.getRenewalTime(); + } + + if (stopping()) + return; + + resetFailures(); + schedule(nextAttempt); + } + + /** */ + private void resetFailures() { + consecutiveFailures.addAndGet(-failures); + failures = 0; + } + + /** */ + private void schedule(long time) { + if (stopping()) + return; + + if (pending != null) + pending.cancel(false); + + if (time == 0) + return; + + try { + pending = executor.schedule(this, Math.max(0, time - System.currentTimeMillis()), MILLISECONDS); + } + catch (RejectedExecutionException e) { + if (!stopping()) + throw e; + + return; + } + + if (log.isInfoEnabled()) + log.info("TLS renewal scheduled [transports=" + transports + ", nextAttempt=" + new Date(time) + ']'); + } + } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index fe517fe19f12a..744a0413f9c2c 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,23 +17,363 @@ package org.apache.ignite.internal.processors.ssl; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.processors.metric.GridMetricManager; +import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; +import org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor; +import org.apache.ignite.logger.NullLogger; +import org.apache.ignite.spi.metric.LongMetric; import org.apache.ignite.ssl.SslContextFactory; +import org.junit.After; +import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import static java.util.Collections.emptyList; +import static java.util.concurrent.TimeUnit.HOURS; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.apache.ignite.testframework.GridTestUtils.runAsync; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** */ public class SslFactoryRegistryTest { + /** */ + private final SslFactoryRegistry registry = new SslFactoryRegistry(); + + /** */ + private final SslContextFactory factory = mock(SslContextFactory.class); + + /** */ + private final GridKernalContext ctx = mock(GridKernalContext.class); + + /** */ + private final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + + /** */ + private final MetricRegistryImpl metrics = new MetricRegistryImpl("ssl", null, null, null, new NullLogger()); + + /** */ + private MockedStatic executorFactory; + + /** */ + private Runnable scheduled; + + /** */ + private ScheduledFuture pending; + + /** */ + private long delay; + + /** */ + @Before + public void setUp() { + GridMetricManager metricMgr = mock(GridMetricManager.class); + + when(ctx.metric()).thenReturn(metricMgr); + when(metricMgr.registry("ssl")).thenReturn(metrics); + when(ctx.log(SslFactoryRegistry.class)).thenReturn(new NullLogger()); + when(factory.isRenewalEnabled()).thenReturn(true); + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); + when(executor.shutdownNow()).thenReturn(emptyList()); + when(executor.isTerminated()).thenReturn(true); + + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + + return null; + }).when(executor).execute(any(Runnable.class)); + + when(executor.schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS))).thenAnswer(call -> { + scheduled = call.getArgument(0); + delay = call.getArgument(1); + + pending = mock(ScheduledFuture.class); + + return pending; + }); + + executorFactory = mockStatic(IgniteScheduledThreadPoolExecutor.class); + executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) + .thenReturn(executor); + } + + /** */ + @After + public void tearDown() { + registry.stop(); + executorFactory.close(); + } + /** */ @Test public void testDuplicateRegistrationKeepsOriginalFactory() { - SslFactoryRegistry registry = new SslFactoryRegistry(); - SslContextFactory factory = new SslContextFactory(); - registry.register("communication", factory); assertThrows(IllegalStateException.class, () -> registry.register("communication", new SslContextFactory())); assertSame(factory, registry.factories().get("communication")); } + + /** */ + @Test + public void testDisabledFactoryDoesNotStartRenewal() { + when(factory.isRenewalEnabled()).thenReturn(false); + + registry.register("communication", factory); + registry.start(ctx); + + executorFactory.verifyNoInteractions(); + assertEquals(0, failures()); + } + + /** */ + @Test + public void testSharedFactoryIsScheduledOnce() { + registry.register("communication", factory); + registry.register("discovery", factory); + registry.start(ctx); + + verify(executor).schedule(any(Runnable.class), eq(0L), eq(MILLISECONDS)); + } + + /** */ + @Test + public void testEnablingRenewalOnRestartInitializesSharedFactoryOnce() { + when(factory.getRenewalTime()).thenReturn(0L, System.currentTimeMillis() + HOURS.toMillis(2)); + + registry.register("communication", factory); + registry.register("discovery", factory); + registry.start(ctx); + + verify(factory).init(); + assertTrue(delay > HOURS.toMillis(1)); + } + + /** */ + @Test + public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { + when(factory.getRenewalTime()).thenReturn(0L); + doThrow(new IgniteException("Certificate chain is unavailable")).when(factory).init(); + + registry.register("communication", factory); + + assertThrows(IgniteException.class, () -> registry.start(ctx)); + executorFactory.verifyNoInteractions(); + } + + /** */ + @Test + public void testFailuresIncreaseRetryDelayUntilLimit() { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + + for (int failure = 1; failure <= 8; failure++) { + long before = System.currentTimeMillis(); + + scheduled.run(); + + long base = MINUTES.toMillis(Math.min(1L << (failure - 1), 60)); + + assertEquals(failure, failures()); + assertTrue(delay >= base - (System.currentTimeMillis() - before)); + assertTrue(delay <= base + base / 2); + } + } + + /** */ + @Test + public void testNormalReturnResetsFailuresAndReadsNextWindow() { + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + assertEquals(1, failures()); + + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + + scheduled.run(); + + assertEquals(0, failures()); + assertTrue(delay > HOURS.toMillis(1)); + } + + /** */ + @Test + public void testManualReloadReplacesRetryAndResetsFailures() { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + assertEquals(1, failures()); + + ScheduledFuture retry = pending; + + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + + synchronized (factory) { + registry.onReload(factory); + } + + verify(retry).cancel(false); + assertEquals(0, failures()); + assertTrue(delay > HOURS.toMillis(1)); + } + + /** */ + @Test + public void testMetricKeepsOtherFactoryFailuresAfterReload() { + SslContextFactory otherFactory = mock(SslContextFactory.class); + + when(otherFactory.isRenewalEnabled()).thenReturn(true); + when(otherFactory.getRenewalTime()).thenReturn(1L); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(); + + registry.register("communication", factory); + registry.register("client connector", otherFactory); + registry.start(ctx); + + ArgumentCaptor attempts = ArgumentCaptor.forClass(Runnable.class); + + verify(executor, times(2)).schedule(attempts.capture(), anyLong(), eq(MILLISECONDS)); + attempts.getAllValues().forEach(Runnable::run); + + assertEquals(2, failures()); + + synchronized (factory) { + registry.onReload(factory); + } + + assertEquals(1, failures()); + } + + /** */ + @Test + public void testMetricResetDoesNotCorruptFailureCount() { + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + metrics.reset(); + + assertEquals(1, failures()); + + scheduled.run(); + + assertEquals(0, failures()); + } + + /** */ + @Test + public void testQueuedManualResetKeepsFailureAfterManualReload() { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + Queue notifications = new ArrayDeque<>(); + + doAnswer(call -> notifications.add(call.getArgument(0))).when(executor).execute(any(Runnable.class)); + + synchronized (factory) { + registry.onReload(factory); + } + + scheduled.run(); + notifications.remove().run(); + + assertEquals(1, failures()); + verify(executor, times(3)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + } + + /** */ + @Test + public void testStoppingNodeDoesNotStartRenewal() { + when(ctx.isStopping()).thenReturn(true); + + registry.register("communication", factory); + registry.start(ctx); + + executorFactory.verifyNoInteractions(); + } + + /** */ + @Test + public void testStoppingWhileWaitingForFactorySkipsRenewal() throws Exception { + registry.register("communication", factory); + registry.start(ctx); + + AtomicReference renewalThread = new AtomicReference<>(); + IgniteInternalFuture attempt; + + synchronized (factory) { + attempt = runAsync(() -> { + renewalThread.set(Thread.currentThread()); + scheduled.run(); + }); + + assertTrue(waitForCondition( + () -> renewalThread.get() != null && renewalThread.get().getState() == Thread.State.BLOCKED, 5_000)); + + when(ctx.isStopping()).thenReturn(true); + } + + attempt.get(5_000); + + verify(factory, never()).renew(); + assertEquals(0, failures()); + } + + /** */ + @Test + public void testStoppingDuringLoadDoesNotCountFailure() { + doAnswer(call -> { + when(ctx.isStopping()).thenReturn(true); + + throw new IgniteException("Load interrupted by node stop"); + }).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + assertEquals(0, failures()); + verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + } + + /** */ + private long failures() { + return metrics.findMetric("ConsecutiveRenewalFailures").value(); + } } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java new file mode 100644 index 0000000000000..f37958e9f9a73 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java @@ -0,0 +1,154 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.SSLContext; +import org.apache.ignite.Ignition; +import org.apache.ignite.client.ClientRetryNonePolicy; +import org.apache.ignite.client.IgniteClient; +import org.apache.ignite.client.SslMode; +import org.apache.ignite.configuration.ClientConfiguration; +import org.apache.ignite.configuration.ClientConnectorConfiguration; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; +import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static java.util.concurrent.TimeUnit.DAYS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; +import static org.apache.ignite.ssl.TestCertificateAuthority.save; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; + +/** */ +public class SslCertificateRenewalTest extends GridCommonAbstractTest { + /** */ + @Test + public void testRenewalUpdatesNewConnectionsAndPreservesEstablishedConnection() throws Exception { + long now = System.currentTimeMillis(); + TestCertificateAuthority ca1 = new TestCertificateAuthority("ca1", now - DAYS.toMillis(1), now + DAYS.toMillis(10)); + TestCertificateAuthority ca2 = new TestCertificateAuthority("ca2", now - DAYS.toMillis(1), now + DAYS.toMillis(10)); + Path dir = Files.createTempDirectory("ssl-renewal"); + Path keys = dir.resolve("keys.jks"); + Path trust = dir.resolve("trust.jks"); + AtomicBoolean blockRenewal = new AtomicBoolean(); + CountDownLatch renewalStarted = new CountDownLatch(1); + CountDownLatch allowRenewal = new CountDownLatch(1); + + SslContextFactory serverFactory = new SslContextFactory() { + /** */ + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + if (keys.toString().equals(path) && blockRenewal.get()) { + renewalStarted.countDown(); + + try { + if (!allowRenewal.await(30, SECONDS)) + throw new IOException("Timed out waiting for the replacement key store"); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + + throw new IOException(e); + } + } + + return super.openFileInputStream(path); + } + }; + + try { + save(ca1.keyStore("node", now - MINUTES.toMillis(110), now + MINUTES.toMillis(10)), keys); + save(ca1.trustStore(), trust); + configure(serverFactory, keys, trust); + serverFactory.setRenewalEnabled(true); + + SSLContext initCtx = serverFactory.create(); + + blockRenewal.set(true); + + IgniteEx node = startGrid(0, cfg -> { + ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(new TcpDiscoveryVmIpFinder(true)); + + cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration() + .setSslEnabled(true) + .setSslClientAuth(true) + .setUseIgniteSslContextFactory(false) + .setSslContextFactory(serverFactory)); + }); + + assertTrue("Automatic renewal did not start", renewalStarted.await(10, SECONDS)); + + ClientConfiguration clientCfg = clientConfiguration(node, configure(new SslContextFactory(), keys, trust)); + + try (IgniteClient connected = Ignition.startClient(clientCfg)) { + connected.cacheNames(); + + save(ca2.keyStore("node", now - MINUTES.toMillis(1), now + DAYS.toMillis(1)), keys); + save(ca2.trustStore(), trust); + allowRenewal.countDown(); + + assertTrue("The renewed context was not installed", waitForCondition(() -> serverFactory.create() != initCtx, 10_000)); + + try (IgniteClient newConnection = Ignition.startClient(clientConfiguration(node, + configure(new SslContextFactory(), keys, trust)))) { + newConnection.cacheNames(); + } + + connected.cacheNames(); + } + } + finally { + allowRenewal.countDown(); + stopAllGrids(); + U.delete(dir); + } + } + + /** */ + private static ClientConfiguration clientConfiguration(IgniteEx node, SslContextFactory factory) { + return new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(factory) + .setRetryPolicy(new ClientRetryNonePolicy()) + .setPartitionAwarenessEnabled(false) + .setClusterDiscoveryEnabled(false); + } + + /** */ + private static SslContextFactory configure(SslContextFactory factory, Path keys, Path trust) { + factory.setKeyStoreFilePath(keys.toString()); + factory.setKeyStorePassword(PASSWORD); + factory.setTrustStoreFilePath(trust.toString()); + factory.setTrustStorePassword(PASSWORD); + + return factory; + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java new file mode 100644 index 0000000000000..0e6922a5a446e --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -0,0 +1,435 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.junit.Before; +import org.junit.Test; + +import static java.util.concurrent.TimeUnit.DAYS; +import static java.util.concurrent.TimeUnit.HOURS; +import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** */ +public class SslContextFactoryRenewalTest { + /** */ + private final long now = System.currentTimeMillis() / 1_000 * 1_000; + + /** */ + private TestCertificateAuthority authority; + + /** */ + @Before + public void setUp() throws Exception { + authority = new TestCertificateAuthority("issuer", now - DAYS.toMillis(365), now + DAYS.toMillis(365)); + } + + /** */ + @Test + public void testRenewalIsDisabledByDefault() { + SslContextFactory factory = new SslContextFactory(); + + assertFalse(factory.isRenewalEnabled()); + assertEquals(0, factory.getRenewalTime()); + + factory.renew(); + } + + /** */ + @Test + public void testDisabledRenewalAcceptsStoreWithoutPrivateKeys() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.trustStore()); + + factory.setRenewalEnabled(false); + factory.create(); + + assertEquals(0, factory.getRenewalTime()); + } + + /** */ + @Test + public void testRenewalWindowUsesLoadedKeyStore() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15))); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + assertEquals(1, factory.keyStoreLoads); + } + + /** */ + @Test + public void testRenewalWindowUsesCustomInputStream() throws Exception { + KeyStore keyStore = authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + keyStore.store(bytes, PASSWORD); + + SslContextFactory factory = new SslContextFactory() { + /** */ + private static final long serialVersionUID = 0L; + + /** {@inheritDoc} */ + @Override protected InputStream openFileInputStream(String path) throws IOException { + return new ByteArrayInputStream(bytes.toByteArray()); + } + }; + + factory.setKeyStoreFilePath("custom-source"); + factory.setKeyStorePassword(PASSWORD); + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + factory.setRenewalEnabled(true); + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + } + + /** */ + @Test + public void testIssuerExpiryStartsRenewalWindow() throws Exception { + TestCertificateAuthority issuer = new TestCertificateAuthority("issuer", now - DAYS.toMillis(365), now + HOURS.toMillis(15)); + InMemorySslContextFactory factory = new InMemorySslContextFactory( + issuer.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(100))); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + } + + /** */ + @Test + public void testIssuerExpiredBeforeLeafStartIsAlreadyDue() throws Exception { + TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("issuer", now - DAYS.toMillis(365), now - 1_000); + InMemorySslContextFactory factory = new InMemorySslContextFactory( + expiredIssuer.keyStore("node", now + DAYS.toMillis(100), now + DAYS.toMillis(200))); + + factory.create(); + + assertEquals(now - 1_000, factory.getRenewalTime()); + } + + /** */ + @Test + public void testEarliestChainDeterminesRenewalWindow() throws Exception { + KeyStore keys = authority.keyStore("first", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); + KeyStore other = authority.keyStore("second", now - HOURS.toMillis(200), now + HOURS.toMillis(100)); + + keys.setKeyEntry("second", other.getKey("second", PASSWORD), PASSWORD, other.getCertificateChain("second")); + + InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + } + + /** */ + @Test + public void testEqualExpiriesUseLongestLifetime() throws Exception { + for (String longerAlias : new String[] {"first", "second"}) { + String shorterAlias = longerAlias.equals("first") ? "second" : "first"; + KeyStore keys = authority.keyStore(shorterAlias, now - HOURS.toMillis(5), now + HOURS.toMillis(15)); + KeyStore longer = authority.keyStore(longerAlias, now - HOURS.toMillis(85), now + HOURS.toMillis(15)); + + keys.setKeyEntry(longerAlias, longer.getKey(longerAlias, PASSWORD), PASSWORD, longer.getCertificateChain(longerAlias)); + + InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + } + } + + /** */ + @Test + public void testSecretKeysDoNotAffectRenewalWindow() throws Exception { + KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); + KeyStore mixed = KeyStore.getInstance("JCEKS"); + + mixed.load(null, PASSWORD); + mixed.setKeyEntry("node", keys.getKey("node", PASSWORD), PASSWORD, keys.getCertificateChain("node")); + mixed.setKeyEntry("secret", new SecretKeySpec(new byte[16], "AES"), PASSWORD, null); + + InMemorySslContextFactory factory = new InMemorySslContextFactory(mixed); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + } + + /** */ + @Test + public void testEnabledRenewalRequiresPrivateKey() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.trustStore()); + IgniteException err = assertThrows(IgniteException.class, factory::create); + + assertTrue(err.getCause().getMessage().contains("private key with an X.509 certificate chain")); + assertEquals(0, factory.getRenewalTime()); + } + + /** */ + @Test + public void testRenewBeforeWindowDoesNotLoad() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); + factory.create(); + factory.renew(); + + assertEquals(1, factory.keyStoreLoads); + } + + /** */ + @Test + public void testRenewReplacesExpiredContext() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + factory.renew(); + + assertNotSame(ctx, factory.create()); + assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); + } + + /** */ + @Test + public void testRenewRejectsUnchangedExpiry() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("first", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + + factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(80), now + HOURS.toMillis(10)); + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("later earliest expiry")); + assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); + } + + /** */ + @Test + public void testRenewRejectsExpiredIssuer() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", now - HOURS.toMillis(100), now - 1_000); + + factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); + assertSame(ctx, factory.create()); + } + + /** */ + @Test + public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + KeyStore replacement = authority.keyStore("valid", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + KeyStore notYetValidKeys = authority.keyStore("future", now + HOURS.toMillis(1), now + HOURS.toMillis(100)); + + replacement.setKeyEntry("future", notYetValidKeys.getKey("future", PASSWORD), PASSWORD, + notYetValidKeys.getCertificateChain("future")); + factory.keyStore = replacement; + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("alias=future")); + assertSame(ctx, factory.create()); + } + + /** */ + @Test + public void testManualReloadAcceptsEarlierExpiry() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + factory.init(); + + assertNotSame(ctx, factory.create()); + assertEquals(now - HOURS.toMillis(16), factory.getRenewalTime()); + } + + /** */ + @Test + public void testManualReloadAcceptsFutureCertificate() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("node", now + HOURS.toMillis(1), now + HOURS.toMillis(101)); + factory.init(); + + assertNotSame(ctx, factory.create()); + assertEquals(now + HOURS.toMillis(86), factory.getRenewalTime()); + } + + /** */ + @Test + public void testManualReloadMovesRenewalWindow() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + + factory.create(); + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + factory.init(); + factory.renew(); + + assertEquals(2, factory.keyStoreLoads); + assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); + } + + /** */ + @Test + public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + factory.setTrustManagers((TrustManager[])null); + factory.setTrustStoreFilePath("missing-trust-store"); + factory.setTrustStorePassword(PASSWORD); + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("missing-trust-store")); + assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); + + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + factory.renew(); + + assertNotSame(ctx, factory.create()); + assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); + } + + /** */ + @Test + public void testRenewRejectsCertificatesExpiredDuringTrustStoreLoad() throws Exception { + KeyStore trustStore = authority.trustStore(); + KeyStore expiredKeys = authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + + /** */ + class SlowTrustStoreFactory extends InMemorySslContextFactory { + /** */ + private static final long serialVersionUID = 0L; + + /** */ + private long replacementExpiry; + + /** */ + private SlowTrustStoreFactory() { + super(expiredKeys); + } + + /** {@inheritDoc} */ + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { + if (!"slow-trust-store".equals(path)) + return super.loadKeyStore(type, path, pwd); + + try { + assertTrue(waitForCondition(() -> System.currentTimeMillis() > replacementExpiry, 10_000)); + } + catch (IgniteInterruptedCheckedException e) { + Thread.currentThread().interrupt(); + + throw new SSLException(e); + } + + return trustStore; + } + } + + SlowTrustStoreFactory factory = new SlowTrustStoreFactory(); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + + factory.replacementExpiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), factory.replacementExpiry); + factory.setTrustManagers((TrustManager[])null); + factory.setTrustStoreFilePath("slow-trust-store"); + factory.setTrustStorePassword(PASSWORD); + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("expired while loading")); + assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); + } + + /** */ + private static class InMemorySslContextFactory extends SslContextFactory { + /** */ + private static final long serialVersionUID = 0L; + + /** */ + protected KeyStore keyStore; + + /** */ + private int keyStoreLoads; + + /** */ + private InMemorySslContextFactory(KeyStore keyStore) { + this.keyStore = keyStore; + + setKeyStoreFilePath("memory"); + setKeyStorePassword(PASSWORD); + setTrustManagers(getDisabledTrustManager()); + setRenewalEnabled(true); + } + + /** {@inheritDoc} */ + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { + if (!"memory".equals(path)) + return super.loadKeyStore(type, path, pwd); + + keyStoreLoads++; + + return keyStore; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java new file mode 100644 index 0000000000000..417757a7c8e8b --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java @@ -0,0 +1,122 @@ +/* + * 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. + */ + +package org.apache.ignite.ssl; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.Date; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** Test certificates with explicit validity dates. */ +class TestCertificateAuthority { + /** */ + static final char[] PASSWORD = "123456".toCharArray(); + + /** */ + private final X500Principal name; + + /** */ + private final KeyPair keys = keyPair(); + + /** */ + private final X509Certificate cert; + + /** */ + private long serial; + + /** */ + TestCertificateAuthority(String name, long notBefore, long notAfter) throws Exception { + this.name = new X500Principal("CN=" + name); + cert = certificate(this.name, keys, notBefore, notAfter, true); + } + + /** */ + KeyStore keyStore(String alias, long notBefore, long notAfter) throws Exception { + KeyPair leafKeys = keyPair(); + X509Certificate leaf = certificate(new X500Principal("CN=" + alias), leafKeys, notBefore, notAfter, false); + KeyStore store = emptyStore(); + + store.setKeyEntry(alias, leafKeys.getPrivate(), PASSWORD, new X509Certificate[] {leaf, cert}); + + return store; + } + + /** */ + KeyStore trustStore() throws Exception { + KeyStore store = emptyStore(); + + store.setCertificateEntry(name.getName(), cert); + + return store; + } + + /** */ + static void save(KeyStore store, Path path) throws Exception { + try (OutputStream out = Files.newOutputStream(path)) { + store.store(out, PASSWORD); + } + } + + /** */ + private X509Certificate certificate(X500Principal subject, KeyPair subjectKeys, long notBefore, long notAfter, boolean ca) + throws Exception { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, + BigInteger.valueOf(++serial), + new Date(notBefore), + new Date(notAfter), + subject, + subjectKeys.getPublic()); + + if (ca) + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + + return new JcaX509CertificateConverter().getCertificate( + builder.build(new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()))); + } + + /** */ + private static KeyPair keyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + + generator.initialize(new ECGenParameterSpec("secp256r1")); + + return generator.generateKeyPair(); + } + + /** */ + private static KeyStore emptyStore() throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); + + store.load(null, PASSWORD); + + return store; + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 7b207d1226a0e..4a1aad4784a21 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java @@ -83,7 +83,9 @@ import org.apache.ignite.plugin.security.SecurityBasicPermissionSetSerializationTest; import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; +import org.apache.ignite.ssl.SslCertificateRenewalTest; import org.apache.ignite.ssl.SslContextFactoryInitTest; +import org.apache.ignite.ssl.SslContextFactoryRenewalTest; import org.apache.ignite.ssl.SslContextReloadTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; @@ -149,6 +151,8 @@ MultipleSSLContextsTest.class, SSLContextWrapperTest.class, SslContextFactoryInitTest.class, + SslContextFactoryRenewalTest.class, + SslCertificateRenewalTest.class, SslFactoryRegistryTest.class, SslReloadTaskTest.class, SslContextReloadTest.class, From 285bdd3f6b697a1386588ab44cb71bf5bfa7c42c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 17:51:27 +0300 Subject: [PATCH 209/242] IGNITE-29211 Isolate renewal from the reload caller context --- .../_docs/monitoring-metrics/new-metrics.adoc | 1 - .../processors/ssl/SslFactoryRegistry.java | 10 ++- .../ssl/SslFactoryRegistryTest.java | 42 ++++++++++- .../ignite/ssl/SslCertificateRenewalTest.java | 4 -- .../ssl/SslContextFactoryRenewalTest.java | 71 +++---------------- 5 files changed, 52 insertions(+), 76 deletions(-) diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 7ca470c1fdac5..42dfed8abb167 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -442,7 +442,6 @@ Register name: `ssl` |ConsecutiveRenewalFailures | long | Sum of consecutive automatic renewal failures across the node's SSL factories. Zero when renewal is disabled. |=== -A successful renewal, manual reload or skipped attempt clears the corresponding factory's count. See link:security/ssl-tls#automatic-certificate-renewal[Automatic Certificate Renewal] for configuration and recovery. == Communication Connection Pool diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index fb6251db62747..8461d87f84316 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -21,7 +21,6 @@ import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -31,6 +30,8 @@ import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.thread.context.OperationContext; +import org.apache.ignite.internal.thread.context.Scope; import org.apache.ignite.ssl.SslContextFactory; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -111,10 +112,7 @@ public synchronized void stop() { if (executor == null) return; - for (Runnable task : executor.shutdownNow()) { - if (task instanceof Future) - ((Future)task).cancel(false); - } + executor.shutdownNow(); boolean interrupted = false; @@ -147,7 +145,7 @@ public void onReload(Factory factory) { renewal.manualReloadPending = true; - try { + try (Scope ignored = OperationContext.restoreSnapshot(null)) { executor.execute(renewal::onReload); } catch (RejectedExecutionException e) { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 744a0413f9c2c..9672862ad08f3 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -18,15 +18,21 @@ package org.apache.ignite.internal.processors.ssl; import java.util.ArrayDeque; +import java.util.List; import java.util.Queue; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.processors.metric.GridMetricManager; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; +import org.apache.ignite.internal.thread.context.OperationContext; +import org.apache.ignite.internal.thread.context.OperationContextAttribute; +import org.apache.ignite.internal.thread.context.Scope; import org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor; import org.apache.ignite.logger.NullLogger; import org.apache.ignite.spi.metric.LongMetric; @@ -37,13 +43,13 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; -import static java.util.Collections.emptyList; import static java.util.concurrent.TimeUnit.HOURS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MINUTES; import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -98,7 +104,6 @@ public void setUp() { when(ctx.log(SslFactoryRegistry.class)).thenReturn(new NullLogger()); when(factory.isRenewalEnabled()).thenReturn(true); when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); - when(executor.shutdownNow()).thenReturn(emptyList()); when(executor.isTerminated()).thenReturn(true); doAnswer(call -> { @@ -317,6 +322,39 @@ public void testQueuedManualResetKeepsFailureAfterManualReload() { verify(executor, times(3)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } + /** */ + @Test + public void testAttemptAfterManualReloadDoesNotInheritCallerContext() throws Exception { + OperationContextAttribute caller = OperationContextAttribute.newInstance(); + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); + List attemptCallers = new CopyOnWriteArrayList<>(); + + executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) + .thenCallRealMethod(); + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); + doAnswer(call -> { + attemptCallers.add(OperationContext.get(caller)); + renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); + + return null; + }).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + + assertTrue(waitForCondition(() -> attemptCallers.size() == 1, 10_000)); + + try (Scope ignored = OperationContext.set(caller, "operator")) { + synchronized (factory) { + renewalTime.set(System.currentTimeMillis()); + registry.onReload(factory); + } + } + + assertTrue(waitForCondition(() -> attemptCallers.size() == 2, 10_000)); + assertNull(attemptCallers.get(1)); + } + /** */ @Test public void testStoppingNodeDoesNotStartRenewal() { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java index f37958e9f9a73..45e835ad0d90e 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java @@ -32,8 +32,6 @@ import org.apache.ignite.configuration.ClientConnectorConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi; -import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -94,8 +92,6 @@ public void testRenewalUpdatesNewConnectionsAndPreservesEstablishedConnection() blockRenewal.set(true); IgniteEx node = startGrid(0, cfg -> { - ((TcpDiscoverySpi)cfg.getDiscoverySpi()).setIpFinder(new TcpDiscoveryVmIpFinder(true)); - cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration() .setSslEnabled(true) .setSslClientAuth(true) diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 0e6922a5a446e..753992587249d 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -17,11 +17,8 @@ package org.apache.ignite.ssl; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; import java.security.KeyStore; +import java.security.cert.X509Certificate; import javax.crypto.spec.SecretKeySpec; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; @@ -90,33 +87,6 @@ public void testRenewalWindowUsesLoadedKeyStore() throws Exception { assertEquals(1, factory.keyStoreLoads); } - /** */ - @Test - public void testRenewalWindowUsesCustomInputStream() throws Exception { - KeyStore keyStore = authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - keyStore.store(bytes, PASSWORD); - - SslContextFactory factory = new SslContextFactory() { - /** */ - private static final long serialVersionUID = 0L; - - /** {@inheritDoc} */ - @Override protected InputStream openFileInputStream(String path) throws IOException { - return new ByteArrayInputStream(bytes.toByteArray()); - } - }; - - factory.setKeyStoreFilePath("custom-source"); - factory.setKeyStorePassword(PASSWORD); - factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); - factory.setRenewalEnabled(true); - factory.create(); - - assertEquals(now, factory.getRenewalTime()); - } - /** */ @Test public void testIssuerExpiryStartsRenewalWindow() throws Exception { @@ -292,35 +262,6 @@ public void testManualReloadAcceptsEarlierExpiry() throws Exception { assertEquals(now - HOURS.toMillis(16), factory.getRenewalTime()); } - /** */ - @Test - public void testManualReloadAcceptsFutureCertificate() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - - factory.keyStore = authority.keyStore("node", now + HOURS.toMillis(1), now + HOURS.toMillis(101)); - factory.init(); - - assertNotSame(ctx, factory.create()); - assertEquals(now + HOURS.toMillis(86), factory.getRenewalTime()); - } - - /** */ - @Test - public void testManualReloadMovesRenewalWindow() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - - factory.create(); - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - factory.init(); - factory.renew(); - - assertEquals(2, factory.keyStoreLoads); - assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); - } - /** */ @Test public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Exception { @@ -351,7 +292,10 @@ public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Except @Test public void testRenewRejectsCertificatesExpiredDuringTrustStoreLoad() throws Exception { KeyStore trustStore = authority.trustStore(); - KeyStore expiredKeys = authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + KeyStore curKeys = authority.keyStore("expired", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + KeyStore validKeys = authority.keyStore("valid", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + + curKeys.setKeyEntry("valid", validKeys.getKey("valid", PASSWORD), PASSWORD, validKeys.getCertificateChain("valid")); /** */ class SlowTrustStoreFactory extends InMemorySslContextFactory { @@ -363,7 +307,7 @@ class SlowTrustStoreFactory extends InMemorySslContextFactory { /** */ private SlowTrustStoreFactory() { - super(expiredKeys); + super(curKeys); } /** {@inheritDoc} */ @@ -389,7 +333,7 @@ private SlowTrustStoreFactory() { long renewalTime = factory.getRenewalTime(); factory.replacementExpiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), factory.replacementExpiry); + factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), factory.replacementExpiry); factory.setTrustManagers((TrustManager[])null); factory.setTrustStoreFilePath("slow-trust-store"); factory.setTrustStorePassword(PASSWORD); @@ -397,6 +341,7 @@ private SlowTrustStoreFactory() { IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("expired while loading")); + ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); } From 5757400152a285945a7375729ac921ad40502408 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 19:23:35 +0300 Subject: [PATCH 210/242] IGNITE-29211 Coordinate TLS renewal in the registry --- .../management/ssl/SslReloadTask.java | 5 +- .../processors/ssl/SslFactoryRegistry.java | 71 +++------- .../ssl/SslFactoryRegistryTest.java | 125 +++++++++--------- 3 files changed, 82 insertions(+), 119 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index b1ff1843edc50..2d6446ed6cf2b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -161,10 +161,7 @@ private SslReloadJob(NoArg arg, boolean debug) { .collect(joining(", ")); try { - synchronized (factory) { - ((AbstractSslContextFactory)factory).init(); - ignite.context().ssl().onReload(factory); - } + ignite.context().ssl().reload((AbstractSslContextFactory)factory); } catch (Exception e) { ignite.log().warning("TLS reload: " + nodeId + ": " + transports + ": failed" + initiator, e); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 8461d87f84316..bd142d5caf584 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -32,6 +32,7 @@ import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -102,7 +103,7 @@ public synchronized void start(GridKernalContext ctx) { return; executor = newSingleThreadScheduledExecutor("ssl-renewal", ctx.igniteInstanceName()); - executor.execute(() -> renewals.values().forEach(renewal -> renewal.schedule(renewal.factory.getRenewalTime()))); + renewals.values().forEach(Renewal::schedule); } /** */ @@ -129,29 +130,14 @@ public synchronized void stop() { Thread.currentThread().interrupt(); } - /** */ - public void onReload(Factory factory) { - ScheduledExecutorService executor = this.executor; - - if (executor == null || stopping()) - return; - - Renewal renewal = renewals.get(factory); + /** Reloads the factory and resets its automatic renewal schedule and failure count. */ + public void reload(AbstractSslContextFactory factory) { + Renewal renewal = executor == null ? null : renewals.get(factory); if (renewal == null) - return; - - assert Thread.holdsLock(factory); - - renewal.manualReloadPending = true; - - try (Scope ignored = OperationContext.restoreSnapshot(null)) { - executor.execute(renewal::onReload); - } - catch (RejectedExecutionException e) { - if (!stopping()) - throw e; - } + factory.init(); + else + renewal.reload(); } /** */ @@ -173,9 +159,6 @@ private class Renewal implements Runnable { /** */ private int failures; - /** Guarded by the factory monitor. */ - private boolean manualReloadPending; - /** */ private Renewal(SslContextFactory factory, String transports) { this.factory = factory; @@ -183,11 +166,10 @@ private Renewal(SslContextFactory factory, String transports) { } /** {@inheritDoc} */ - @Override public void run() { + @Override public synchronized void run() { if (stopping()) return; - boolean manuallyReloaded; Exception failure = null; long nextAttempt = 0; @@ -195,9 +177,6 @@ private Renewal(SslContextFactory factory, String transports) { if (stopping()) return; - manuallyReloaded = manualReloadPending; - manualReloadPending = false; - try { factory.renew(); nextAttempt = factory.getRenewalTime(); @@ -210,10 +189,9 @@ private Renewal(SslContextFactory factory, String transports) { if (stopping()) return; - if (manuallyReloaded || failure == null) + if (failure == null) resetFailures(); - - if (failure != null) { + else { failures++; consecutiveFailures.incrementAndGet(); @@ -228,25 +206,11 @@ private Renewal(SslContextFactory factory, String transports) { } /** */ - private void onReload() { - if (stopping()) - return; - - long nextAttempt; - - synchronized (factory) { - if (stopping() || !manualReloadPending) - return; - - manualReloadPending = false; - nextAttempt = factory.getRenewalTime(); - } - - if (stopping()) - return; + private synchronized void reload() { + factory.init(); resetFailures(); - schedule(nextAttempt); + schedule(); } /** */ @@ -255,6 +219,11 @@ private void resetFailures() { failures = 0; } + /** */ + private synchronized void schedule() { + schedule(factory.getRenewalTime()); + } + /** */ private void schedule(long time) { if (stopping()) @@ -266,7 +235,7 @@ private void schedule(long time) { if (time == 0) return; - try { + try (Scope ignored = OperationContext.restoreSnapshot(null)) { pending = executor.schedule(this, Math.max(0, time - System.currentTimeMillis()), MILLISECONDS); } catch (RejectedExecutionException e) { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 9672862ad08f3..ad0ed8dfe7a2d 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,10 +17,9 @@ package org.apache.ignite.internal.processors.ssl; -import java.util.ArrayDeque; -import java.util.List; -import java.util.Queue; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicLong; @@ -46,10 +45,10 @@ import static java.util.concurrent.TimeUnit.HOURS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MINUTES; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -106,12 +105,6 @@ public void setUp() { when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); when(executor.isTerminated()).thenReturn(true); - doAnswer(call -> { - call.getArgument(0, Runnable.class).run(); - - return null; - }).when(executor).execute(any(Runnable.class)); - when(executor.schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS))).thenAnswer(call -> { scheduled = call.getArgument(0); delay = call.getArgument(1); @@ -244,15 +237,33 @@ public void testManualReloadReplacesRetryAndResetsFailures() { when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - synchronized (factory) { - registry.onReload(factory); - } + registry.reload(factory); verify(retry).cancel(false); assertEquals(0, failures()); assertTrue(delay > HOURS.toMillis(1)); } + /** */ + @Test + public void testFailedManualReloadKeepsRetryAndFailures() { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + ScheduledFuture retry = pending; + IgniteException err = new IgniteException("Invalid replacement"); + + doThrow(err).when(factory).init(); + + assertSame(err, assertThrows(IgniteException.class, () -> registry.reload(factory))); + assertEquals(1, failures()); + verify(retry, never()).cancel(false); + verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + } + /** */ @Test public void testMetricKeepsOtherFactoryFailuresAfterReload() { @@ -274,9 +285,7 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() { assertEquals(2, failures()); - synchronized (factory) { - registry.onReload(factory); - } + registry.reload(factory); assertEquals(1, failures()); } @@ -300,69 +309,51 @@ public void testMetricResetDoesNotCorruptFailureCount() { /** */ @Test - public void testQueuedManualResetKeepsFailureAfterManualReload() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - - registry.register("communication", factory); - registry.start(ctx); - scheduled.run(); - - Queue notifications = new ArrayDeque<>(); - - doAnswer(call -> notifications.add(call.getArgument(0))).when(executor).execute(any(Runnable.class)); - - synchronized (factory) { - registry.onReload(factory); - } - - scheduled.run(); - notifications.remove().run(); - - assertEquals(1, failures()); - verify(executor, times(3)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - } - - /** */ - @Test - public void testAttemptAfterManualReloadDoesNotInheritCallerContext() throws Exception { + public void testRenewalDoesNotInheritCallerContext() throws Exception { OperationContextAttribute caller = OperationContextAttribute.newInstance(); AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); - List attemptCallers = new CopyOnWriteArrayList<>(); + BlockingQueue> attemptCallers = new LinkedBlockingQueue<>(); executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) .thenCallRealMethod(); when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); doAnswer(call -> { - attemptCallers.add(OperationContext.get(caller)); renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); + attemptCallers.add(Optional.ofNullable(OperationContext.get(caller))); return null; }).when(factory).renew(); + doAnswer(call -> { + renewalTime.set(System.currentTimeMillis()); - registry.register("communication", factory); - registry.start(ctx); + return null; + }).when(factory).init(); - assertTrue(waitForCondition(() -> attemptCallers.size() == 1, 10_000)); + registry.register("communication", factory); try (Scope ignored = OperationContext.set(caller, "operator")) { - synchronized (factory) { - renewalTime.set(System.currentTimeMillis()); - registry.onReload(factory); - } + registry.start(ctx); + + assertEquals(Optional.empty(), attemptCallers.poll(10, SECONDS)); + + registry.reload(factory); + + assertEquals("operator", OperationContext.get(caller)); } - assertTrue(waitForCondition(() -> attemptCallers.size() == 2, 10_000)); - assertNull(attemptCallers.get(1)); + assertEquals(Optional.empty(), attemptCallers.poll(10, SECONDS)); } /** */ @Test public void testStoppingNodeDoesNotStartRenewal() { when(ctx.isStopping()).thenReturn(true); + when(factory.getRenewalTime()).thenReturn(0L); registry.register("communication", factory); registry.start(ctx); + verify(factory, never()).init(); executorFactory.verifyNoInteractions(); } @@ -373,21 +364,27 @@ public void testStoppingWhileWaitingForFactorySkipsRenewal() throws Exception { registry.start(ctx); AtomicReference renewalThread = new AtomicReference<>(); - IgniteInternalFuture attempt; + IgniteInternalFuture attempt = null; - synchronized (factory) { - attempt = runAsync(() -> { - renewalThread.set(Thread.currentThread()); - scheduled.run(); - }); + try { + synchronized (factory) { + attempt = runAsync(() -> { + renewalThread.set(Thread.currentThread()); + scheduled.run(); + }); - assertTrue(waitForCondition( - () -> renewalThread.get() != null && renewalThread.get().getState() == Thread.State.BLOCKED, 5_000)); + long deadline = System.currentTimeMillis() + 5_000; - when(ctx.isStopping()).thenReturn(true); - } + assertTrue(waitForCondition(() -> renewalThread.get() != null && renewalThread.get().getState() == Thread.State.BLOCKED, + () -> System.currentTimeMillis() < deadline, 10)); - attempt.get(5_000); + when(ctx.isStopping()).thenReturn(true); + } + } + finally { + if (attempt != null) + attempt.get(5_000); + } verify(factory, never()).renew(); assertEquals(0, failures()); From 529f552de69e837a71d3374d1ebe703504818e81 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 19:56:45 +0300 Subject: [PATCH 211/242] IGNITE-29211 Cover renewal recovery and shutdown --- docs/_docs/security/ssl-tls.adoc | 2 +- .../ssl/SslFactoryRegistryTest.java | 66 +++++++++++++++++++ .../ssl/SslContextFactoryRenewalTest.java | 44 ++++++++----- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index a644f94c9ba72..a783d00307576 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -206,7 +206,7 @@ The factory reads renewal dates from the key store it actually loads. It selects the key chain that expires first and starts renewal with 15% of its lifetime remaining. This lifetime runs from the key certificate's validity start to the earliest expiry in its chain, including issuing certificates. For chains with the same expiry, it uses the longest lifetime. -Startup fails if renewal is enabled and the store has no private key with an X.509 certificate chain. +With renewal enabled, startup fails unless the store contains at least one private key and every private key has an X.509 chain. Every certificate in an automatic replacement's key chains must be within its validity dates, and the earliest expiry must move later. Otherwise, Ignite keeps the current context and retries, even after its certificates expire. diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index ad0ed8dfe7a2d..ccd3de8cd58d6 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -19,9 +19,11 @@ import java.util.Optional; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import org.apache.ignite.IgniteException; @@ -60,6 +62,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -390,6 +393,69 @@ public void testStoppingWhileWaitingForFactorySkipsRenewal() throws Exception { assertEquals(0, failures()); } + /** */ + @Test + public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { + CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + CountDownLatch terminationWaitEntered = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + ScheduledExecutorService renewalExecutor = spy(new IgniteScheduledThreadPoolExecutor("ssl-renewal", null, 1)); + + executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) + .thenReturn(renewalExecutor); + doAnswer(call -> { + terminationWaitEntered.countDown(); + + return call.callRealMethod(); + }).when(renewalExecutor).awaitTermination(anyLong(), eq(MILLISECONDS)); + doAnswer(call -> { + attemptStarted.countDown(); + + try { + assertTrue(allowCompletion.await(30, SECONDS)); + } + catch (InterruptedException e) { + interrupted.set(true); + + assertTrue(allowCompletion.await(30, SECONDS)); + } + + if (Thread.currentThread().isInterrupted()) + interrupted.set(true); + + return null; + }).when(factory).renew(); + + registry.register("communication", factory); + + IgniteInternalFuture stop = null; + + try { + registry.start(ctx); + + assertTrue(attemptStarted.await(5, SECONDS)); + + when(ctx.isStopping()).thenReturn(true); + + stop = runAsync(() -> { + registry.stop(); + + assertTrue(renewalExecutor.isTerminated()); + }); + + assertTrue(terminationWaitEntered.await(5, SECONDS)); + } + finally { + allowCompletion.countDown(); + + if (stop != null) + stop.get(5_000); + } + + assertTrue(interrupted.get()); + } + /** */ @Test public void testStoppingDuringLoadDoesNotCountFailure() { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 753992587249d..ff7f205a5cade 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -232,29 +232,36 @@ public void testRenewRejectsExpiredIssuer() throws Exception { /** */ @Test public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - KeyStore replacement = authority.keyStore("valid", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - KeyStore notYetValidKeys = authority.keyStore("future", now + HOURS.toMillis(1), now + HOURS.toMillis(100)); - - replacement.setKeyEntry("future", notYetValidKeys.getKey("future", PASSWORD), PASSWORD, - notYetValidKeys.getCertificateChain("future")); - factory.keyStore = replacement; - - IgniteException err = assertThrows(IgniteException.class, factory::renew); - - assertTrue(err.getCause().getMessage().contains("alias=future")); - assertSame(ctx, factory.create()); + for (String notYetValidAlias : new String[] {"first", "second"}) { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + String validAlias = notYetValidAlias.equals("first") ? "second" : "first"; + KeyStore replacement = authority.keyStore(validAlias, now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + KeyStore notYetValidKeys = authority.keyStore(notYetValidAlias, now + HOURS.toMillis(1), now + HOURS.toMillis(100)); + + replacement.setKeyEntry(notYetValidAlias, notYetValidKeys.getKey(notYetValidAlias, PASSWORD), PASSWORD, + notYetValidKeys.getCertificateChain(notYetValidAlias)); + factory.keyStore = replacement; + + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("alias=" + notYetValidAlias)); + assertSame(ctx, factory.create()); + } } /** */ @Test - public void testManualReloadAcceptsEarlierExpiry() throws Exception { + public void testManualReloadAcceptsEarlierExpiryAfterRenewalFailure() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); + IgniteException err = assertThrows(IgniteException.class, factory::renew); + + assertTrue(err.getCause().getMessage().contains("later earliest expiry")); + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); factory.init(); @@ -316,7 +323,10 @@ private SlowTrustStoreFactory() { return super.loadKeyStore(type, path, pwd); try { - assertTrue(waitForCondition(() -> System.currentTimeMillis() > replacementExpiry, 10_000)); + long deadline = System.currentTimeMillis() + 10_000; + + assertTrue(waitForCondition(() -> System.currentTimeMillis() > replacementExpiry, + () -> System.currentTimeMillis() < deadline)); } catch (IgniteInterruptedCheckedException e) { Thread.currentThread().interrupt(); From be307699bfcd00af552a6068c14bbfbbe1d07cc1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 11 Nov 2026 20:18:28 +0300 Subject: [PATCH 212/242] IGNITE-29211 Keep renewal cancellation inside factory --- .../apache/ignite/ssl/SslContextFactory.java | 6 +- .../processors/ssl/SslFactoryRegistry.java | 27 ++++----- .../ssl/SslFactoryRegistryTest.java | 59 ++++++++----------- .../ssl/SslContextFactoryRenewalTest.java | 56 ++++++++++++++---- 4 files changed, 84 insertions(+), 64 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index feac8945781ed..c75cbddf058a2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -31,6 +31,7 @@ import java.util.Arrays; import java.util.Date; import java.util.Enumeration; +import java.util.function.BooleanSupplier; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -135,10 +136,11 @@ public synchronized long getRenewalTime() { * Replacement certificates must be within their validity dates and have a later earliest expiry; * otherwise the current context is kept. * + * @param cancelled Cancellation condition checked after waiting for any active load. * @throws org.apache.ignite.IgniteException If replacement certificates cannot be loaded or accepted. */ - public synchronized void renew() { - if (!renewalEnabled || renewalTime == 0 || renewalTime > System.currentTimeMillis()) + public synchronized void renew(BooleanSupplier cancelled) { + if (cancelled.getAsBoolean() || !renewalEnabled || renewalTime == 0 || renewalTime > System.currentTimeMillis()) return; renewing = true; diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index bd142d5caf584..98799bfb779eb 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -82,13 +82,11 @@ public synchronized void start(GridKernalContext ctx) { for (Factory factory : factories.values()) { if (factory instanceof SslContextFactory && ((SslContextFactory)factory).isRenewalEnabled() && !renewals.containsKey(factory)) { - synchronized (factory) { - if (ctx.isStopping()) - return; + if (ctx.isStopping()) + return; - if (((SslContextFactory)factory).getRenewalTime() == 0) - ((SslContextFactory)factory).init(); - } + if (((SslContextFactory)factory).getRenewalTime() == 0) + ((SslContextFactory)factory).init(); String transports = factories.entrySet().stream() .filter(entry -> entry.getValue() == factory) @@ -173,17 +171,12 @@ private Renewal(SslContextFactory factory, String transports) { Exception failure = null; long nextAttempt = 0; - synchronized (factory) { - if (stopping()) - return; - - try { - factory.renew(); - nextAttempt = factory.getRenewalTime(); - } - catch (Exception e) { - failure = e; - } + try { + factory.renew(SslFactoryRegistry.this::stopping); + nextAttempt = factory.getRenewalTime(); + } + catch (Exception e) { + failure = e; } if (stopping()) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index ccd3de8cd58d6..807afece0cfea 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -25,7 +25,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; @@ -49,8 +49,8 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; -import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -188,7 +188,7 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { /** */ @Test public void testFailuresIncreaseRetryDelayUntilLimit() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); @@ -209,7 +209,7 @@ public void testFailuresIncreaseRetryDelayUntilLimit() { /** */ @Test public void testNormalReturnResetsFailuresAndReadsNextWindow() { - doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); @@ -228,7 +228,7 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { /** */ @Test public void testManualReloadReplacesRetryAndResetsFailures() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); @@ -250,7 +250,7 @@ public void testManualReloadReplacesRetryAndResetsFailures() { /** */ @Test public void testFailedManualReloadKeepsRetryAndFailures() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); @@ -274,8 +274,8 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() { when(otherFactory.isRenewalEnabled()).thenReturn(true); when(otherFactory.getRenewalTime()).thenReturn(1L); - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(any()); registry.register("communication", factory); registry.register("client connector", otherFactory); @@ -296,7 +296,7 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() { /** */ @Test public void testMetricResetDoesNotCorruptFailureCount() { - doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); @@ -325,7 +325,7 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { attemptCallers.add(Optional.ofNullable(OperationContext.get(caller))); return null; - }).when(factory).renew(); + }).when(factory).renew(any()); doAnswer(call -> { renewalTime.set(System.currentTimeMillis()); @@ -362,35 +362,24 @@ public void testStoppingNodeDoesNotStartRenewal() { /** */ @Test - public void testStoppingWhileWaitingForFactorySkipsRenewal() throws Exception { - registry.register("communication", factory); - registry.start(ctx); + public void testRenewalObservesNodeStop() { + doAnswer(call -> { + BooleanSupplier cancelled = call.getArgument(0); - AtomicReference renewalThread = new AtomicReference<>(); - IgniteInternalFuture attempt = null; + assertFalse(cancelled.getAsBoolean()); - try { - synchronized (factory) { - attempt = runAsync(() -> { - renewalThread.set(Thread.currentThread()); - scheduled.run(); - }); + when(ctx.isStopping()).thenReturn(true); - long deadline = System.currentTimeMillis() + 5_000; + assertTrue(cancelled.getAsBoolean()); - assertTrue(waitForCondition(() -> renewalThread.get() != null && renewalThread.get().getState() == Thread.State.BLOCKED, - () -> System.currentTimeMillis() < deadline, 10)); + return null; + }).when(factory).renew(any()); - when(ctx.isStopping()).thenReturn(true); - } - } - finally { - if (attempt != null) - attempt.get(5_000); - } + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); - verify(factory, never()).renew(); - assertEquals(0, failures()); + verify(factory).renew(any()); } /** */ @@ -425,7 +414,7 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { interrupted.set(true); return null; - }).when(factory).renew(); + }).when(factory).renew(any()); registry.register("communication", factory); @@ -463,7 +452,7 @@ public void testStoppingDuringLoadDoesNotCountFailure() { when(ctx.isStopping()).thenReturn(true); throw new IgniteException("Load interrupted by node stop"); - }).when(factory).renew(); + }).when(factory).renew(any()); registry.register("communication", factory); registry.start(ctx); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index ff7f205a5cade..064822b0bab0e 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -19,15 +19,18 @@ import java.security.KeyStore; import java.security.cert.X509Certificate; +import java.util.concurrent.atomic.AtomicBoolean; import javax.crypto.spec.SecretKeySpec; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.testframework.GridTestThread; import org.junit.Before; import org.junit.Test; +import static java.lang.Thread.State.BLOCKED; import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.HOURS; import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; @@ -61,7 +64,7 @@ public void testRenewalIsDisabledByDefault() { assertFalse(factory.isRenewalEnabled()); assertEquals(0, factory.getRenewalTime()); - factory.renew(); + factory.renew(() -> false); } /** */ @@ -177,11 +180,44 @@ public void testRenewBeforeWindowDoesNotLoad() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); factory.create(); - factory.renew(); + factory.renew(() -> false); assertEquals(1, factory.keyStoreLoads); } + /** */ + @Test + public void testCancellationWhileWaitingForFactorySkipsRenewal() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + + AtomicBoolean cancelled = new AtomicBoolean(); + GridTestThread attempt = new GridTestThread(() -> factory.renew(cancelled::get)); + + try { + synchronized (factory) { + attempt.start(); + + long deadline = System.currentTimeMillis() + 5_000; + + assertTrue(waitForCondition(() -> attempt.getState() == BLOCKED, () -> System.currentTimeMillis() < deadline)); + + cancelled.set(true); + } + } + finally { + attempt.join(5_000); + } + + assertFalse(attempt.isAlive()); + attempt.checkError(); + assertEquals(1, factory.keyStoreLoads); + assertSame(ctx, factory.create()); + } + /** */ @Test public void testRenewReplacesExpiredContext() throws Exception { @@ -190,7 +226,7 @@ public void testRenewReplacesExpiredContext() throws Exception { SSLContext ctx = factory.create(); factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - factory.renew(); + factory.renew(() -> false); assertNotSame(ctx, factory.create()); assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); @@ -206,7 +242,7 @@ public void testRenewRejectsUnchangedExpiry() throws Exception { factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(80), now + HOURS.toMillis(10)); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("later earliest expiry")); assertSame(ctx, factory.create()); @@ -223,7 +259,7 @@ public void testRenewRejectsExpiredIssuer() throws Exception { factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); assertSame(ctx, factory.create()); @@ -244,7 +280,7 @@ public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { notYetValidKeys.getCertificateChain(notYetValidAlias)); factory.keyStore = replacement; - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("alias=" + notYetValidAlias)); assertSame(ctx, factory.create()); @@ -258,7 +294,7 @@ public void testManualReloadAcceptsEarlierExpiryAfterRenewalFailure() throws Exc authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("later earliest expiry")); @@ -282,14 +318,14 @@ public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Except factory.setTrustStoreFilePath("missing-trust-store"); factory.setTrustStorePassword(PASSWORD); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("missing-trust-store")); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); - factory.renew(); + factory.renew(() -> false); assertNotSame(ctx, factory.create()); assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); @@ -348,7 +384,7 @@ private SlowTrustStoreFactory() { factory.setTrustStoreFilePath("slow-trust-store"); factory.setTrustStorePassword(PASSWORD); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); assertTrue(err.getCause().getMessage().contains("expired while loading")); ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); From 73e75d82769c42948f3b357367d3b66a7a7ea447 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 15:51:19 +0300 Subject: [PATCH 213/242] IGNITE-29211 Serialize renewal operations on the scheduler --- .../processors/ssl/SslFactoryRegistry.java | 44 ++- .../ssl/SslFactoryRegistryTest.java | 254 +++++++++++++++++- 2 files changed, 285 insertions(+), 13 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 98799bfb779eb..96a9127e28d5f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -21,6 +21,7 @@ import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -28,10 +29,13 @@ import java.util.concurrent.atomic.AtomicLong; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; @@ -101,7 +105,10 @@ public synchronized void start(GridKernalContext ctx) { return; executor = newSingleThreadScheduledExecutor("ssl-renewal", ctx.igniteInstanceName()); - renewals.values().forEach(Renewal::schedule); + + try (Scope ignored = OperationContext.restoreSnapshot(null)) { + executor.execute(() -> renewals.values().forEach(Renewal::schedule)); + } } /** */ @@ -111,7 +118,10 @@ public synchronized void stop() { if (executor == null) return; - executor.shutdownNow(); + for (Runnable task : executor.shutdownNow()) { + if (task instanceof Future) + ((Future)task).cancel(false); + } boolean interrupted = false; @@ -129,13 +139,27 @@ public synchronized void stop() { } /** Reloads the factory and resets its automatic renewal schedule and failure count. */ - public void reload(AbstractSslContextFactory factory) { - Renewal renewal = executor == null ? null : renewals.get(factory); + public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { + Future reload; + + synchronized (this) { + Renewal renewal = executor == null ? null : renewals.get(factory); + + reload = renewal == null ? null : executor.submit(renewal::reload); + } - if (renewal == null) + if (reload == null) factory.init(); - else - renewal.reload(); + else { + try { + U.get(reload); + } + catch (IgniteInterruptedCheckedException e) { + reload.cancel(true); + + throw e; + } + } } /** */ @@ -164,7 +188,7 @@ private Renewal(SslContextFactory factory, String transports) { } /** {@inheritDoc} */ - @Override public synchronized void run() { + @Override public void run() { if (stopping()) return; @@ -199,7 +223,7 @@ private Renewal(SslContextFactory factory, String transports) { } /** */ - private synchronized void reload() { + private void reload() { factory.init(); resetFailures(); @@ -213,7 +237,7 @@ private void resetFailures() { } /** */ - private synchronized void schedule() { + private void schedule() { schedule(factory.getRenewalTime()); } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 807afece0cfea..e531684b0a501 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -19,16 +19,22 @@ import java.util.Optional; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; +import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.processors.metric.GridMetricManager; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; @@ -51,6 +57,7 @@ import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -108,6 +115,19 @@ public void setUp() { when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); when(executor.isTerminated()).thenReturn(true); + doAnswer(call -> { + call.getArgument(0).run(); + + return null; + }).when(executor).execute(any(Runnable.class)); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask task = new FutureTask<>(call.getArgument(0), null); + + task.run(); + + return task; + }); + when(executor.schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS))).thenAnswer(call -> { scheduled = call.getArgument(0); delay = call.getArgument(1); @@ -185,6 +205,62 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { executorFactory.verifyNoInteractions(); } + /** */ + @Test + public void testManualReloadFollowsInitialScheduling() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch schedulingStarted = new CountDownLatch(1); + CountDownLatch allowScheduling = new CountDownLatch(1); + CountDownLatch reloadSubmitted = new CountDownLatch(1); + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); + + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); + doAnswer(call -> { + schedulingStarted.countDown(); + assertTrue(allowScheduling.await(10, SECONDS)); + + return call.callRealMethod(); + }).when(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + doAnswer(call -> { + renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); + + return null; + }).when(factory).init(); + doAnswer(call -> { + Object fut = call.callRealMethod(); + + reloadSubmitted.countDown(); + + return fut; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + + IgniteInternalFuture reload = null; + + try { + registry.start(ctx); + + assertTrue(schedulingStarted.await(5, SECONDS)); + + reload = runAsync(() -> registry.reload(factory)); + + assertTrue(reloadSubmitted.await(5, SECONDS)); + verify(factory, never()).init(); + } + finally { + allowScheduling.countDown(); + + if (reload != null) + reload.get(5_000); + } + + ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); + + verify(renewalExecutor, times(2)).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); + assertTrue(delays.getValue() > HOURS.toMillis(2)); + } + /** */ @Test public void testFailuresIncreaseRetryDelayUntilLimit() { @@ -227,7 +303,7 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { /** */ @Test - public void testManualReloadReplacesRetryAndResetsFailures() { + public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); registry.register("communication", factory); @@ -261,7 +337,10 @@ public void testFailedManualReloadKeepsRetryAndFailures() { doThrow(err).when(factory).init(); - assertSame(err, assertThrows(IgniteException.class, () -> registry.reload(factory))); + IgniteCheckedException actual = assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)); + + assertSame(err, actual.getCause()); + assertEquals(err.getMessage(), actual.getMessage()); assertEquals(1, failures()); verify(retry, never()).cancel(false); verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); @@ -269,7 +348,47 @@ public void testFailedManualReloadKeepsRetryAndFailures() { /** */ @Test - public void testMetricKeepsOtherFactoryFailuresAfterReload() { + public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); + BlockingQueue attempts = new LinkedBlockingQueue<>(); + + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); + doAnswer(call -> { + renewalTime.set(System.currentTimeMillis()); + + return null; + }).when(factory).init(); + doAnswer(call -> { + attempts.add(true); + + throw new IgniteException("Source unavailable"); + }).when(factory).renew(any()); + doAnswer(call -> { + Future reload = (Future)call.callRealMethod(); + + reload.get(5, SECONDS); + assertNotNull(attempts.poll(5, SECONDS)); + + FutureTask afterAttempt = new FutureTask<>(() -> { }, null); + + renewalExecutor.execute(afterAttempt); + afterAttempt.get(5, SECONDS); + + return reload; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + registry.start(ctx); + + registry.reload(factory); + + assertEquals(1, failures()); + } + + /** */ + @Test + public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { SslContextFactory otherFactory = mock(SslContextFactory.class); when(otherFactory.isRenewalEnabled()).thenReturn(true); @@ -327,6 +446,7 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { return null; }).when(factory).renew(any()); doAnswer(call -> { + assertEquals("operator", OperationContext.get(caller)); renewalTime.set(System.currentTimeMillis()); return null; @@ -445,6 +565,124 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { assertTrue(interrupted.get()); } + /** */ + @Test + public void testStopCancelsQueuedReload() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + BlockingQueue> submissions = new LinkedBlockingQueue<>(); + + doAnswer(call -> { + attemptStarted.countDown(); + assertTrue(allowCompletion.await(10, SECONDS)); + + return null; + }).when(factory).renew(any()); + doAnswer(call -> { + Future fut = (Future)call.callRealMethod(); + + submissions.add(fut); + + return fut; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + + IgniteInternalFuture reload = null; + Future queuedReload = null; + + try { + registry.start(ctx); + + assertTrue(attemptStarted.await(5, SECONDS)); + + reload = runAsync(() -> assertThrows(IgniteCheckedException.class, () -> registry.reload(factory))); + queuedReload = submissions.poll(5, SECONDS); + + assertNotNull(queuedReload); + + registry.stop(); + + assertTrue(reload.get(5_000).getCause() instanceof CancellationException); + verify(factory, never()).init(); + } + finally { + allowCompletion.countDown(); + + if (queuedReload != null) + queuedReload.cancel(true); + + if (reload != null) + reload.get(5_000); + } + } + + /** */ + @Test + public void testInterruptedCallerCancelsQueuedReload() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + CountDownLatch reloadSubmitted = new CountDownLatch(1); + AtomicReference caller = new AtomicReference<>(); + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); + + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); + doAnswer(call -> { + attemptStarted.countDown(); + assertTrue(allowCompletion.await(10, SECONDS)); + renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); + + return null; + }).when(factory).renew(any()); + doAnswer(call -> { + Object fut = call.callRealMethod(); + + reloadSubmitted.countDown(); + + return fut; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + + IgniteInternalFuture reload = null; + + try { + registry.start(ctx); + + assertTrue(attemptStarted.await(5, SECONDS)); + + reload = runAsync(() -> { + caller.set(Thread.currentThread()); + + assertThrows(IgniteInterruptedCheckedException.class, () -> registry.reload(factory)); + + return Thread.currentThread().isInterrupted(); + }); + + assertTrue(reloadSubmitted.await(5, SECONDS)); + + caller.get().interrupt(); + + assertTrue(reload.get(5_000)); + + allowCompletion.countDown(); + renewalExecutor.submit(() -> { }).get(5, SECONDS); + + verify(factory, never()).init(); + } + finally { + allowCompletion.countDown(); + + if (caller.get() != null) + caller.get().interrupt(); + + if (reload != null) + reload.get(5_000); + } + } + /** */ @Test public void testStoppingDuringLoadDoesNotCountFailure() { @@ -466,4 +704,14 @@ public void testStoppingDuringLoadDoesNotCountFailure() { private long failures() { return metrics.findMetric("ConsecutiveRenewalFailures").value(); } + + /** */ + private ScheduledExecutorService useRealExecutor() { + ScheduledExecutorService renewalExecutor = spy(new IgniteScheduledThreadPoolExecutor("ssl-renewal", null, 1)); + + executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) + .thenReturn(renewalExecutor); + + return renewalExecutor; + } } From 33ea1c83f36e174c9ebd98e7c654cd3025462361 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 16:20:58 +0300 Subject: [PATCH 214/242] IGNITE-29211 Publish renewal dates after a successful load SslContextFactory no longer declares synchronized methods. It publishes renewal dates only after the whole load succeeds, so getRenewalTime() reads a volatile field. renew() builds and installs the context under the factory monitor and passes the replacement mode as a parameter. The registry reload no longer takes the registry monitor. The registry test bounds stop() in tearDown, so a broken shutdown fails the test instead of hanging it. Co-Authored-By: Claude Opus 5.5 --- .../apache/ignite/ssl/SslContextFactory.java | 69 +++++++++++-------- .../processors/ssl/SslFactoryRegistry.java | 10 +-- .../ssl/SslFactoryRegistryTest.java | 10 ++- .../ssl/SslContextFactoryRenewalTest.java | 2 +- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index c75cbddf058a2..3b5af3563c490 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -39,6 +39,7 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.apache.ignite.IgniteException; import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; @@ -98,13 +99,13 @@ public class SslContextFactory extends AbstractSslContextFactory { private boolean renewalEnabled; /** */ - private transient long renewalTime; + private transient volatile long renewalTime; /** */ private transient long expirationTime; - /** */ - private transient boolean renewing; + /** Key store read by the load in progress. */ + private transient KeyStore loadedKeyStore; /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. @@ -127,7 +128,7 @@ public void setRenewalEnabled(boolean enabled) { * * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} when disabled or not initialized. */ - public synchronized long getRenewalTime() { + public long getRenewalTime() { return renewalEnabled ? renewalTime : 0; } @@ -139,41 +140,49 @@ public synchronized long getRenewalTime() { * @param cancelled Cancellation condition checked after waiting for any active load. * @throws org.apache.ignite.IgniteException If replacement certificates cannot be loaded or accepted. */ - public synchronized void renew(BooleanSupplier cancelled) { - if (cancelled.getAsBoolean() || !renewalEnabled || renewalTime == 0 || renewalTime > System.currentTimeMillis()) + public void renew(BooleanSupplier cancelled) { + if (!renewalEnabled) return; - renewing = true; + synchronized (this) { + if (cancelled.getAsBoolean() || renewalTime == 0 || renewalTime > System.currentTimeMillis()) + return; - try { - init(); - } - finally { - renewing = false; + try { + sslCtx.set(load(true)); + } + catch (SSLException e) { + throw new IgniteException(e); + } } } /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { - long prevRenewalTime = renewalTime; - long prevExpirationTime = expirationTime; - boolean initialized = false; + return load(false); + } + /** + * Builds an SSL context and, with renewal enabled, accepts the dates of the key store it loaded. + * + * @param replacement Whether the loaded certificates replace the current ones automatically. + * @return Initialized SSL context. + * @throws SSLException If the context cannot be built or the certificates cannot be accepted. + */ + private SSLContext load(boolean replacement) throws SSLException { try { SSLContext ctx = super.createSslContext(); - if (renewing && expirationTime < System.currentTimeMillis()) - throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + expirationTime + ']'); - - initialized = true; + if (renewalEnabled) + acceptCertificateDates(loadedKeyStore, replacement); return ctx; } + catch (GeneralSecurityException e) { + throw new SSLException("Failed to read certificates [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + } finally { - if (!initialized) { - renewalTime = prevRenewalTime; - expirationTime = prevExpirationTime; - } + loadedKeyStore = null; } } @@ -357,7 +366,7 @@ public static TrustManager getDisabledTrustManager() { KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); if (renewalEnabled) - readCertificateDates(keyStore); + loadedKeyStore = keyStore; keyMgrFactory.init(keyStore, keyStorePwd); @@ -373,11 +382,15 @@ public static TrustManager getDisabledTrustManager() { } /** + * Reads renewal dates from the loaded key store. A replacement also requires every certificate in its key chains + * to be valid now and the earliest expiry to be later than the current one. + * * @param store Loaded key store. + * @param replacement Whether the certificates replace the current ones automatically. * @throws GeneralSecurityException If a key or certificate cannot be read. - * @throws SSLException If the certificate chains cannot be used for renewal. + * @throws SSLException If the certificates cannot be accepted. */ - private void readCertificateDates(KeyStore store) throws GeneralSecurityException, SSLException { + private void acceptCertificateDates(KeyStore store, boolean replacement) throws GeneralSecurityException, SSLException { long earliestExpiry = Long.MAX_VALUE; long selectedStart = Long.MAX_VALUE; Date now = new Date(); @@ -401,7 +414,7 @@ private void readCertificateDates(KeyStore store) throws GeneralSecurityExceptio X509Certificate x509 = (X509Certificate)cert; - if (renewing) { + if (replacement) { try { x509.checkValidity(now); } @@ -425,7 +438,7 @@ private void readCertificateDates(KeyStore store) throws GeneralSecurityExceptio if (earliestExpiry == Long.MAX_VALUE) throw new SSLException("Certificate renewal requires a private key with an X.509 certificate chain."); - if (renewing && earliestExpiry <= expirationTime) + if (replacement && earliestExpiry <= expirationTime) throw new SSLException("Replacement certificates must have a later earliest expiry [current=" + expirationTime + ", replacement=" + earliestExpiry + ']'); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 96a9127e28d5f..86b1f290a5f5c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -140,13 +140,9 @@ public synchronized void stop() { /** Reloads the factory and resets its automatic renewal schedule and failure count. */ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - Future reload; - - synchronized (this) { - Renewal renewal = executor == null ? null : renewals.get(factory); - - reload = renewal == null ? null : executor.submit(renewal::reload); - } + ScheduledExecutorService executor = this.executor; + Renewal renewal = executor == null ? null : renewals.get(factory); + Future reload = renewal == null ? null : executor.submit(renewal::reload); if (reload == null) factory.init(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index e531684b0a501..83acc1f13ee6a 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -144,9 +144,13 @@ public void setUp() { /** */ @After - public void tearDown() { - registry.stop(); - executorFactory.close(); + public void tearDown() throws Exception { + try { + runAsync(registry::stop).get(10_000); + } + finally { + executorFactory.close(); + } } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 064822b0bab0e..bc6de68ea7091 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -386,7 +386,7 @@ private SlowTrustStoreFactory() { IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); - assertTrue(err.getCause().getMessage().contains("expired while loading")); + assertTrue(err.getCause().getMessage().contains("alias=replacement")); ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); From 5c0f0a82bc53a4ab7ccfe52787f0dfa2e68ed5ac Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 16:38:29 +0300 Subject: [PATCH 215/242] IGNITE-29211 Cancel a waiting renewal through thread interruption renew() no longer takes a cancellation callback. It checks the interrupt flag after it gets the factory monitor and before it reads the source. stop() already interrupts the renewal thread with shutdownNow(), so an attempt that waited for another load of the same factory exits without reading the source. Co-Authored-By: Claude Opus 5.5 --- .../apache/ignite/ssl/SslContextFactory.java | 15 +++--- .../processors/ssl/SslFactoryRegistry.java | 2 +- .../ssl/SslFactoryRegistryTest.java | 49 +++++-------------- .../ssl/SslContextFactoryRenewalTest.java | 37 ++++++++------ 4 files changed, 46 insertions(+), 57 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 3b5af3563c490..df26526711f7a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -31,7 +31,6 @@ import java.util.Arrays; import java.util.Date; import java.util.Enumeration; -import java.util.function.BooleanSupplier; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -40,6 +39,7 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; @@ -135,19 +135,22 @@ public long getRenewalTime() { /** * Reloads certificates when their renewal window has started. Disabled renewal and a future window are skipped. * Replacement certificates must be within their validity dates and have a later earliest expiry; - * otherwise the current context is kept. + * otherwise the current context is kept. An interrupted thread does not load certificates. * - * @param cancelled Cancellation condition checked after waiting for any active load. - * @throws org.apache.ignite.IgniteException If replacement certificates cannot be loaded or accepted. + * @throws IgniteInterruptedException If the calling thread is interrupted. + * @throws IgniteException If replacement certificates cannot be loaded or accepted. */ - public void renew(BooleanSupplier cancelled) { + public void renew() { if (!renewalEnabled) return; synchronized (this) { - if (cancelled.getAsBoolean() || renewalTime == 0 || renewalTime > System.currentTimeMillis()) + if (renewalTime == 0 || renewalTime > System.currentTimeMillis()) return; + if (Thread.currentThread().isInterrupted()) + throw new IgniteInterruptedException("Certificate renewal was interrupted before loading."); + try { sslCtx.set(load(true)); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 86b1f290a5f5c..637d1bcf00f81 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -192,7 +192,7 @@ private Renewal(SslContextFactory factory, String transports) { long nextAttempt = 0; try { - factory.renew(SslFactoryRegistry.this::stopping); + factory.renew(); nextAttempt = factory.getRenewalTime(); } catch (Exception e) { diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 83acc1f13ee6a..b0596439232ce 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -29,7 +29,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BooleanSupplier; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.GridKernalContext; @@ -268,7 +267,7 @@ public void testManualReloadFollowsInitialScheduling() throws Exception { /** */ @Test public void testFailuresIncreaseRetryDelayUntilLimit() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); registry.start(ctx); @@ -289,7 +288,7 @@ public void testFailuresIncreaseRetryDelayUntilLimit() { /** */ @Test public void testNormalReturnResetsFailuresAndReadsNextWindow() { - doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); registry.register("communication", factory); registry.start(ctx); @@ -308,7 +307,7 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { /** */ @Test public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); registry.start(ctx); @@ -330,7 +329,7 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { /** */ @Test public void testFailedManualReloadKeepsRetryAndFailures() { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); registry.start(ctx); @@ -367,7 +366,7 @@ public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Excepti attempts.add(true); throw new IgniteException("Source unavailable"); - }).when(factory).renew(any()); + }).when(factory).renew(); doAnswer(call -> { Future reload = (Future)call.callRealMethod(); @@ -397,8 +396,8 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { when(otherFactory.isRenewalEnabled()).thenReturn(true); when(otherFactory.getRenewalTime()).thenReturn(1L); - doThrow(new IgniteException("Source unavailable")).when(factory).renew(any()); - doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(any()); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(); registry.register("communication", factory); registry.register("client connector", otherFactory); @@ -419,7 +418,7 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { /** */ @Test public void testMetricResetDoesNotCorruptFailureCount() { - doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(any()); + doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); registry.register("communication", factory); registry.start(ctx); @@ -448,7 +447,7 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { attemptCallers.add(Optional.ofNullable(OperationContext.get(caller))); return null; - }).when(factory).renew(any()); + }).when(factory).renew(); doAnswer(call -> { assertEquals("operator", OperationContext.get(caller)); renewalTime.set(System.currentTimeMillis()); @@ -484,28 +483,6 @@ public void testStoppingNodeDoesNotStartRenewal() { executorFactory.verifyNoInteractions(); } - /** */ - @Test - public void testRenewalObservesNodeStop() { - doAnswer(call -> { - BooleanSupplier cancelled = call.getArgument(0); - - assertFalse(cancelled.getAsBoolean()); - - when(ctx.isStopping()).thenReturn(true); - - assertTrue(cancelled.getAsBoolean()); - - return null; - }).when(factory).renew(any()); - - registry.register("communication", factory); - registry.start(ctx); - scheduled.run(); - - verify(factory).renew(any()); - } - /** */ @Test public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { @@ -538,7 +515,7 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { interrupted.set(true); return null; - }).when(factory).renew(any()); + }).when(factory).renew(); registry.register("communication", factory); @@ -582,7 +559,7 @@ public void testStopCancelsQueuedReload() throws Exception { assertTrue(allowCompletion.await(10, SECONDS)); return null; - }).when(factory).renew(any()); + }).when(factory).renew(); doAnswer(call -> { Future fut = (Future)call.callRealMethod(); @@ -639,7 +616,7 @@ public void testInterruptedCallerCancelsQueuedReload() throws Exception { renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); return null; - }).when(factory).renew(any()); + }).when(factory).renew(); doAnswer(call -> { Object fut = call.callRealMethod(); @@ -694,7 +671,7 @@ public void testStoppingDuringLoadDoesNotCountFailure() { when(ctx.isStopping()).thenReturn(true); throw new IgniteException("Load interrupted by node stop"); - }).when(factory).renew(any()); + }).when(factory).renew(); registry.register("communication", factory); registry.start(ctx); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index bc6de68ea7091..ba0ac62e3f8f4 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -25,6 +25,7 @@ import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.testframework.GridTestThread; import org.junit.Before; @@ -64,7 +65,7 @@ public void testRenewalIsDisabledByDefault() { assertFalse(factory.isRenewalEnabled()); assertEquals(0, factory.getRenewalTime()); - factory.renew(() -> false); + factory.renew(); } /** */ @@ -180,22 +181,29 @@ public void testRenewBeforeWindowDoesNotLoad() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); factory.create(); - factory.renew(() -> false); + factory.renew(); assertEquals(1, factory.keyStoreLoads); } /** */ @Test - public void testCancellationWhileWaitingForFactorySkipsRenewal() throws Exception { + public void testInterruptWhileWaitingForFactorySkipsRenewal() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - AtomicBoolean cancelled = new AtomicBoolean(); - GridTestThread attempt = new GridTestThread(() -> factory.renew(cancelled::get)); + AtomicBoolean interrupted = new AtomicBoolean(); + GridTestThread attempt = new GridTestThread(() -> { + try { + factory.renew(); + } + catch (IgniteInterruptedException ignored) { + interrupted.set(true); + } + }); try { synchronized (factory) { @@ -205,7 +213,7 @@ public void testCancellationWhileWaitingForFactorySkipsRenewal() throws Exceptio assertTrue(waitForCondition(() -> attempt.getState() == BLOCKED, () -> System.currentTimeMillis() < deadline)); - cancelled.set(true); + attempt.interrupt(); } } finally { @@ -214,6 +222,7 @@ public void testCancellationWhileWaitingForFactorySkipsRenewal() throws Exceptio assertFalse(attempt.isAlive()); attempt.checkError(); + assertTrue(interrupted.get()); assertEquals(1, factory.keyStoreLoads); assertSame(ctx, factory.create()); } @@ -226,7 +235,7 @@ public void testRenewReplacesExpiredContext() throws Exception { SSLContext ctx = factory.create(); factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - factory.renew(() -> false); + factory.renew(); assertNotSame(ctx, factory.create()); assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); @@ -242,7 +251,7 @@ public void testRenewRejectsUnchangedExpiry() throws Exception { factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(80), now + HOURS.toMillis(10)); - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("later earliest expiry")); assertSame(ctx, factory.create()); @@ -259,7 +268,7 @@ public void testRenewRejectsExpiredIssuer() throws Exception { factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); assertSame(ctx, factory.create()); @@ -280,7 +289,7 @@ public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { notYetValidKeys.getCertificateChain(notYetValidAlias)); factory.keyStore = replacement; - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("alias=" + notYetValidAlias)); assertSame(ctx, factory.create()); @@ -294,7 +303,7 @@ public void testManualReloadAcceptsEarlierExpiryAfterRenewalFailure() throws Exc authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("later earliest expiry")); @@ -318,14 +327,14 @@ public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Except factory.setTrustStoreFilePath("missing-trust-store"); factory.setTrustStorePassword(PASSWORD); - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("missing-trust-store")); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); - factory.renew(() -> false); + factory.renew(); assertNotSame(ctx, factory.create()); assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); @@ -384,7 +393,7 @@ private SlowTrustStoreFactory() { factory.setTrustStoreFilePath("slow-trust-store"); factory.setTrustStorePassword(PASSWORD); - IgniteException err = assertThrows(IgniteException.class, () -> factory.renew(() -> false)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("alias=replacement")); ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); From e5f996c7dd2343b476b7c88444511b6c9753440b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 17:12:30 +0300 Subject: [PATCH 216/242] IGNITE-29211 Fix renewal startup ordering and expiry checks --- .../apache/ignite/ssl/SslContextFactory.java | 3 + .../processors/ssl/SslFactoryRegistry.java | 10 ++- .../ssl/SslFactoryRegistryTest.java | 62 ++++++++------- .../ssl/SslContextFactoryRenewalTest.java | 77 ++++++------------- 4 files changed, 68 insertions(+), 84 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index df26526711f7a..66d1ba066fc4e 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -445,6 +445,9 @@ private void acceptCertificateDates(KeyStore store, boolean replacement) throws throw new SSLException("Replacement certificates must have a later earliest expiry [current=" + expirationTime + ", replacement=" + earliestExpiry + ']'); + if (replacement && earliestExpiry < System.currentTimeMillis()) + throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); + expirationTime = earliestExpiry; renewalTime = earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION); } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 637d1bcf00f81..9971ffdb87bfd 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -140,9 +140,13 @@ public synchronized void stop() { /** Reloads the factory and resets its automatic renewal schedule and failure count. */ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - ScheduledExecutorService executor = this.executor; - Renewal renewal = executor == null ? null : renewals.get(factory); - Future reload = renewal == null ? null : executor.submit(renewal::reload); + Future reload; + + synchronized (this) { + Renewal renewal = executor == null ? null : renewals.get(factory); + + reload = renewal == null ? null : executor.submit(renewal::reload); + } if (reload == null) factory.init(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index b0596439232ce..bdd4ac71854fb 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,6 +17,8 @@ package org.apache.ignite.internal.processors.ssl; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CancellationException; @@ -47,6 +49,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -54,8 +57,8 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -65,6 +68,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -212,55 +216,57 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { @Test public void testManualReloadFollowsInitialScheduling() throws Exception { ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch schedulingStarted = new CountDownLatch(1); - CountDownLatch allowScheduling = new CountDownLatch(1); - CountDownLatch reloadSubmitted = new CountDownLatch(1); AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); + AtomicReference caller = new AtomicReference<>(); + AtomicReference> reload = new AtomicReference<>(); when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); doAnswer(call -> { - schedulingStarted.countDown(); - assertTrue(allowScheduling.await(10, SECONDS)); + IgniteInternalFuture fut = runAsync(() -> { + caller.set(Thread.currentThread()); + + registry.reload(factory); + }); + + reload.set(fut); + + long deadline = System.nanoTime() + SECONDS.toNanos(5); + + assertTrue(waitForCondition(() -> { + if (fut.isDone()) + return true; + + Thread thread = caller.get(); + ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); + + return info != null && info.getThreadState() == Thread.State.BLOCKED && + info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); + }, () -> System.nanoTime() < deadline)); return call.callRealMethod(); - }).when(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + }).when(renewalExecutor).execute(any(Runnable.class)); doAnswer(call -> { renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); return null; }).when(factory).init(); - doAnswer(call -> { - Object fut = call.callRealMethod(); - - reloadSubmitted.countDown(); - - return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); registry.register("communication", factory); - IgniteInternalFuture reload = null; - try { registry.start(ctx); - - assertTrue(schedulingStarted.await(5, SECONDS)); - - reload = runAsync(() -> registry.reload(factory)); - - assertTrue(reloadSubmitted.await(5, SECONDS)); - verify(factory, never()).init(); } finally { - allowScheduling.countDown(); - - if (reload != null) - reload.get(5_000); + if (reload.get() != null) + reload.get().get(5_000); } ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); + InOrder order = inOrder(renewalExecutor, factory); - verify(renewalExecutor, times(2)).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); + order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); + order.verify(factory).init(); + order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); assertTrue(delays.getValue() > HOURS.toMillis(2)); } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index ba0ac62e3f8f4..6a5879fa623fb 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -18,15 +18,15 @@ package org.apache.ignite.ssl; import java.security.KeyStore; +import java.security.cert.Certificate; import java.security.cert.X509Certificate; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Date; import javax.crypto.spec.SecretKeySpec; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteInterruptedException; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.testframework.GridTestThread; import org.junit.Before; import org.junit.Test; @@ -42,6 +42,9 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; /** */ public class SslContextFactoryRenewalTest { @@ -195,15 +198,7 @@ public void testInterruptWhileWaitingForFactorySkipsRenewal() throws Exception { factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - AtomicBoolean interrupted = new AtomicBoolean(); - GridTestThread attempt = new GridTestThread(() -> { - try { - factory.renew(); - } - catch (IgniteInterruptedException ignored) { - interrupted.set(true); - } - }); + GridTestThread attempt = new GridTestThread(() -> assertThrows(IgniteInterruptedException.class, factory::renew)); try { synchronized (factory) { @@ -222,7 +217,6 @@ public void testInterruptWhileWaitingForFactorySkipsRenewal() throws Exception { assertFalse(attempt.isAlive()); attempt.checkError(); - assertTrue(interrupted.get()); assertEquals(1, factory.keyStoreLoads); assertSame(ctx, factory.create()); } @@ -342,60 +336,37 @@ public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Except /** */ @Test - public void testRenewRejectsCertificatesExpiredDuringTrustStoreLoad() throws Exception { - KeyStore trustStore = authority.trustStore(); + public void testRenewRejectsCertificatesExpiredDuringDateValidation() throws Exception { KeyStore curKeys = authority.keyStore("expired", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); KeyStore validKeys = authority.keyStore("valid", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); curKeys.setKeyEntry("valid", validKeys.getKey("valid", PASSWORD), PASSWORD, validKeys.getCertificateChain("valid")); - /** */ - class SlowTrustStoreFactory extends InMemorySslContextFactory { - /** */ - private static final long serialVersionUID = 0L; - - /** */ - private long replacementExpiry; - - /** */ - private SlowTrustStoreFactory() { - super(curKeys); - } - - /** {@inheritDoc} */ - @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { - if (!"slow-trust-store".equals(path)) - return super.loadKeyStore(type, path, pwd); - - try { - long deadline = System.currentTimeMillis() + 10_000; + InMemorySslContextFactory factory = new InMemorySslContextFactory(curKeys); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + long expiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; + KeyStore replacement = authority.keyStore("replacement", now - HOURS.toMillis(1), expiry); + Certificate[] chain = replacement.getCertificateChain("replacement"); + X509Certificate cert = spy((X509Certificate)chain[0]); - assertTrue(waitForCondition(() -> System.currentTimeMillis() > replacementExpiry, - () -> System.currentTimeMillis() < deadline)); - } - catch (IgniteInterruptedCheckedException e) { - Thread.currentThread().interrupt(); + doAnswer(validityCheck -> { + validityCheck.callRealMethod(); - throw new SSLException(e); - } + long deadline = System.currentTimeMillis() + 10_000; - return trustStore; - } - } + assertTrue(waitForCondition(() -> System.currentTimeMillis() > expiry, () -> System.currentTimeMillis() < deadline)); - SlowTrustStoreFactory factory = new SlowTrustStoreFactory(); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); + return null; + }).when(cert).checkValidity(any(Date.class)); - factory.replacementExpiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; - factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), factory.replacementExpiry); - factory.setTrustManagers((TrustManager[])null); - factory.setTrustStoreFilePath("slow-trust-store"); - factory.setTrustStorePassword(PASSWORD); + chain[0] = cert; + replacement.setKeyEntry("replacement", replacement.getKey("replacement", PASSWORD), PASSWORD, chain); + factory.keyStore = replacement; IgniteException err = assertThrows(IgniteException.class, factory::renew); - assertTrue(err.getCause().getMessage().contains("alias=replacement")); + assertTrue(err.getCause().getMessage().contains("expired while loading")); ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); From 997e016d1d1e117de5254a737c261365d004bb68 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 17:39:51 +0300 Subject: [PATCH 217/242] IGNITE-29211 Explain certificate renewal as an operating procedure --- docs/_docs/security/ssl-tls.adoc | 87 ++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index a783d00307576..8c815144dc1ca 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -190,7 +190,14 @@ If you configured `trustManagers` directly, the command reuses them instead of r == Automatic Certificate Renewal -To load replacement certificates automatically, enable renewal on each `SslContextFactory` before starting the node: +Automatic renewal lets nodes load replacement certificates without a reload command. +You must supply the replacements: Ignite reads them from the configured source and does not issue certificates. +Renewal covers the same TCP transports as <>. +It does not update the HTTP REST endpoint or external client applications. + +=== Enable Renewal + +Enable renewal on each configured `SslContextFactory` before starting the node: [source,java] ---- @@ -198,33 +205,57 @@ factory.setRenewalEnabled(true); ---- Changing this setting requires a node restart. -Renewal covers the same TCP transports as the reload command; it does not cover the HTTP REST endpoint or external client applications. -Ignite loads certificates from the configured source; it does not issue them. -Arrange for replacement stores to become available there, and follow the trust-first sequence above when changing certificate authorities. - -The factory reads renewal dates from the key store it actually loads. -It selects the key chain that expires first and starts renewal with 15% of its lifetime remaining. -This lifetime runs from the key certificate's validity start to the earliest expiry in its chain, including issuing certificates. -For chains with the same expiry, it uses the longest lifetime. -With renewal enabled, startup fails unless the store contains at least one private key and every private key has an X.509 chain. - -Every certificate in an automatic replacement's key chains must be within its validity dates, and the earliest expiry must move later. -Otherwise, Ignite keeps the current context and retries, even after its certificates expire. -The retry delay starts at one minute, doubles to one hour, and includes a random addition of up to half that delay. -Established connections remain open; new connections use an accepted replacement. - -Check the node log for the next attempt, affected transports and any load or validation errors. -The `ssl.ConsecutiveRenewalFailures` metric adds up consecutive automatic failures across the node's factories. -A successful renewal or manual reload clears that factory's count. -A skipped attempt also clears it, for example when another node sharing the factory has already renewed it. - -If renewal fails, correct the replacement store and run `control.sh --ssl reload` to apply it immediately. -Manual reload requires a key chain but allows unchanged expiry dates, so it can apply a trust-store change alone. -After a successful manual reload, Ignite recalculates the next attempt from the loaded certificates. - -Custom sources can extend javadoc:org.apache.ignite.ssl.SslContextFactory[] and override `loadKeyStore()` or `openFileInputStream()`. -Limit the duration of source requests: a blocked load delays manual reload and can delay node shutdown. -Factories extending only `AbstractSslContextFactory` support manual reload but do not participate in automatic renewal. +The key store must contain a private key, and every private key must have an X.509 certificate chain. +A chain contains the key's certificate and the certificates of its issuers. +With renewal enabled, a store that does not meet these requirements prevents node startup. + +Before replacing key stores, prepare peers' trust stores as described in <>. +Once every affected peer trusts the replacement certificates, put the replacement key stores in the configured source. +Keep the configured paths, store types and passwords unchanged. +Ignite loads the replacements automatically when renewal is due; no reload command is needed for this step. + +=== When Renewal Runs + +A chain expires when any certificate in it expires, including an issuing certificate. +Ignite selects the chain that expires first and starts renewal attempts with 15% of its lifetime remaining. +If this point has already passed when the node starts, Ignite schedules an attempt immediately. +See link:{javadoc_base_url}/org/apache/ignite/ssl/SslContextFactory.html#getRenewalTime()[getRenewalTime(), window=_blank] for the calculation. + +Ignite accepts an automatic replacement only when: + +* Every certificate in its key chains is currently valid. +* The earliest expiry among those chains is later than the earliest expiry of the certificates currently in use. + +New connections use the accepted certificates; established connections remain open. +To confirm a replacement, open a new TLS connection to each affected endpoint and compare its peer certificate with the replacement. + +=== Check Failures and Recover + +If loading or validation fails, Ignite keeps the current certificates and retries, even after they expire. +Keeping expired certificates does not extend their validity, so new connections may fail until a valid replacement is available. +Retry intervals grow from 1–1.5 minutes to 60–90 minutes. +The base delay doubles after each failure, with a random addition of up to 50%. + +Check the node log for the next attempt, affected transports and the reason for a failure. +The `ssl.ConsecutiveRenewalFailures` metric sums consecutive failed automatic attempts across the node's factories. +Each factory's count resets after a successful renewal or manual reload. +It also resets if an automatic attempt finds that renewal is no longer due. + +To recover without waiting for the next retry: + +. Correct the replacement stores and run link:tools/control-script#reloading-tls-certificates[`control.sh --ssl reload`]. +. Check the command result and verify a new connection with the replacement certificates. + +Manual reload does not enforce certificate validity dates or require later expiry. +It can therefore apply a trust-store change alone or restore previous certificates. +After a successful manual reload, Ignite recalculates the schedule and attempts renewal immediately if it is already due. +If TLS errors prevent the command from connecting, install valid key and trust stores that restore mutual trust, then restart the affected nodes. + +=== Custom Certificate Sources + +For automatic renewal, custom sources require a subclass of javadoc:org.apache.ignite.ssl.SslContextFactory[]. +Factories extending only `AbstractSslContextFactory` support manual reload only. +Limit request duration in custom sources: a blocked load can delay other renewals, manual reload and node shutdown. == SslContextFactory Properties From bf5ed264176b9e1001acf663158707f26e98791f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 17:52:15 +0300 Subject: [PATCH 218/242] IGNITE-29211 Clarify renewal skips and failure contracts --- .../apache/ignite/ssl/SslContextFactory.java | 30 +++++++++++-------- .../processors/ssl/SslFactoryRegistry.java | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 66d1ba066fc4e..aebf9c81b2c96 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -122,23 +122,28 @@ public void setRenewalEnabled(boolean enabled) { } /** - * The window covers the last 15% of the earliest-expiring key chain's lifetime. - * Its lifetime runs from the leaf's {@code notBefore} to the earliest {@code notAfter} in the chain. - * Chains with the same expiry use the longest lifetime. + * The renewal time is calculated from the certificates loaded with renewal enabled. + * The chain that expires first determines the window, which starts with 15% of its lifetime remaining. + * Its lifetime runs from the key certificate's {@code notBefore} to the earliest {@code notAfter} in its chain. + * For chains with the same expiry, the longest lifetime is used. * - * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} when disabled or not initialized. + * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} if renewal is disabled + * or certificates have not yet been loaded with renewal enabled. */ public long getRenewalTime() { return renewalEnabled ? renewalTime : 0; } /** - * Reloads certificates when their renewal window has started. Disabled renewal and a future window are skipped. - * Replacement certificates must be within their validity dates and have a later earliest expiry; - * otherwise the current context is kept. An interrupted thread does not load certificates. + * Replaces the current SSL context when the renewal window has started. + * Does nothing when renewal is disabled, no renewal date has been loaded, or the window has not started. + *

+ * Every certificate in the replacement's private-key chains must be valid now. + * Their earliest expiry must be later than that of the certificates currently in use. + * A failed replacement leaves the current context and renewal time unchanged. * - * @throws IgniteInterruptedException If the calling thread is interrupted. - * @throws IgniteException If replacement certificates cannot be loaded or accepted. + * @throws IgniteInterruptedException If the thread is interrupted before a due replacement starts loading. + * @throws IgniteException If the replacement cannot be loaded or accepted. */ public void renew() { if (!renewalEnabled) @@ -166,7 +171,7 @@ public void renew() { } /** - * Builds an SSL context and, with renewal enabled, accepts the dates of the key store it loaded. + * Builds an SSL context and updates renewal dates when renewal is enabled. * * @param replacement Whether the loaded certificates replace the current ones automatically. * @return Initialized SSL context. @@ -385,11 +390,10 @@ public static TrustManager getDisabledTrustManager() { } /** - * Reads renewal dates from the loaded key store. A replacement also requires every certificate in its key chains - * to be valid now and the earliest expiry to be later than the current one. + * Validates the key chains and updates renewal dates. * * @param store Loaded key store. - * @param replacement Whether the certificates replace the current ones automatically. + * @param replacement Whether to check current validity and later expiry for an automatic replacement. * @throws GeneralSecurityException If a key or certificate cannot be read. * @throws SSLException If the certificates cannot be accepted. */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 9971ffdb87bfd..bb3d0fec4d976 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -138,7 +138,7 @@ public synchronized void stop() { Thread.currentThread().interrupt(); } - /** Reloads the factory and resets its automatic renewal schedule and failure count. */ + /** Reloads the factory and, on success, resets its automatic renewal schedule and failure count. */ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { Future reload; From 65e136f0b5b0b6194a80e06af44204e5cc20b2e4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 18:14:54 +0300 Subject: [PATCH 219/242] IGNITE-29211 Explain manual reload outcomes and recovery options --- docs/_docs/security/ssl-tls.adoc | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 8c815144dc1ca..b52a4c6d1ce78 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -246,10 +246,24 @@ To recover without waiting for the next retry: . Correct the replacement stores and run link:tools/control-script#reloading-tls-certificates[`control.sh --ssl reload`]. . Check the command result and verify a new connection with the replacement certificates. -Manual reload does not enforce certificate validity dates or require later expiry. -It can therefore apply a trust-store change alone or restore previous certificates. -After a successful manual reload, Ignite recalculates the schedule and attempts renewal immediately if it is already due. -If TLS errors prevent the command from connecting, install valid key and trust stores that restore mutual trust, then restart the affected nodes. +*What a successful command means.* +Manual reload can apply a trust-store change without changing the node's certificate, or restore certificates from a backup. +It accepts certificates with unchanged or earlier expiry dates, including expired or not-yet-valid certificates. +Success means that the stores were loaded. +Other nodes and clients may still reject the certificates on a new connection. + +*What happens to automatic renewal.* +If enabled, automatic renewal continues after manual reload, using the dates of the certificates just loaded. +If it is already time to renew them, Ignite schedules an automatic attempt immediately. +If the source still returns the same certificates, the attempt fails and retries continue. +That failure does not undo the manual reload. + +*If the command cannot connect.* +First check the command's certificates and trust settings. +If the problem is on the node, put a valid replacement in its source and wait for an automatic retry. +Its earliest expiry must be later than that of the certificates currently in use. +A trust-store change alone cannot be applied by automatic renewal. +To load corrected stores without the command or waiting for a retry, restart the affected nodes. === Custom Certificate Sources From d24c9acae2e2c40650253d1560f74c286f051fb8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 19:06:26 +0300 Subject: [PATCH 220/242] IGNITE-29211 Wait one hour after reloading certificates already due for renewal --- docs/_docs/security/ssl-tls.adoc | 7 +- .../processors/ssl/SslFactoryRegistry.java | 25 ++++- .../ssl/SslFactoryRegistryTest.java | 100 +++++++++++++----- 3 files changed, 99 insertions(+), 33 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index b52a4c6d1ce78..289e2117426ce 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -221,6 +221,10 @@ Ignite selects the chain that expires first and starts renewal attempts with 15% If this point has already passed when the node starts, Ignite schedules an attempt immediately. See link:{javadoc_base_url}/org/apache/ignite/ssl/SslContextFactory.html#getRenewalTime()[getRenewalTime(), window=_blank] for the calculation. +After a successful manual or automatic reload, Ignite calculates the next attempt from the loaded certificates. +If their renewal window has already started, Ignite logs a warning and waits one hour before trying again automatically. +You can still run the manual reload command during this wait. + Ignite accepts an automatic replacement only when: * Every certificate in its key chains is currently valid. @@ -253,8 +257,7 @@ Success means that the stores were loaded. Other nodes and clients may still reject the certificates on a new connection. *What happens to automatic renewal.* -If enabled, automatic renewal continues after manual reload, using the dates of the certificates just loaded. -If it is already time to renew them, Ignite schedules an automatic attempt immediately. +If enabled, automatic renewal continues after manual reload. If the source still returns the same certificates, the attempt fails and retries continue. That failure does not undo the manual reload. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index bb3d0fec4d976..3b904c39087d8 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -39,6 +39,7 @@ import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; +import static java.util.concurrent.TimeUnit.HOURS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.stream.Collectors.joining; @@ -46,6 +47,9 @@ /** SSL factories used by running transports. */ public class SslFactoryRegistry { + /** Delay after a successful load whose renewal window has already started. */ + private static final long RENEWAL_RETRY_DELAY = HOURS.toMillis(1); + /** */ private final Map> factories = new LinkedHashMap<>(); @@ -206,8 +210,10 @@ private Renewal(SslContextFactory factory, String transports) { if (stopping()) return; - if (failure == null) + if (failure == null) { resetFailures(); + nextAttempt = nextAttemptTime(nextAttempt); + } else { failures++; consecutiveFailures.incrementAndGet(); @@ -227,7 +233,22 @@ private void reload() { factory.init(); resetFailures(); - schedule(); + schedule(nextAttemptTime(factory.getRenewalTime())); + } + + /** */ + private long nextAttemptTime(long renewalTime) { + long now = System.currentTimeMillis(); + + if (renewalTime == 0 || renewalTime > now) + return renewalTime; + + long nextAttempt = now + RENEWAL_RETRY_DELAY; + + log.warning("TLS certificates are already in their renewal window [transports=" + transports + + ", nextAttempt=" + new Date(nextAttempt) + ']'); + + return nextAttempt; } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index bdd4ac71854fb..461cc021ed812 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -33,6 +33,7 @@ import java.util.concurrent.atomic.AtomicReference; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; +import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.IgniteInterruptedCheckedException; @@ -41,6 +42,7 @@ import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.OperationContextAttribute; import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.thread.context.concurrent.OperationContextAwareScheduledExecutorService; import org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor; import org.apache.ignite.logger.NullLogger; import org.apache.ignite.spi.metric.LongMetric; @@ -66,6 +68,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -310,6 +313,59 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { assertTrue(delay > HOURS.toMillis(1)); } + /** */ + @Test + public void testSuccessfulRenewalInCurrentWindowWaitsOneHour() { + IgniteLogger log = mock(IgniteLogger.class); + + when(ctx.log(SslFactoryRegistry.class)).thenReturn(log); + + registry.register("communication", factory); + registry.start(ctx); + + long before = System.currentTimeMillis(); + + scheduled.run(); + + assertTrue(delay >= HOURS.toMillis(1) - (System.currentTimeMillis() - before)); + assertTrue(delay <= HOURS.toMillis(1)); + assertEquals(0, failures()); + verify(log).warning(startsWith("TLS certificates are already in their renewal window [transports=communication, nextAttempt=")); + } + + /** */ + @Test + public void testManualReloadInCurrentWindowSchedulesRenewalInOneHour() throws Exception { + registry.register("communication", factory); + registry.start(ctx); + + ScheduledFuture previous = pending; + long before = System.currentTimeMillis(); + + registry.reload(factory); + + verify(factory).init(); + verify(previous).cancel(false); + assertTrue(delay >= HOURS.toMillis(1) - (System.currentTimeMillis() - before)); + assertTrue(delay <= HOURS.toMillis(1)); + } + + /** */ + @Test + public void testSuccessfulRenewalKeepsFutureWindowWithinOneHour() { + registry.register("communication", factory); + registry.start(ctx); + + long before = System.currentTimeMillis(); + + when(factory.getRenewalTime()).thenReturn(before + MINUTES.toMillis(30)); + + scheduled.run(); + + assertTrue(delay >= MINUTES.toMillis(30) - (System.currentTimeMillis() - before)); + assertTrue(delay <= MINUTES.toMillis(30)); + } + /** */ @Test public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { @@ -358,38 +414,18 @@ public void testFailedManualReloadKeepsRetryAndFailures() { /** */ @Test public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); - BlockingQueue attempts = new LinkedBlockingQueue<>(); - - when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); - doAnswer(call -> { - renewalTime.set(System.currentTimeMillis()); - - return null; - }).when(factory).init(); - doAnswer(call -> { - attempts.add(true); - - throw new IgniteException("Source unavailable"); - }).when(factory).renew(); - doAnswer(call -> { - Future reload = (Future)call.callRealMethod(); - - reload.get(5, SECONDS); - assertNotNull(attempts.poll(5, SECONDS)); - - FutureTask afterAttempt = new FutureTask<>(() -> { }, null); + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask reload = new FutureTask<>(call.getArgument(0), null); - renewalExecutor.execute(afterAttempt); - afterAttempt.get(5, SECONDS); + reload.run(); + scheduled.run(); return reload; - }).when(renewalExecutor).submit(any(Runnable.class)); + }); registry.register("communication", factory); registry.start(ctx); - registry.reload(factory); assertEquals(1, failures()); @@ -446,7 +482,7 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { BlockingQueue> attemptCallers = new LinkedBlockingQueue<>(); executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) - .thenCallRealMethod(); + .thenReturn(new OperationContextAwareScheduledExecutorService(executor) { }); when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); doAnswer(call -> { renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); @@ -465,15 +501,21 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { try (Scope ignored = OperationContext.set(caller, "operator")) { registry.start(ctx); + } + + scheduled.run(); - assertEquals(Optional.empty(), attemptCallers.poll(10, SECONDS)); + assertEquals(Optional.empty(), attemptCallers.remove()); + try (Scope ignored = OperationContext.set(caller, "operator")) { registry.reload(factory); assertEquals("operator", OperationContext.get(caller)); } - assertEquals(Optional.empty(), attemptCallers.poll(10, SECONDS)); + scheduled.run(); + + assertEquals(Optional.empty(), attemptCallers.remove()); } /** */ From 16441e8229069b5c6782d4fad93bb4542dc5b701 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 19:44:36 +0300 Subject: [PATCH 221/242] IGNITE-29211 Validate replacements and report unchanged TLS material --- .../_docs/monitoring-metrics/new-metrics.adoc | 2 + docs/_docs/security/ssl-tls.adoc | 40 ++-- .../ignite/ssl/AbstractSslContextFactory.java | 2 +- .../apache/ignite/ssl/SslContextFactory.java | 186 ++++++++++++++---- .../util/GridCommandHandlerSslReloadTest.java | 28 +++ .../management/ssl/SslReloadTask.java | 6 +- .../processors/ssl/SslFactoryRegistry.java | 51 ++++- .../ssl/SslFactoryRegistryTest.java | 110 +++++++++-- .../ssl/SslContextFactoryRenewalTest.java | 161 ++++++++++++++- 9 files changed, 503 insertions(+), 83 deletions(-) diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 42dfed8abb167..4347ee329696f 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -440,6 +440,8 @@ Register name: `ssl` |=== |Name | Type | Description |ConsecutiveRenewalFailures | long | Sum of consecutive automatic renewal failures across the node's SSL factories. Zero when renewal is disabled. +|ManualReloadAttempts | long | Factory reload attempts started by the node for manual commands, including errors and unchanged material. Excludes startup loads. +|AutomaticRenewalAttempts | long | Automatic factory renewal calls, including errors, unchanged material and calls that find renewal is no longer due. |=== See link:security/ssl-tls#automatic-certificate-renewal[Automatic Certificate Renewal] for configuration and recovery. diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 289e2117426ce..05a54cbb75fef 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -176,11 +176,20 @@ The command asks for confirmation; use `--yes` to skip it. . Check the command result and the reload logs of any failed nodes, then verify a new connection with the replacement certificates. Established connections continue using their existing TLS context. +On reload, `SslContextFactory` checks that every certificate in each private-key chain is valid at that moment. +Replacement certificates may expire earlier than the current ones or have the same expiry date. +You can also update the trust store without replacing the node's certificate. +These checks apply even when automatic renewal is disabled. + +If the keys, certificates, trust store and TLS settings are unchanged, the command reports `unchanged` and continues with the other factories. +It keeps the existing context, automatic retry schedule and failure count. + If a factory fails to reload, it keeps its previous context, and that node stops reloading its remaining factories. Successful reloads remain in effect; other nodes continue independently. Check the affected node's log for error details. Correct the failed store and run the command again. -To restore the previous certificates, restore the backed-up files and reload again while peers still trust those certificates. +To restore the previous certificates, restore the backed-up files and reload again. +The restored certificates must still be valid and trusted by peers. Custom factories must extend javadoc:org.apache.ignite.ssl.AbstractSslContextFactory[]. They must reread key and trust data from their sources on reload and supply the updated context for new connections. @@ -221,14 +230,12 @@ Ignite selects the chain that expires first and starts renewal attempts with 15% If this point has already passed when the node starts, Ignite schedules an attempt immediately. See link:{javadoc_base_url}/org/apache/ignite/ssl/SslContextFactory.html#getRenewalTime()[getRenewalTime(), window=_blank] for the calculation. -After a successful manual or automatic reload, Ignite calculates the next attempt from the loaded certificates. +After replacing a context manually or automatically, Ignite calculates the next attempt from the loaded certificates. If their renewal window has already started, Ignite logs a warning and waits one hour before trying again automatically. You can still run the manual reload command during this wait. -Ignite accepts an automatic replacement only when: - -* Every certificate in its key chains is currently valid. -* The earliest expiry among those chains is later than the earliest expiry of the certificates currently in use. +Automatic renewal applies the same certificate checks as <>. +If the source returns unchanged material, Ignite logs a failed attempt and uses the retry intervals below. New connections use the accepted certificates; established connections remain open. To confirm a replacement, open a new TLS connection to each affected endpoint and compare its peer certificate with the replacement. @@ -242,36 +249,41 @@ The base delay doubles after each failure, with a random addition of up to 50%. Check the node log for the next attempt, affected transports and the reason for a failure. The `ssl.ConsecutiveRenewalFailures` metric sums consecutive failed automatic attempts across the node's factories. -Each factory's count resets after a successful renewal or manual reload. +Each factory's count resets when a renewal or manual reload replaces its context. It also resets if an automatic attempt finds that renewal is no longer due. +`ssl.ManualReloadAttempts` and `ssl.AutomaticRenewalAttempts` count the node's calls to reload each factory. +They include errors, unchanged material and automatic attempts that find renewal is no longer due. +A factory shared by several transports is counted once per attempt. +Startup loading and direct application calls to the factory are not counted. + To recover without waiting for the next retry: . Correct the replacement stores and run link:tools/control-script#reloading-tls-certificates[`control.sh --ssl reload`]. . Check the command result and verify a new connection with the replacement certificates. *What a successful command means.* -Manual reload can apply a trust-store change without changing the node's certificate, or restore certificates from a backup. -It accepts certificates with unchanged or earlier expiry dates, including expired or not-yet-valid certificates. -Success means that the stores were loaded. -Other nodes and clients may still reject the certificates on a new connection. +The result is `reloaded` when new material or settings were applied, or `unchanged` when nothing needed replacing. +Checking certificate dates does not establish whether peers trust the certificates or whether they have been revoked. +Verify a new TLS connection after a replacement. *What happens to automatic renewal.* If enabled, automatic renewal continues after manual reload. -If the source still returns the same certificates, the attempt fails and retries continue. +If the source still returns unchanged material, the attempt fails and retries continue. That failure does not undo the manual reload. *If the command cannot connect.* First check the command's certificates and trust settings. If the problem is on the node, put a valid replacement in its source and wait for an automatic retry. -Its earliest expiry must be later than that of the certificates currently in use. -A trust-store change alone cannot be applied by automatic renewal. To load corrected stores without the command or waiting for a retry, restart the affected nodes. === Custom Certificate Sources For automatic renewal, custom sources require a subclass of javadoc:org.apache.ignite.ssl.SslContextFactory[]. Factories extending only `AbstractSslContextFactory` support manual reload only. +The certificate checks and `unchanged` detection described above belong to `SslContextFactory`. +Factories extending only the base class retain their own loading behavior. +When a custom trust manager is supplied or a private key cannot be encoded, Ignite cannot compare all the material and reloads the context. Limit request duration in custom sources: a blocked load can delay other renewals, manual reload and node shutdown. == SslContextFactory Properties diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 6babf0267f3d9..d5f25a70d8d99 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -177,7 +177,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads a new SSL context and caches it for the base implementation of {@link #create()}. + * Loads an SSL context and caches it for the base implementation of {@link #create()}. * If loading fails, the previously cached context is kept. * * @throws IgniteException If the context cannot be initialized. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index aebf9c81b2c96..d9aad23b7e1b9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -17,20 +17,27 @@ package org.apache.ignite.ssl; +import java.io.DataOutputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.security.DigestOutputStream; import java.security.GeneralSecurityException; +import java.security.Key; import java.security.KeyStore; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.Enumeration; +import java.util.List; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -43,6 +50,8 @@ import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; +import static java.nio.charset.StandardCharsets.UTF_8; + /** * SSL context factory that provides SSL context configuration with specified key and trust stores. *

@@ -102,11 +111,14 @@ public class SslContextFactory extends AbstractSslContextFactory { private transient volatile long renewalTime; /** */ - private transient long expirationTime; + private transient byte[] materialFingerprint; /** Key store read by the load in progress. */ private transient KeyStore loadedKeyStore; + /** Trust store read by the load in progress. */ + private transient KeyStore loadedTrustStore; + /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -139,7 +151,7 @@ public long getRenewalTime() { * Does nothing when renewal is disabled, no renewal date has been loaded, or the window has not started. *

* Every certificate in the replacement's private-key chains must be valid now. - * Their earliest expiry must be later than that of the certificates currently in use. + * Unchanged material and TLS settings are a failed renewal attempt. * A failed replacement leaves the current context and renewal time unchanged. * * @throws IgniteInterruptedException If the thread is interrupted before a due replacement starts loading. @@ -156,41 +168,65 @@ public void renew() { if (Thread.currentThread().isInterrupted()) throw new IgniteInterruptedException("Certificate renewal was interrupted before loading."); - try { - sslCtx.set(load(true)); - } - catch (SSLException e) { - throw new IgniteException(e); - } + if (!reloadContext()) + throw new IgniteException("TLS key and trust material is unchanged."); } } - /** {@inheritDoc} */ - @Override SSLContext createSslContext() throws SSLException { - return load(false); - } - /** - * Builds an SSL context and updates renewal dates when renewal is enabled. + * Reloads key and trust material and TLS settings. Replacement key chains must be valid now. + * Custom trust managers and keys without an encoding are reloaded without equality checks. * - * @param replacement Whether the loaded certificates replace the current ones automatically. - * @return Initialized SSL context. - * @throws SSLException If the context cannot be built or the certificates cannot be accepted. + * @return {@code true} if the context was replaced, {@code false} if the material and settings are unchanged. + * @throws IgniteException If the replacement cannot be loaded or accepted. */ - private SSLContext load(boolean replacement) throws SSLException { + public synchronized boolean reload() { + return reloadContext(); + } + + /** */ + private boolean reloadContext() { + try { + SSLContext ctx = createSslContext(); + + if (ctx == sslCtx.get()) + return false; + + sslCtx.set(ctx); + + return true; + } + catch (SSLException e) { + throw new IgniteException(e); + } + } + + /** {@inheritDoc} */ + @Override SSLContext createSslContext() throws SSLException { try { SSLContext ctx = super.createSslContext(); + SSLContext cur = sslCtx.get(); + byte[] fingerprint = materialFingerprint(); + long nextRenewal = renewalEnabled || cur != null ? certificateRenewalTime(loadedKeyStore, cur != null) : 0; + + if (cur != null && fingerprint != null && Arrays.equals(materialFingerprint, fingerprint) && + (!renewalEnabled || renewalTime != 0)) + return cur; - if (renewalEnabled) - acceptCertificateDates(loadedKeyStore, replacement); + materialFingerprint = fingerprint; + renewalTime = nextRenewal; return ctx; } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to read certificates [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + catch (SSLException e) { + throw e; + } + catch (GeneralSecurityException | IOException e) { + throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } finally { loadedKeyStore = null; + loadedTrustStore = null; } } @@ -373,8 +409,7 @@ public static TrustManager getDisabledTrustManager() { KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - if (renewalEnabled) - loadedKeyStore = keyStore; + loadedKeyStore = keyStore; keyMgrFactory.init(keyStore, keyStorePwd); @@ -390,14 +425,15 @@ public static TrustManager getDisabledTrustManager() { } /** - * Validates the key chains and updates renewal dates. + * Validates the key chains and calculates the renewal date. * * @param store Loaded key store. - * @param replacement Whether to check current validity and later expiry for an automatic replacement. + * @param replacement Whether the certificates replace an existing context. + * @return Start of the renewal window, or {@code 0} if renewal is disabled. * @throws GeneralSecurityException If a key or certificate cannot be read. * @throws SSLException If the certificates cannot be accepted. */ - private void acceptCertificateDates(KeyStore store, boolean replacement) throws GeneralSecurityException, SSLException { + private long certificateRenewalTime(KeyStore store, boolean replacement) throws GeneralSecurityException, SSLException { long earliestExpiry = Long.MAX_VALUE; long selectedStart = Long.MAX_VALUE; Date now = new Date(); @@ -442,18 +478,98 @@ private void acceptCertificateDates(KeyStore store, boolean replacement) throws } } - if (earliestExpiry == Long.MAX_VALUE) + if (renewalEnabled && earliestExpiry == Long.MAX_VALUE) throw new SSLException("Certificate renewal requires a private key with an X.509 certificate chain."); - if (replacement && earliestExpiry <= expirationTime) - throw new SSLException("Replacement certificates must have a later earliest expiry [current=" + expirationTime + - ", replacement=" + earliestExpiry + ']'); - if (replacement && earliestExpiry < System.currentTimeMillis()) throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); - expirationTime = earliestExpiry; - renewalTime = earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION); + return renewalEnabled ? + earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION) : 0; + } + + /** */ + private byte[] materialFingerprint() throws GeneralSecurityException, IOException { + if (trustMgrs != null && (trustMgrs.length != 1 || !(trustMgrs[0] instanceof DisabledX509TrustManager))) + return null; + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + try (DataOutputStream out = new DataOutputStream(new DigestOutputStream(OutputStream.nullOutputStream(), digest))) { + writeStrings(out, proto, keyAlgorithm, keyStoreType, trustStoreType); + writeStrings(out, protocols); + writeStrings(out, cipherSuites); + + if (!writeStore(out, loadedKeyStore, true)) + return null; + + out.writeBoolean(loadedTrustStore != null); + + if (loadedTrustStore != null) + writeStore(out, loadedTrustStore, false); + } + + return digest.digest(); + } + + /** */ + private boolean writeStore(DataOutputStream out, KeyStore store, boolean keys) throws GeneralSecurityException, IOException { + List aliases = Collections.list(store.aliases()); + + Collections.sort(aliases); + + for (String alias : aliases) { + Key key = keys && store.isKeyEntry(alias) ? store.getKey(alias, keyStorePwd) : null; + Certificate cert = keys ? null : store.getCertificate(alias); + + if (keys ? !(key instanceof PrivateKey) : cert == null) + continue; + + out.writeBoolean(true); + writeBytes(out, alias.getBytes(UTF_8)); + + if (keys) { + byte[] encoded = key.getEncoded(); + + if (encoded == null) + return false; + + writeBytes(out, encoded); + + Certificate[] chain = store.getCertificateChain(alias); + + out.writeInt(chain == null ? -1 : chain.length); + + if (chain != null) { + for (Certificate certificate : chain) + writeBytes(out, certificate.getEncoded()); + } + } + else + writeBytes(out, cert.getEncoded()); + } + + out.writeBoolean(false); + + return true; + } + + /** */ + private static void writeBytes(DataOutputStream out, byte[] bytes) throws IOException { + out.writeInt(bytes == null ? -1 : bytes.length); + + if (bytes != null) + out.write(bytes); + } + + /** */ + private static void writeStrings(DataOutputStream out, String... values) throws IOException { + out.writeInt(values == null ? -1 : values.length); + + if (values != null) { + for (String val : values) + writeBytes(out, val == null ? null : val.getBytes(UTF_8)); + } } /** {@inheritDoc} */ @@ -463,6 +579,8 @@ private void acceptCertificateDates(KeyStore store, boolean replacement) throws KeyStore trustStore = loadKeyStore(trustStoreType, trustStoreFilePath, trustStorePwd); + loadedTrustStore = trustStore; + try { TrustManagerFactory trustMgrFactory = TrustManagerFactory.getInstance(keyAlgorithm); diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 81809a7fc91c5..5d0cc0ebfd7b3 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -36,6 +36,7 @@ import org.apache.ignite.internal.IgniteInternalFuture; import org.apache.ignite.internal.TestRecordingCommunicationSpi; import org.apache.ignite.internal.processors.rest.client.message.GridClientPingPacket; +import org.apache.ignite.spi.metric.LongMetric; import org.apache.ignite.ssl.SslContextFactory; import org.apache.ignite.testframework.ListeningTestLogger; import org.apache.ignite.testframework.LogListener; @@ -88,6 +89,8 @@ public void testSharedFactoryReloadedOnceOnServerAndClient() throws Exception { int[] loads = factories.stream().mapToInt(factory -> factory.loads.get()).toArray(); + factories.forEach(factory -> factory.setProtocols("TLSv1.2")); + assertEquals(EXIT_CODE_OK, executeTlsReload()); for (int i = 0; i < nodes.size(); i++) { @@ -97,9 +100,34 @@ public void testSharedFactoryReloadedOnceOnServerAndClient() throws Exception { : "communication, client connector, binary REST, discovery"; assertContains(log, testOut.toString(), nodes.get(i).localNode().id() + ": " + transports + ": reloaded"); + assertEquals(1, nodes.get(i).context().metric().registry("ssl").findMetric("ManualReloadAttempts").value()); } } + /** */ + @Test + public void testUnchangedFactoryDoesNotPreventReloadingNextFactory() throws Exception { + SslContextFactory unchanged = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + SslContextFactory changed = (SslContextFactory)sslTrustedFactory("node01", "trustone"); + IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(unchanged); + + cfg.getClientConnectorConfiguration().setSslEnabled(true).setUseIgniteSslContextFactory(false).setSslContextFactory(changed); + + IgniteEx node = startGrid(cfg); + SSLContext unchangedCtx = unchanged.create(); + SSLContext changedCtx = changed.create(); + + changed.setProtocols("TLSv1.2"); + + assertEquals(EXIT_CODE_OK, executeTlsReload()); + assertSame(unchangedCtx, unchanged.create()); + assertNotSame(changedCtx, changed.create()); + assertArrayEquals(new String[] {"TLSv1.2"}, changed.create().createSSLEngine().getEnabledProtocols()); + assertContains(log, testOut.toString(), node.localNode().id() + ": communication, discovery: unchanged"); + assertContains(log, testOut.toString(), node.localNode().id() + ": client connector: reloaded"); + assertEquals(2, node.context().metric().registry("ssl").findMetric("ManualReloadAttempts").value()); + } + /** */ @Test public void testReloadedFilesApplyToNewConnections() throws Exception { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index 2d6446ed6cf2b..deb091addfe5b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -160,8 +160,10 @@ private SslReloadJob(NoArg arg, boolean debug) { .map(Map.Entry::getKey) .collect(joining(", ")); + boolean reloaded; + try { - ignite.context().ssl().reload((AbstractSslContextFactory)factory); + reloaded = ignite.context().ssl().reload((AbstractSslContextFactory)factory); } catch (Exception e) { ignite.log().warning("TLS reload: " + nodeId + ": " + transports + ": failed" + initiator, e); @@ -169,7 +171,7 @@ private SslReloadJob(NoArg arg, boolean debug) { throw new IgniteException(transports + ": " + errorMessage(e), e); } - String report = nodeId + ": " + transports + ": reloaded"; + String report = nodeId + ": " + transports + (reloaded ? ": reloaded" : ": unchanged"); reports.add(report); ignite.log().info("TLS reload: " + report + initiator); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 3b904c39087d8..4d1a942473e3c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -33,6 +33,7 @@ import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInterruptedCheckedException; +import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; import org.apache.ignite.internal.util.typedef.internal.U; @@ -68,6 +69,12 @@ public class SslFactoryRegistry { /** */ private final AtomicLong consecutiveFailures = new AtomicLong(); + /** */ + private final AtomicLong manualReloadAttempts = new AtomicLong(); + + /** */ + private final AtomicLong automaticRenewalAttempts = new AtomicLong(); + /** */ public synchronized void register(String transport, Factory factory) { if (factories.putIfAbsent(transport, factory) != null) @@ -83,8 +90,13 @@ public synchronized Map> factories() { public synchronized void start(GridKernalContext ctx) { this.ctx = ctx; log = ctx.log(getClass()); - ctx.metric().registry("ssl").register("ConsecutiveRenewalFailures", consecutiveFailures::get, + + MetricRegistryImpl metrics = ctx.metric().registry("ssl"); + + metrics.register("ConsecutiveRenewalFailures", consecutiveFailures::get, "Sum of consecutive automatic certificate renewal failures across SSL factories."); + metrics.register("ManualReloadAttempts", manualReloadAttempts::get, "Number of manual SSL factory reload attempts."); + metrics.register("AutomaticRenewalAttempts", automaticRenewalAttempts::get, "Number of automatic SSL factory renewal attempts."); Map> factories = factories(); @@ -142,9 +154,15 @@ public synchronized void stop() { Thread.currentThread().interrupt(); } - /** Reloads the factory and, on success, resets its automatic renewal schedule and failure count. */ - public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - Future reload; + /** + * Reloads the factory and resets its automatic renewal schedule and failure count if a context is replaced. + * + * @param factory Factory to reload. + * @return Whether the context was replaced. + * @throws IgniteCheckedException If an asynchronous reload fails or is interrupted. + */ + public boolean reload(AbstractSslContextFactory factory) throws IgniteCheckedException { + Future reload; synchronized (this) { Renewal renewal = executor == null ? null : renewals.get(factory); @@ -153,10 +171,10 @@ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedExcept } if (reload == null) - factory.init(); + return reloadFactory(factory); else { try { - U.get(reload); + return U.get(reload); } catch (IgniteInterruptedCheckedException e) { reload.cancel(true); @@ -166,6 +184,18 @@ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedExcept } } + /** */ + private boolean reloadFactory(AbstractSslContextFactory factory) { + manualReloadAttempts.incrementAndGet(); + + if (factory instanceof SslContextFactory) + return ((SslContextFactory)factory).reload(); + + factory.init(); + + return true; + } + /** */ private boolean stopping() { return ctx.isStopping() || executor.isShutdown(); @@ -200,6 +230,8 @@ private Renewal(SslContextFactory factory, String transports) { long nextAttempt = 0; try { + automaticRenewalAttempts.incrementAndGet(); + factory.renew(); nextAttempt = factory.getRenewalTime(); } @@ -229,11 +261,14 @@ private Renewal(SslContextFactory factory, String transports) { } /** */ - private void reload() { - factory.init(); + private boolean reload() { + if (!reloadFactory(factory)) + return false; resetFailures(); schedule(nextAttemptTime(factory.getRenewalTime())); + + return true; } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 461cc021ed812..64a6d2112eec5 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -21,6 +21,7 @@ import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; @@ -46,6 +47,7 @@ import org.apache.ignite.internal.thread.pool.IgniteScheduledThreadPoolExecutor; import org.apache.ignite.logger.NullLogger; import org.apache.ignite.spi.metric.LongMetric; +import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; import org.junit.After; import org.junit.Before; @@ -61,6 +63,7 @@ import static org.apache.ignite.testframework.GridTestUtils.runAsync; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -118,6 +121,7 @@ public void setUp() { when(metricMgr.registry("ssl")).thenReturn(metrics); when(ctx.log(SslFactoryRegistry.class)).thenReturn(new NullLogger()); when(factory.isRenewalEnabled()).thenReturn(true); + when(factory.reload()).thenReturn(true); when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); when(executor.isTerminated()).thenReturn(true); @@ -126,8 +130,8 @@ public void setUp() { return null; }).when(executor).execute(any(Runnable.class)); - when(executor.submit(any(Runnable.class))).thenAnswer(call -> { - FutureTask task = new FutureTask<>(call.getArgument(0), null); + when(executor.submit(any(Callable.class))).thenAnswer(call -> { + FutureTask task = new FutureTask<>(call.>getArgument(0)); task.run(); @@ -178,6 +182,8 @@ public void testDisabledFactoryDoesNotStartRenewal() { executorFactory.verifyNoInteractions(); assertEquals(0, failures()); + assertEquals(0, metric("ManualReloadAttempts")); + assertEquals(0, metric("AutomaticRenewalAttempts")); } /** */ @@ -201,6 +207,8 @@ public void testEnablingRenewalOnRestartInitializesSharedFactoryOnce() { verify(factory).init(); assertTrue(delay > HOURS.toMillis(1)); + assertEquals(0, metric("ManualReloadAttempts")); + assertEquals(0, metric("AutomaticRenewalAttempts")); } /** */ @@ -251,8 +259,8 @@ public void testManualReloadFollowsInitialScheduling() throws Exception { doAnswer(call -> { renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); - return null; - }).when(factory).init(); + return true; + }).when(factory).reload(); registry.register("communication", factory); @@ -268,7 +276,7 @@ public void testManualReloadFollowsInitialScheduling() throws Exception { InOrder order = inOrder(renewalExecutor, factory); order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); - order.verify(factory).init(); + order.verify(factory).reload(); order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); assertTrue(delays.getValue() > HOURS.toMillis(2)); } @@ -289,6 +297,7 @@ public void testFailuresIncreaseRetryDelayUntilLimit() { long base = MINUTES.toMillis(Math.min(1L << (failure - 1), 60)); assertEquals(failure, failures()); + assertEquals(failure, metric("AutomaticRenewalAttempts")); assertTrue(delay >= base - (System.currentTimeMillis() - before)); assertTrue(delay <= base + base / 2); } @@ -311,6 +320,7 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { assertEquals(0, failures()); assertTrue(delay > HOURS.toMillis(1)); + assertEquals(2, metric("AutomaticRenewalAttempts")); } /** */ @@ -344,7 +354,7 @@ public void testManualReloadInCurrentWindowSchedulesRenewalInOneHour() throws Ex registry.reload(factory); - verify(factory).init(); + verify(factory).reload(); verify(previous).cancel(false); assertTrue(delay >= HOURS.toMillis(1) - (System.currentTimeMillis() - before)); assertTrue(delay <= HOURS.toMillis(1)); @@ -362,6 +372,8 @@ public void testSuccessfulRenewalKeepsFutureWindowWithinOneHour() { scheduled.run(); + verify(factory).renew(); + assertEquals(1, metric("AutomaticRenewalAttempts")); assertTrue(delay >= MINUTES.toMillis(30) - (System.currentTimeMillis() - before)); assertTrue(delay <= MINUTES.toMillis(30)); } @@ -381,13 +393,68 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - registry.reload(factory); + assertTrue(registry.reload(factory)); verify(retry).cancel(false); assertEquals(0, failures()); assertTrue(delay > HOURS.toMillis(1)); } + /** */ + @Test + public void testUnchangedManualReloadKeepsRetryAndFailures() throws Exception { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + + registry.register("communication", factory); + registry.start(ctx); + scheduled.run(); + + ScheduledFuture retry = pending; + + when(factory.reload()).thenReturn(false); + + assertFalse(registry.reload(factory)); + assertEquals(1, failures()); + assertEquals(1, metric("ManualReloadAttempts")); + assertEquals(1, metric("AutomaticRenewalAttempts")); + verify(retry, never()).cancel(false); + verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + } + + /** */ + @Test + public void testManualAttemptMetricCountsAppliedUnchangedAndFailedLoads() throws Exception { + when(factory.isRenewalEnabled()).thenReturn(false); + when(factory.reload()).thenReturn(true, false).thenThrow(new IgniteException("Source unavailable")); + + registry.register("communication", factory); + registry.start(ctx); + + assertTrue(registry.reload(factory)); + assertFalse(registry.reload(factory)); + assertThrows(IgniteException.class, () -> registry.reload(factory)); + assertEquals(3, metric("ManualReloadAttempts")); + assertEquals(0, metric("AutomaticRenewalAttempts")); + + metrics.reset(); + + assertEquals(3, metric("ManualReloadAttempts")); + } + + /** */ + @Test + public void testManualReloadInitializesGenericFactory() throws Exception { + AbstractSslContextFactory genericFactory = mock(AbstractSslContextFactory.class); + + registry.register("communication", genericFactory); + registry.start(ctx); + + assertTrue(registry.reload(genericFactory)); + + verify(genericFactory).init(); + assertEquals(1, metric("ManualReloadAttempts")); + } + /** */ @Test public void testFailedManualReloadKeepsRetryAndFailures() { @@ -400,13 +467,14 @@ public void testFailedManualReloadKeepsRetryAndFailures() { ScheduledFuture retry = pending; IgniteException err = new IgniteException("Invalid replacement"); - doThrow(err).when(factory).init(); + doThrow(err).when(factory).reload(); IgniteCheckedException actual = assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)); assertSame(err, actual.getCause()); assertEquals(err.getMessage(), actual.getMessage()); assertEquals(1, failures()); + assertEquals(1, metric("ManualReloadAttempts")); verify(retry, never()).cancel(false); verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } @@ -415,8 +483,8 @@ public void testFailedManualReloadKeepsRetryAndFailures() { @Test public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - when(executor.submit(any(Runnable.class))).thenAnswer(call -> { - FutureTask reload = new FutureTask<>(call.getArgument(0), null); + when(executor.submit(any(Callable.class))).thenAnswer(call -> { + FutureTask reload = new FutureTask<>(call.>getArgument(0)); reload.run(); scheduled.run(); @@ -437,6 +505,7 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { SslContextFactory otherFactory = mock(SslContextFactory.class); when(otherFactory.isRenewalEnabled()).thenReturn(true); + when(otherFactory.reload()).thenReturn(true); when(otherFactory.getRenewalTime()).thenReturn(1L); doThrow(new IgniteException("Source unavailable")).when(factory).renew(); doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(); @@ -494,8 +563,8 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { assertEquals("operator", OperationContext.get(caller)); renewalTime.set(System.currentTimeMillis()); - return null; - }).when(factory).init(); + return true; + }).when(factory).reload(); registry.register("communication", factory); @@ -614,7 +683,7 @@ public void testStopCancelsQueuedReload() throws Exception { submissions.add(fut); return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); + }).when(renewalExecutor).submit(any(Callable.class)); registry.register("communication", factory); @@ -634,7 +703,8 @@ public void testStopCancelsQueuedReload() throws Exception { registry.stop(); assertTrue(reload.get(5_000).getCause() instanceof CancellationException); - verify(factory, never()).init(); + verify(factory, never()).reload(); + assertEquals(0, metric("ManualReloadAttempts")); } finally { allowCompletion.countDown(); @@ -671,7 +741,7 @@ public void testInterruptedCallerCancelsQueuedReload() throws Exception { reloadSubmitted.countDown(); return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); + }).when(renewalExecutor).submit(any(Callable.class)); registry.register("communication", factory); @@ -699,7 +769,8 @@ public void testInterruptedCallerCancelsQueuedReload() throws Exception { allowCompletion.countDown(); renewalExecutor.submit(() -> { }).get(5, SECONDS); - verify(factory, never()).init(); + verify(factory, never()).reload(); + assertEquals(0, metric("ManualReloadAttempts")); } finally { allowCompletion.countDown(); @@ -731,7 +802,12 @@ public void testStoppingDuringLoadDoesNotCountFailure() { /** */ private long failures() { - return metrics.findMetric("ConsecutiveRenewalFailures").value(); + return metric("ConsecutiveRenewalFailures"); + } + + /** */ + private long metric(String name) { + return metrics.findMetric(name).value(); } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 6a5879fa623fb..986c2a350e5e0 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -25,6 +25,7 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.testframework.GridTestThread; @@ -44,6 +45,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; /** */ @@ -80,6 +82,7 @@ public void testDisabledRenewalAcceptsStoreWithoutPrivateKeys() throws Exception factory.create(); assertEquals(0, factory.getRenewalTime()); + assertFalse(factory.reload()); } /** */ @@ -237,21 +240,116 @@ public void testRenewReplacesExpiredContext() throws Exception { /** */ @Test - public void testRenewRejectsUnchangedExpiry() throws Exception { + public void testRenewRejectsUnchangedMaterial() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("first", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); long renewalTime = factory.getRenewalTime(); - factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(80), now + HOURS.toMillis(10)); - IgniteException err = assertThrows(IgniteException.class, factory::renew); - assertTrue(err.getCause().getMessage().contains("later earliest expiry")); + assertTrue(err.getMessage().contains("unchanged")); + assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); + } + + /** */ + @Test + public void testManualReloadAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { + for (long remainingHours : new long[] {10, 5}) { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(remainingHours)); + + assertTrue(factory.reload()); + assertNotSame(ctx, factory.create()); + } + } + + /** */ + @Test + public void testRenewAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { + for (long remainingHours : new long[] {10, 5}) { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(remainingHours)); + factory.renew(); + + assertNotSame(ctx, factory.create()); + } + } + + /** */ + @Test + public void testManualReloadKeepsUnchangedContext() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + + factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); + + assertFalse(factory.reload()); + + factory.init(); + assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); } + /** */ + @Test + public void testManualReloadAppliesTrustStoreChanges() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + + factory.trustStore = authority.trustStore(); + factory.setTrustManagers((TrustManager[])null); + factory.setTrustStoreFilePath("memory-trust"); + factory.setTrustStorePassword(PASSWORD); + + SSLContext ctx = factory.create(); + TestCertificateAuthority otherIssuer = new TestCertificateAuthority("other-issuer", now - DAYS.toMillis(1), now + DAYS.toMillis(1)); + + factory.trustStore.setCertificateEntry("other-issuer", otherIssuer.trustStore().getCertificate("CN=other-issuer")); + + assertTrue(factory.reload()); + assertNotSame(ctx, factory.create()); + assertFalse(factory.reload()); + } + + /** */ + @Test + public void testManualReloadDetectsPrivateKeyChange() throws Exception { + KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); + InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); + SSLContext ctx = factory.create(); + KeyStore other = authority.keyStore("other", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); + + keys.setKeyEntry("node", other.getKey("other", PASSWORD), PASSWORD, keys.getCertificateChain("node")); + + assertTrue(factory.reload()); + assertNotSame(ctx, factory.create()); + } + + /** */ + @Test + public void testManualReloadWithCustomTrustManagerReplacesContext() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + + factory.setTrustManagers(mock(X509TrustManager.class)); + + SSLContext ctx = factory.create(); + + assertTrue(factory.reload()); + assertNotSame(ctx, factory.create()); + } + /** */ @Test public void testRenewRejectsExpiredIssuer() throws Exception { @@ -297,15 +395,58 @@ public void testManualReloadAcceptsEarlierExpiryAfterRenewalFailure() throws Exc authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); + factory.keyStore = authority.keyStore("expired", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + IgniteException err = assertThrows(IgniteException.class, factory::renew); - assertTrue(err.getCause().getMessage().contains("later earliest expiry")); + assertTrue(err.getCause().getMessage().contains("not currently valid")); - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); + factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(95), now + HOURS.toMillis(5)); factory.init(); assertNotSame(ctx, factory.create()); - assertEquals(now - HOURS.toMillis(16), factory.getRenewalTime()); + assertEquals(now - HOURS.toMillis(10), factory.getRenewalTime()); + } + + /** */ + @Test + public void testManualReloadRejectsExpiredIssuer() throws Exception { + KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); + InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); + TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", now - HOURS.toMillis(100), now - 1_000); + + factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + + IgniteException err = assertThrows(IgniteException.class, factory::reload); + + assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); + assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); + + factory.keyStore = keys; + + assertFalse(factory.reload()); + } + + /** */ + @Test + public void testManualReloadRejectsFutureCertificateWithRenewalDisabled() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + + factory.setRenewalEnabled(false); + + SSLContext ctx = factory.create(); + + factory.keyStore = authority.keyStore("future", now + HOURS.toMillis(1), now + HOURS.toMillis(100)); + + IgniteException err = assertThrows(IgniteException.class, factory::init); + + assertTrue(err.getCause().getMessage().contains("not currently valid")); + assertSame(ctx, factory.create()); + assertEquals(0, factory.getRenewalTime()); } /** */ @@ -380,6 +521,9 @@ private static class InMemorySslContextFactory extends SslContextFactory { /** */ protected KeyStore keyStore; + /** */ + private KeyStore trustStore; + /** */ private int keyStoreLoads; @@ -395,6 +539,9 @@ private InMemorySslContextFactory(KeyStore keyStore) { /** {@inheritDoc} */ @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { + if ("memory-trust".equals(path)) + return trustStore; + if (!"memory".equals(path)) return super.loadKeyStore(type, path, pwd); From 5d4ac0ca950d56d7484df2852ab512a87dc6e5cb Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 20:13:04 +0300 Subject: [PATCH 222/242] IGNITE-29211 Fail renewal attempts that leave certificates due An automatic attempt fails if the loaded certificates are still due for renewal. The rule covers unchanged material and replacements that are themselves in the renewal window. The failure count stays until the node's certificates leave the window, and retries use the regular backoff. The factory loads the same way for manual reload and renewal and does not compare material. The registry and the reload command call init(), and the command reports "reloaded" after each successful load. Manual reload and the initial scheduling both use the factory's renewal date, so the registry does not take its monitor in reload(). Co-Authored-By: Claude Opus 5.5 --- .../_docs/monitoring-metrics/new-metrics.adoc | 4 +- docs/_docs/security/ssl-tls.adoc | 28 ++- .../ignite/ssl/AbstractSslContextFactory.java | 2 +- .../apache/ignite/ssl/SslContextFactory.java | 150 +--------------- .../util/GridCommandHandlerSslReloadTest.java | 24 --- .../management/ssl/SslReloadTask.java | 6 +- .../processors/ssl/SslFactoryRegistry.java | 63 ++----- .../ssl/SslFactoryRegistryTest.java | 163 ++++-------------- .../ssl/SslContextFactoryRenewalTest.java | 76 +------- 9 files changed, 78 insertions(+), 438 deletions(-) diff --git a/docs/_docs/monitoring-metrics/new-metrics.adoc b/docs/_docs/monitoring-metrics/new-metrics.adoc index 4347ee329696f..d80ef2f16cac5 100644 --- a/docs/_docs/monitoring-metrics/new-metrics.adoc +++ b/docs/_docs/monitoring-metrics/new-metrics.adoc @@ -440,8 +440,8 @@ Register name: `ssl` |=== |Name | Type | Description |ConsecutiveRenewalFailures | long | Sum of consecutive automatic renewal failures across the node's SSL factories. Zero when renewal is disabled. -|ManualReloadAttempts | long | Factory reload attempts started by the node for manual commands, including errors and unchanged material. Excludes startup loads. -|AutomaticRenewalAttempts | long | Automatic factory renewal calls, including errors, unchanged material and calls that find renewal is no longer due. +|ManualReloadAttempts | long | Factory reload attempts started by the node for manual commands, including errors. Excludes startup loads. +|AutomaticRenewalAttempts | long | Automatic factory renewal calls, including errors and calls that find renewal is no longer due. |=== See link:security/ssl-tls#automatic-certificate-renewal[Automatic Certificate Renewal] for configuration and recovery. diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 05a54cbb75fef..34cdeadef0a8d 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -181,9 +181,6 @@ Replacement certificates may expire earlier than the current ones or have the sa You can also update the trust store without replacing the node's certificate. These checks apply even when automatic renewal is disabled. -If the keys, certificates, trust store and TLS settings are unchanged, the command reports `unchanged` and continues with the other factories. -It keeps the existing context, automatic retry schedule and failure count. - If a factory fails to reload, it keeps its previous context, and that node stops reloading its remaining factories. Successful reloads remain in effect; other nodes continue independently. Check the affected node's log for error details. @@ -230,12 +227,12 @@ Ignite selects the chain that expires first and starts renewal attempts with 15% If this point has already passed when the node starts, Ignite schedules an attempt immediately. See link:{javadoc_base_url}/org/apache/ignite/ssl/SslContextFactory.html#getRenewalTime()[getRenewalTime(), window=_blank] for the calculation. -After replacing a context manually or automatically, Ignite calculates the next attempt from the loaded certificates. -If their renewal window has already started, Ignite logs a warning and waits one hour before trying again automatically. -You can still run the manual reload command during this wait. - Automatic renewal applies the same certificate checks as <>. -If the source returns unchanged material, Ignite logs a failed attempt and uses the retry intervals below. +An automatic attempt fails if the loaded certificates are still within their renewal window, for example when the source still returns the old certificates. +Ignite then retries with the intervals below. + +After a manual reload, Ignite schedules the next automatic attempt from the loaded certificates. +If their renewal window has already started, the attempt runs immediately. New connections use the accepted certificates; established connections remain open. To confirm a replacement, open a new TLS connection to each affected endpoint and compare its peer certificate with the replacement. @@ -249,11 +246,10 @@ The base delay doubles after each failure, with a random addition of up to 50%. Check the node log for the next attempt, affected transports and the reason for a failure. The `ssl.ConsecutiveRenewalFailures` metric sums consecutive failed automatic attempts across the node's factories. -Each factory's count resets when a renewal or manual reload replaces its context. -It also resets if an automatic attempt finds that renewal is no longer due. +Each factory's count resets after a successful manual reload or an automatic attempt after which renewal is no longer due. `ssl.ManualReloadAttempts` and `ssl.AutomaticRenewalAttempts` count the node's calls to reload each factory. -They include errors, unchanged material and automatic attempts that find renewal is no longer due. +They include errors and automatic attempts that find renewal is no longer due. A factory shared by several transports is counted once per attempt. Startup loading and direct application calls to the factory are not counted. @@ -263,14 +259,13 @@ To recover without waiting for the next retry: . Check the command result and verify a new connection with the replacement certificates. *What a successful command means.* -The result is `reloaded` when new material or settings were applied, or `unchanged` when nothing needed replacing. +The result `reloaded` means that the node loaded the stores and accepted their certificate dates. Checking certificate dates does not establish whether peers trust the certificates or whether they have been revoked. Verify a new TLS connection after a replacement. *What happens to automatic renewal.* -If enabled, automatic renewal continues after manual reload. -If the source still returns unchanged material, the attempt fails and retries continue. -That failure does not undo the manual reload. +If enabled, automatic renewal continues after manual reload, as described in <>. +A failed automatic attempt does not undo the manual reload. *If the command cannot connect.* First check the command's certificates and trust settings. @@ -281,9 +276,8 @@ To load corrected stores without the command or waiting for a retry, restart the For automatic renewal, custom sources require a subclass of javadoc:org.apache.ignite.ssl.SslContextFactory[]. Factories extending only `AbstractSslContextFactory` support manual reload only. -The certificate checks and `unchanged` detection described above belong to `SslContextFactory`. +The certificate checks described above belong to `SslContextFactory`. Factories extending only the base class retain their own loading behavior. -When a custom trust manager is supplied or a private key cannot be encoded, Ignite cannot compare all the material and reloads the context. Limit request duration in custom sources: a blocked load can delay other renewals, manual reload and node shutdown. == SslContextFactory Properties diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index d5f25a70d8d99..6babf0267f3d9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -177,7 +177,7 @@ protected void checkNullParameter(Object param, String name) throws SSLException protected abstract TrustManager[] createTrustManagers() throws SSLException; /** - * Loads an SSL context and caches it for the base implementation of {@link #create()}. + * Loads a new SSL context and caches it for the base implementation of {@link #create()}. * If loading fails, the previously cached context is kept. * * @throws IgniteException If the context cannot be initialized. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index d9aad23b7e1b9..c98bcec66809a 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -17,27 +17,20 @@ package org.apache.ignite.ssl; -import java.io.DataOutputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.security.DigestOutputStream; import java.security.GeneralSecurityException; -import java.security.Key; import java.security.KeyStore; -import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; -import java.util.Collections; import java.util.Date; import java.util.Enumeration; -import java.util.List; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -50,8 +43,6 @@ import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; -import static java.nio.charset.StandardCharsets.UTF_8; - /** * SSL context factory that provides SSL context configuration with specified key and trust stores. *

@@ -110,15 +101,9 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; - /** */ - private transient byte[] materialFingerprint; - /** Key store read by the load in progress. */ private transient KeyStore loadedKeyStore; - /** Trust store read by the load in progress. */ - private transient KeyStore loadedTrustStore; - /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -151,7 +136,6 @@ public long getRenewalTime() { * Does nothing when renewal is disabled, no renewal date has been loaded, or the window has not started. *

* Every certificate in the replacement's private-key chains must be valid now. - * Unchanged material and TLS settings are a failed renewal attempt. * A failed replacement leaves the current context and renewal time unchanged. * * @throws IgniteInterruptedException If the thread is interrupted before a due replacement starts loading. @@ -168,36 +152,7 @@ public void renew() { if (Thread.currentThread().isInterrupted()) throw new IgniteInterruptedException("Certificate renewal was interrupted before loading."); - if (!reloadContext()) - throw new IgniteException("TLS key and trust material is unchanged."); - } - } - - /** - * Reloads key and trust material and TLS settings. Replacement key chains must be valid now. - * Custom trust managers and keys without an encoding are reloaded without equality checks. - * - * @return {@code true} if the context was replaced, {@code false} if the material and settings are unchanged. - * @throws IgniteException If the replacement cannot be loaded or accepted. - */ - public synchronized boolean reload() { - return reloadContext(); - } - - /** */ - private boolean reloadContext() { - try { - SSLContext ctx = createSslContext(); - - if (ctx == sslCtx.get()) - return false; - - sslCtx.set(ctx); - - return true; - } - catch (SSLException e) { - throw new IgniteException(e); + init(); } } @@ -205,28 +160,17 @@ private boolean reloadContext() { @Override SSLContext createSslContext() throws SSLException { try { SSLContext ctx = super.createSslContext(); - SSLContext cur = sslCtx.get(); - byte[] fingerprint = materialFingerprint(); - long nextRenewal = renewalEnabled || cur != null ? certificateRenewalTime(loadedKeyStore, cur != null) : 0; - - if (cur != null && fingerprint != null && Arrays.equals(materialFingerprint, fingerprint) && - (!renewalEnabled || renewalTime != 0)) - return cur; + boolean replacement = sslCtx.get() != null; - materialFingerprint = fingerprint; - renewalTime = nextRenewal; + renewalTime = renewalEnabled || replacement ? certificateRenewalTime(loadedKeyStore, replacement) : 0; return ctx; } - catch (SSLException e) { - throw e; - } - catch (GeneralSecurityException | IOException e) { + catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } finally { loadedKeyStore = null; - loadedTrustStore = null; } } @@ -488,90 +432,6 @@ private long certificateRenewalTime(KeyStore store, boolean replacement) throws earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION) : 0; } - /** */ - private byte[] materialFingerprint() throws GeneralSecurityException, IOException { - if (trustMgrs != null && (trustMgrs.length != 1 || !(trustMgrs[0] instanceof DisabledX509TrustManager))) - return null; - - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - - try (DataOutputStream out = new DataOutputStream(new DigestOutputStream(OutputStream.nullOutputStream(), digest))) { - writeStrings(out, proto, keyAlgorithm, keyStoreType, trustStoreType); - writeStrings(out, protocols); - writeStrings(out, cipherSuites); - - if (!writeStore(out, loadedKeyStore, true)) - return null; - - out.writeBoolean(loadedTrustStore != null); - - if (loadedTrustStore != null) - writeStore(out, loadedTrustStore, false); - } - - return digest.digest(); - } - - /** */ - private boolean writeStore(DataOutputStream out, KeyStore store, boolean keys) throws GeneralSecurityException, IOException { - List aliases = Collections.list(store.aliases()); - - Collections.sort(aliases); - - for (String alias : aliases) { - Key key = keys && store.isKeyEntry(alias) ? store.getKey(alias, keyStorePwd) : null; - Certificate cert = keys ? null : store.getCertificate(alias); - - if (keys ? !(key instanceof PrivateKey) : cert == null) - continue; - - out.writeBoolean(true); - writeBytes(out, alias.getBytes(UTF_8)); - - if (keys) { - byte[] encoded = key.getEncoded(); - - if (encoded == null) - return false; - - writeBytes(out, encoded); - - Certificate[] chain = store.getCertificateChain(alias); - - out.writeInt(chain == null ? -1 : chain.length); - - if (chain != null) { - for (Certificate certificate : chain) - writeBytes(out, certificate.getEncoded()); - } - } - else - writeBytes(out, cert.getEncoded()); - } - - out.writeBoolean(false); - - return true; - } - - /** */ - private static void writeBytes(DataOutputStream out, byte[] bytes) throws IOException { - out.writeInt(bytes == null ? -1 : bytes.length); - - if (bytes != null) - out.write(bytes); - } - - /** */ - private static void writeStrings(DataOutputStream out, String... values) throws IOException { - out.writeInt(values == null ? -1 : values.length); - - if (values != null) { - for (String val : values) - writeBytes(out, val == null ? null : val.getBytes(UTF_8)); - } - } - /** {@inheritDoc} */ @Override protected final TrustManager[] createTrustManagers() throws SSLException { if (trustMgrs != null) @@ -579,8 +439,6 @@ private static void writeStrings(DataOutputStream out, String... values) throws KeyStore trustStore = loadKeyStore(trustStoreType, trustStoreFilePath, trustStorePwd); - loadedTrustStore = trustStore; - try { TrustManagerFactory trustMgrFactory = TrustManagerFactory.getInstance(keyAlgorithm); diff --git a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java index 5d0cc0ebfd7b3..3019eee97236b 100644 --- a/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java +++ b/modules/control-utility/src/test/java/org/apache/ignite/util/GridCommandHandlerSslReloadTest.java @@ -104,30 +104,6 @@ public void testSharedFactoryReloadedOnceOnServerAndClient() throws Exception { } } - /** */ - @Test - public void testUnchangedFactoryDoesNotPreventReloadingNextFactory() throws Exception { - SslContextFactory unchanged = (SslContextFactory)sslTrustedFactory("node01", "trustone"); - SslContextFactory changed = (SslContextFactory)sslTrustedFactory("node01", "trustone"); - IgniteConfiguration cfg = getConfiguration(getTestIgniteInstanceName(0)).setSslContextFactory(unchanged); - - cfg.getClientConnectorConfiguration().setSslEnabled(true).setUseIgniteSslContextFactory(false).setSslContextFactory(changed); - - IgniteEx node = startGrid(cfg); - SSLContext unchangedCtx = unchanged.create(); - SSLContext changedCtx = changed.create(); - - changed.setProtocols("TLSv1.2"); - - assertEquals(EXIT_CODE_OK, executeTlsReload()); - assertSame(unchangedCtx, unchanged.create()); - assertNotSame(changedCtx, changed.create()); - assertArrayEquals(new String[] {"TLSv1.2"}, changed.create().createSSLEngine().getEnabledProtocols()); - assertContains(log, testOut.toString(), node.localNode().id() + ": communication, discovery: unchanged"); - assertContains(log, testOut.toString(), node.localNode().id() + ": client connector: reloaded"); - assertEquals(2, node.context().metric().registry("ssl").findMetric("ManualReloadAttempts").value()); - } - /** */ @Test public void testReloadedFilesApplyToNewConnections() throws Exception { diff --git a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java index deb091addfe5b..2d6446ed6cf2b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/management/ssl/SslReloadTask.java @@ -160,10 +160,8 @@ private SslReloadJob(NoArg arg, boolean debug) { .map(Map.Entry::getKey) .collect(joining(", ")); - boolean reloaded; - try { - reloaded = ignite.context().ssl().reload((AbstractSslContextFactory)factory); + ignite.context().ssl().reload((AbstractSslContextFactory)factory); } catch (Exception e) { ignite.log().warning("TLS reload: " + nodeId + ": " + transports + ": failed" + initiator, e); @@ -171,7 +169,7 @@ private SslReloadJob(NoArg arg, boolean debug) { throw new IgniteException(transports + ": " + errorMessage(e), e); } - String report = nodeId + ": " + transports + (reloaded ? ": reloaded" : ": unchanged"); + String report = nodeId + ": " + transports + ": reloaded"; reports.add(report); ignite.log().info("TLS reload: " + report + initiator); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 4d1a942473e3c..978c0ce07bec8 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -30,6 +30,7 @@ import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInterruptedCheckedException; @@ -40,7 +41,6 @@ import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; -import static java.util.concurrent.TimeUnit.HOURS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.stream.Collectors.joining; @@ -48,9 +48,6 @@ /** SSL factories used by running transports. */ public class SslFactoryRegistry { - /** Delay after a successful load whose renewal window has already started. */ - private static final long RENEWAL_RETRY_DELAY = HOURS.toMillis(1); - /** */ private final Map> factories = new LinkedHashMap<>(); @@ -155,26 +152,21 @@ public synchronized void stop() { } /** - * Reloads the factory and resets its automatic renewal schedule and failure count if a context is replaced. + * Reloads the factory and resets its automatic renewal schedule and failure count. * * @param factory Factory to reload. - * @return Whether the context was replaced. * @throws IgniteCheckedException If an asynchronous reload fails or is interrupted. */ - public boolean reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - Future reload; - - synchronized (this) { - Renewal renewal = executor == null ? null : renewals.get(factory); - - reload = renewal == null ? null : executor.submit(renewal::reload); - } + public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { + ScheduledExecutorService executor = this.executor; + Renewal renewal = executor == null ? null : renewals.get(factory); + Future reload = renewal == null ? null : executor.submit(renewal::reload); if (reload == null) - return reloadFactory(factory); + reloadFactory(factory); else { try { - return U.get(reload); + U.get(reload); } catch (IgniteInterruptedCheckedException e) { reload.cancel(true); @@ -185,15 +177,10 @@ public boolean reload(AbstractSslContextFactory factory) throws IgniteCheckedExc } /** */ - private boolean reloadFactory(AbstractSslContextFactory factory) { + private void reloadFactory(AbstractSslContextFactory factory) { manualReloadAttempts.incrementAndGet(); - if (factory instanceof SslContextFactory) - return ((SslContextFactory)factory).reload(); - factory.init(); - - return true; } /** */ @@ -234,6 +221,9 @@ private Renewal(SslContextFactory factory, String transports) { factory.renew(); nextAttempt = factory.getRenewalTime(); + + if (nextAttempt != 0 && nextAttempt <= System.currentTimeMillis()) + throw new IgniteException("Loaded certificates are still due for renewal [renewalTime=" + new Date(nextAttempt) + ']'); } catch (Exception e) { failure = e; @@ -242,10 +232,8 @@ private Renewal(SslContextFactory factory, String transports) { if (stopping()) return; - if (failure == null) { + if (failure == null) resetFailures(); - nextAttempt = nextAttemptTime(nextAttempt); - } else { failures++; consecutiveFailures.incrementAndGet(); @@ -261,29 +249,10 @@ private Renewal(SslContextFactory factory, String transports) { } /** */ - private boolean reload() { - if (!reloadFactory(factory)) - return false; - + private void reload() { + reloadFactory(factory); resetFailures(); - schedule(nextAttemptTime(factory.getRenewalTime())); - - return true; - } - - /** */ - private long nextAttemptTime(long renewalTime) { - long now = System.currentTimeMillis(); - - if (renewalTime == 0 || renewalTime > now) - return renewalTime; - - long nextAttempt = now + RENEWAL_RETRY_DELAY; - - log.warning("TLS certificates are already in their renewal window [transports=" + transports + - ", nextAttempt=" + new Date(nextAttempt) + ']'); - - return nextAttempt; + schedule(); } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 64a6d2112eec5..7267d61061c09 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,11 +17,8 @@ package org.apache.ignite.internal.processors.ssl; -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Future; @@ -53,7 +50,6 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; -import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -61,9 +57,7 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; -import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -73,8 +67,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -121,7 +115,6 @@ public void setUp() { when(metricMgr.registry("ssl")).thenReturn(metrics); when(ctx.log(SslFactoryRegistry.class)).thenReturn(new NullLogger()); when(factory.isRenewalEnabled()).thenReturn(true); - when(factory.reload()).thenReturn(true); when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis()); when(executor.isTerminated()).thenReturn(true); @@ -130,8 +123,8 @@ public void setUp() { return null; }).when(executor).execute(any(Runnable.class)); - when(executor.submit(any(Callable.class))).thenAnswer(call -> { - FutureTask task = new FutureTask<>(call.>getArgument(0)); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask task = new FutureTask<>(call.getArgument(0), null); task.run(); @@ -225,60 +218,23 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { /** */ @Test - public void testManualReloadFollowsInitialScheduling() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); - AtomicReference caller = new AtomicReference<>(); - AtomicReference> reload = new AtomicReference<>(); - - when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); - doAnswer(call -> { - IgniteInternalFuture fut = runAsync(() -> { - caller.set(Thread.currentThread()); - - registry.reload(factory); - }); + public void testInitialSchedulingRunsOnRenewalThread() { + AtomicReference initialScheduling = new AtomicReference<>(); - reload.set(fut); - - long deadline = System.nanoTime() + SECONDS.toNanos(5); - - assertTrue(waitForCondition(() -> { - if (fut.isDone()) - return true; - - Thread thread = caller.get(); - ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); - - return info != null && info.getThreadState() == Thread.State.BLOCKED && - info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); - }, () -> System.nanoTime() < deadline)); - - return call.callRealMethod(); - }).when(renewalExecutor).execute(any(Runnable.class)); doAnswer(call -> { - renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); + initialScheduling.set(call.getArgument(0)); - return true; - }).when(factory).reload(); + return null; + }).when(executor).execute(any(Runnable.class)); registry.register("communication", factory); + registry.start(ctx); - try { - registry.start(ctx); - } - finally { - if (reload.get() != null) - reload.get().get(5_000); - } + verify(executor, never()).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - ArgumentCaptor delays = ArgumentCaptor.forClass(Long.class); - InOrder order = inOrder(renewalExecutor, factory); + initialScheduling.get().run(); - order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); - order.verify(factory).reload(); - order.verify(renewalExecutor).schedule(any(Runnable.class), delays.capture(), eq(MILLISECONDS)); - assertTrue(delays.getValue() > HOURS.toMillis(2)); + verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } /** */ @@ -325,57 +281,33 @@ public void testNormalReturnResetsFailuresAndReadsNextWindow() { /** */ @Test - public void testSuccessfulRenewalInCurrentWindowWaitsOneHour() { + public void testRenewalLeavingCertificatesDueIsFailure() { IgniteLogger log = mock(IgniteLogger.class); when(ctx.log(SslFactoryRegistry.class)).thenReturn(log); registry.register("communication", factory); registry.start(ctx); - - long before = System.currentTimeMillis(); - scheduled.run(); - assertTrue(delay >= HOURS.toMillis(1) - (System.currentTimeMillis() - before)); - assertTrue(delay <= HOURS.toMillis(1)); - assertEquals(0, failures()); - verify(log).warning(startsWith("TLS certificates are already in their renewal window [transports=communication, nextAttempt=")); + verify(factory).renew(); + assertEquals(1, failures()); + verify(log).warning(startsWith("TLS renewal failed [transports=communication, nextAttempt="), any(IgniteException.class)); } /** */ @Test - public void testManualReloadInCurrentWindowSchedulesRenewalInOneHour() throws Exception { + public void testManualReloadInCurrentWindowAttemptsImmediately() throws Exception { registry.register("communication", factory); registry.start(ctx); ScheduledFuture previous = pending; - long before = System.currentTimeMillis(); registry.reload(factory); - verify(factory).reload(); + verify(factory).init(); verify(previous).cancel(false); - assertTrue(delay >= HOURS.toMillis(1) - (System.currentTimeMillis() - before)); - assertTrue(delay <= HOURS.toMillis(1)); - } - - /** */ - @Test - public void testSuccessfulRenewalKeepsFutureWindowWithinOneHour() { - registry.register("communication", factory); - registry.start(ctx); - - long before = System.currentTimeMillis(); - - when(factory.getRenewalTime()).thenReturn(before + MINUTES.toMillis(30)); - - scheduled.run(); - - verify(factory).renew(); - assertEquals(1, metric("AutomaticRenewalAttempts")); - assertTrue(delay >= MINUTES.toMillis(30) - (System.currentTimeMillis() - before)); - assertTrue(delay <= MINUTES.toMillis(30)); + assertEquals(0, delay); } /** */ @@ -393,7 +325,7 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - assertTrue(registry.reload(factory)); + registry.reload(factory); verify(retry).cancel(false); assertEquals(0, failures()); @@ -402,43 +334,21 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { /** */ @Test - public void testUnchangedManualReloadKeepsRetryAndFailures() throws Exception { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - - registry.register("communication", factory); - registry.start(ctx); - scheduled.run(); - - ScheduledFuture retry = pending; - - when(factory.reload()).thenReturn(false); - - assertFalse(registry.reload(factory)); - assertEquals(1, failures()); - assertEquals(1, metric("ManualReloadAttempts")); - assertEquals(1, metric("AutomaticRenewalAttempts")); - verify(retry, never()).cancel(false); - verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - } - - /** */ - @Test - public void testManualAttemptMetricCountsAppliedUnchangedAndFailedLoads() throws Exception { + public void testManualAttemptMetricCountsAppliedAndFailedLoads() throws Exception { when(factory.isRenewalEnabled()).thenReturn(false); - when(factory.reload()).thenReturn(true, false).thenThrow(new IgniteException("Source unavailable")); + doNothing().doThrow(new IgniteException("Source unavailable")).when(factory).init(); registry.register("communication", factory); registry.start(ctx); + registry.reload(factory); - assertTrue(registry.reload(factory)); - assertFalse(registry.reload(factory)); assertThrows(IgniteException.class, () -> registry.reload(factory)); - assertEquals(3, metric("ManualReloadAttempts")); + assertEquals(2, metric("ManualReloadAttempts")); assertEquals(0, metric("AutomaticRenewalAttempts")); metrics.reset(); - assertEquals(3, metric("ManualReloadAttempts")); + assertEquals(2, metric("ManualReloadAttempts")); } /** */ @@ -449,7 +359,7 @@ public void testManualReloadInitializesGenericFactory() throws Exception { registry.register("communication", genericFactory); registry.start(ctx); - assertTrue(registry.reload(genericFactory)); + registry.reload(genericFactory); verify(genericFactory).init(); assertEquals(1, metric("ManualReloadAttempts")); @@ -467,7 +377,7 @@ public void testFailedManualReloadKeepsRetryAndFailures() { ScheduledFuture retry = pending; IgniteException err = new IgniteException("Invalid replacement"); - doThrow(err).when(factory).reload(); + doThrow(err).when(factory).init(); IgniteCheckedException actual = assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)); @@ -483,8 +393,8 @@ public void testFailedManualReloadKeepsRetryAndFailures() { @Test public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - when(executor.submit(any(Callable.class))).thenAnswer(call -> { - FutureTask reload = new FutureTask<>(call.>getArgument(0)); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask reload = new FutureTask<>(call.getArgument(0), null); reload.run(); scheduled.run(); @@ -505,7 +415,6 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { SslContextFactory otherFactory = mock(SslContextFactory.class); when(otherFactory.isRenewalEnabled()).thenReturn(true); - when(otherFactory.reload()).thenReturn(true); when(otherFactory.getRenewalTime()).thenReturn(1L); doThrow(new IgniteException("Source unavailable")).when(factory).renew(); doThrow(new IgniteException("Source unavailable")).when(otherFactory).renew(); @@ -538,6 +447,8 @@ public void testMetricResetDoesNotCorruptFailureCount() { assertEquals(1, failures()); + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + scheduled.run(); assertEquals(0, failures()); @@ -563,8 +474,8 @@ public void testRenewalDoesNotInheritCallerContext() throws Exception { assertEquals("operator", OperationContext.get(caller)); renewalTime.set(System.currentTimeMillis()); - return true; - }).when(factory).reload(); + return null; + }).when(factory).init(); registry.register("communication", factory); @@ -683,7 +594,7 @@ public void testStopCancelsQueuedReload() throws Exception { submissions.add(fut); return fut; - }).when(renewalExecutor).submit(any(Callable.class)); + }).when(renewalExecutor).submit(any(Runnable.class)); registry.register("communication", factory); @@ -703,7 +614,7 @@ public void testStopCancelsQueuedReload() throws Exception { registry.stop(); assertTrue(reload.get(5_000).getCause() instanceof CancellationException); - verify(factory, never()).reload(); + verify(factory, never()).init(); assertEquals(0, metric("ManualReloadAttempts")); } finally { @@ -741,7 +652,7 @@ public void testInterruptedCallerCancelsQueuedReload() throws Exception { reloadSubmitted.countDown(); return fut; - }).when(renewalExecutor).submit(any(Callable.class)); + }).when(renewalExecutor).submit(any(Runnable.class)); registry.register("communication", factory); @@ -769,7 +680,7 @@ public void testInterruptedCallerCancelsQueuedReload() throws Exception { allowCompletion.countDown(); renewalExecutor.submit(() -> { }).get(5, SECONDS); - verify(factory, never()).reload(); + verify(factory, never()).init(); assertEquals(0, metric("ManualReloadAttempts")); } finally { diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 986c2a350e5e0..313b0bd6d47a4 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -25,7 +25,6 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.testframework.GridTestThread; @@ -45,7 +44,6 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; /** */ @@ -80,9 +78,9 @@ public void testDisabledRenewalAcceptsStoreWithoutPrivateKeys() throws Exception factory.setRenewalEnabled(false); factory.create(); + factory.init(); assertEquals(0, factory.getRenewalTime()); - assertFalse(factory.reload()); } /** */ @@ -238,21 +236,6 @@ public void testRenewReplacesExpiredContext() throws Exception { assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); } - /** */ - @Test - public void testRenewRejectsUnchangedMaterial() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("first", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); - - IgniteException err = assertThrows(IgniteException.class, factory::renew); - - assertTrue(err.getMessage().contains("unchanged")); - assertSame(ctx, factory.create()); - assertEquals(renewalTime, factory.getRenewalTime()); - } - /** */ @Test public void testManualReloadAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { @@ -263,7 +246,8 @@ public void testManualReloadAcceptsDifferentCertificatesWithSameOrEarlierExpiry( factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(remainingHours)); - assertTrue(factory.reload()); + factory.init(); + assertNotSame(ctx, factory.create()); } } @@ -283,24 +267,6 @@ public void testRenewAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throw } } - /** */ - @Test - public void testManualReloadKeepsUnchangedContext() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); - - factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); - - assertFalse(factory.reload()); - - factory.init(); - - assertSame(ctx, factory.create()); - assertEquals(renewalTime, factory.getRenewalTime()); - } - /** */ @Test public void testManualReloadAppliesTrustStoreChanges() throws Exception { @@ -317,36 +283,8 @@ public void testManualReloadAppliesTrustStoreChanges() throws Exception { factory.trustStore.setCertificateEntry("other-issuer", otherIssuer.trustStore().getCertificate("CN=other-issuer")); - assertTrue(factory.reload()); - assertNotSame(ctx, factory.create()); - assertFalse(factory.reload()); - } - - /** */ - @Test - public void testManualReloadDetectsPrivateKeyChange() throws Exception { - KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); - InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); - SSLContext ctx = factory.create(); - KeyStore other = authority.keyStore("other", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); - - keys.setKeyEntry("node", other.getKey("other", PASSWORD), PASSWORD, keys.getCertificateChain("node")); - - assertTrue(factory.reload()); - assertNotSame(ctx, factory.create()); - } - - /** */ - @Test - public void testManualReloadWithCustomTrustManagerReplacesContext() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - - factory.setTrustManagers(mock(X509TrustManager.class)); - - SSLContext ctx = factory.create(); + factory.init(); - assertTrue(factory.reload()); assertNotSame(ctx, factory.create()); } @@ -419,15 +357,11 @@ public void testManualReloadRejectsExpiredIssuer() throws Exception { factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - IgniteException err = assertThrows(IgniteException.class, factory::reload); + IgniteException err = assertThrows(IgniteException.class, factory::init); assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); - - factory.keyStore = keys; - - assertFalse(factory.reload()); } /** */ From 51a1e00171538fe4b27202e4f712ffb4304839cc Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 20:30:02 +0300 Subject: [PATCH 223/242] IGNITE-29211 Preserve renewal retry ordering during startup --- docs/_docs/security/ssl-tls.adoc | 6 +- .../processors/ssl/SslFactoryRegistry.java | 10 ++- .../ssl/SslFactoryRegistryTest.java | 61 ++++++++++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index 34cdeadef0a8d..fdd7f04a4f425 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -228,8 +228,9 @@ If this point has already passed when the node starts, Ignite schedules an attem See link:{javadoc_base_url}/org/apache/ignite/ssl/SslContextFactory.html#getRenewalTime()[getRenewalTime(), window=_blank] for the calculation. Automatic renewal applies the same certificate checks as <>. -An automatic attempt fails if the loaded certificates are still within their renewal window, for example when the source still returns the old certificates. -Ignite then retries with the intervals below. +If the loaded certificates are still within their renewal window, Ignite records a failed renewal attempt even though it installed the valid material. +This can happen when the certificates have not changed, only the trust store changed, or the replacements are already within their renewal window. +The loaded context remains in use while Ignite retries with the intervals below. After a manual reload, Ignite schedules the next automatic attempt from the loaded certificates. If their renewal window has already started, the attempt runs immediately. @@ -260,6 +261,7 @@ To recover without waiting for the next retry: *What a successful command means.* The result `reloaded` means that the node loaded the stores and accepted their certificate dates. +The command also reports `reloaded` when the files have not changed. Checking certificate dates does not establish whether peers trust the certificates or whether they have been revoked. Verify a new TLS connection after a replacement. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 978c0ce07bec8..98286ab72f44c 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -158,9 +158,13 @@ public synchronized void stop() { * @throws IgniteCheckedException If an asynchronous reload fails or is interrupted. */ public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - ScheduledExecutorService executor = this.executor; - Renewal renewal = executor == null ? null : renewals.get(factory); - Future reload = renewal == null ? null : executor.submit(renewal::reload); + Future reload; + + synchronized (this) { + Renewal renewal = executor == null ? null : renewals.get(factory); + + reload = renewal == null ? null : executor.submit(renewal::reload); + } if (reload == null) reloadFactory(factory); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 7267d61061c09..7c69e806f9d6d 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,6 +17,8 @@ package org.apache.ignite.internal.processors.ssl; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CancellationException; @@ -50,6 +52,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -57,6 +60,7 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; @@ -69,6 +73,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -219,10 +224,10 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { /** */ @Test public void testInitialSchedulingRunsOnRenewalThread() { - AtomicReference initialScheduling = new AtomicReference<>(); + AtomicReference initScheduling = new AtomicReference<>(); doAnswer(call -> { - initialScheduling.set(call.getArgument(0)); + initScheduling.set(call.getArgument(0)); return null; }).when(executor).execute(any(Runnable.class)); @@ -232,11 +237,61 @@ public void testInitialSchedulingRunsOnRenewalThread() { verify(executor, never()).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - initialScheduling.get().run(); + initScheduling.get().run(); verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } + /** */ + @Test + public void testManualReloadFollowsInitialScheduling() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + AtomicReference caller = new AtomicReference<>(); + AtomicReference> reload = new AtomicReference<>(); + + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + doAnswer(call -> { + IgniteInternalFuture fut = runAsync(() -> { + caller.set(Thread.currentThread()); + + registry.reload(factory); + }); + + reload.set(fut); + + long deadline = System.nanoTime() + SECONDS.toNanos(5); + + assertTrue(waitForCondition(() -> { + if (fut.isDone()) + return true; + + Thread thread = caller.get(); + ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); + + return info != null && info.getThreadState() == Thread.State.BLOCKED && + info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); + }, () -> System.nanoTime() < deadline)); + + return call.callRealMethod(); + }).when(renewalExecutor).execute(any(Runnable.class)); + + registry.register("communication", factory); + + try { + registry.start(ctx); + } + finally { + if (reload.get() != null) + reload.get().get(5_000); + } + + InOrder order = inOrder(renewalExecutor, factory); + + order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + order.verify(factory).init(); + order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + } + /** */ @Test public void testFailuresIncreaseRetryDelayUntilLimit() { From 1288607db8b0e34764106b5ec918497c66554171 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 20:43:02 +0300 Subject: [PATCH 224/242] IGNITE-29211 Fix IDEA warnings in TLS renewal start() writes ctx and log under the registry monitor, and the renewal thread reads them without it, so both fields are volatile. Pattern variables replace casts after instanceof checks in the registry and the factory. This also removes the cast to SslContextFactory that IDEA reports as overly strong. testInitialSchedulingRunsOnRenewalThread is removed: testManualReloadFollowsInitialScheduling also fails when start() schedules attempts on its own thread. Co-Authored-By: Claude Opus 5.5 --- .../apache/ignite/ssl/SslContextFactory.java | 4 +--- .../processors/ssl/SslFactoryRegistry.java | 16 +++++++------- .../ssl/SslFactoryRegistryTest.java | 21 ------------------- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index c98bcec66809a..21937c7f82191 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -396,11 +396,9 @@ private long certificateRenewalTime(KeyStore store, boolean replacement) throws long chainExpiry = Long.MAX_VALUE; for (Certificate cert : chain) { - if (!(cert instanceof X509Certificate)) + if (!(cert instanceof X509Certificate x509)) throw new SSLException("Certificate chain is not X.509 [alias=" + alias + "]. Remove the key or add its X.509 chain."); - X509Certificate x509 = (X509Certificate)cert; - if (replacement) { try { x509.checkValidity(now); diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 98286ab72f44c..0863bde0f70a0 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -58,10 +58,10 @@ public class SslFactoryRegistry { private volatile ScheduledExecutorService executor; /** */ - private GridKernalContext ctx; + private volatile GridKernalContext ctx; /** */ - private IgniteLogger log; + private volatile IgniteLogger log; /** */ private final AtomicLong consecutiveFailures = new AtomicLong(); @@ -98,19 +98,19 @@ public synchronized void start(GridKernalContext ctx) { Map> factories = factories(); for (Factory factory : factories.values()) { - if (factory instanceof SslContextFactory && ((SslContextFactory)factory).isRenewalEnabled() && !renewals.containsKey(factory)) { + if (factory instanceof SslContextFactory sslFactory && sslFactory.isRenewalEnabled() && !renewals.containsKey(factory)) { if (ctx.isStopping()) return; - if (((SslContextFactory)factory).getRenewalTime() == 0) - ((SslContextFactory)factory).init(); + if (sslFactory.getRenewalTime() == 0) + sslFactory.init(); String transports = factories.entrySet().stream() .filter(entry -> entry.getValue() == factory) .map(Map.Entry::getKey) .collect(joining(", ")); - renewals.put(factory, new Renewal((SslContextFactory)factory, transports)); + renewals.put(factory, new Renewal(sslFactory, transports)); } } @@ -132,8 +132,8 @@ public synchronized void stop() { return; for (Runnable task : executor.shutdownNow()) { - if (task instanceof Future) - ((Future)task).cancel(false); + if (task instanceof Future fut) + fut.cancel(false); } boolean interrupted = false; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 7c69e806f9d6d..2db94451d06d9 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -221,27 +221,6 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { executorFactory.verifyNoInteractions(); } - /** */ - @Test - public void testInitialSchedulingRunsOnRenewalThread() { - AtomicReference initScheduling = new AtomicReference<>(); - - doAnswer(call -> { - initScheduling.set(call.getArgument(0)); - - return null; - }).when(executor).execute(any(Runnable.class)); - - registry.register("communication", factory); - registry.start(ctx); - - verify(executor, never()).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - - initScheduling.get().run(); - - verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - } - /** */ @Test public void testManualReloadFollowsInitialScheduling() throws Exception { From 03b2a539248c79b923ad9e381b8e7094ff64c24f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 20:50:45 +0300 Subject: [PATCH 225/242] IGNITE-29211 Rely on executor publication for registry fields --- .../ignite/internal/processors/ssl/SslFactoryRegistry.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 0863bde0f70a0..e0ad66ba079d3 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -58,10 +58,10 @@ public class SslFactoryRegistry { private volatile ScheduledExecutorService executor; /** */ - private volatile GridKernalContext ctx; + private GridKernalContext ctx; /** */ - private volatile IgniteLogger log; + private IgniteLogger log; /** */ private final AtomicLong consecutiveFailures = new AtomicLong(); From f61dce59ae8f02141b27c37faf70db8f1dafd97b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 20:58:25 +0300 Subject: [PATCH 226/242] IGNITE-29211 Keep renewal dependencies in each renewal Only the renewal thread reads the node context and the logger, so each Renewal holds them in final fields. The registry no longer writes fields under its monitor that the renewal thread reads without it, and IDEA stops reporting ctx and log as accessed in both contexts. Co-Authored-By: Claude Opus 5.5 --- .../processors/ssl/SslFactoryRegistry.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index e0ad66ba079d3..6157f2978f0c1 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -57,12 +57,6 @@ public class SslFactoryRegistry { /** */ private volatile ScheduledExecutorService executor; - /** */ - private GridKernalContext ctx; - - /** */ - private IgniteLogger log; - /** */ private final AtomicLong consecutiveFailures = new AtomicLong(); @@ -85,9 +79,6 @@ public synchronized Map> factories() { /** */ public synchronized void start(GridKernalContext ctx) { - this.ctx = ctx; - log = ctx.log(getClass()); - MetricRegistryImpl metrics = ctx.metric().registry("ssl"); metrics.register("ConsecutiveRenewalFailures", consecutiveFailures::get, @@ -110,7 +101,7 @@ public synchronized void start(GridKernalContext ctx) { .map(Map.Entry::getKey) .collect(joining(", ")); - renewals.put(factory, new Renewal(sslFactory, transports)); + renewals.put(factory, new Renewal(sslFactory, transports, ctx)); } } @@ -187,11 +178,6 @@ private void reloadFactory(AbstractSslContextFactory factory) { factory.init(); } - /** */ - private boolean stopping() { - return ctx.isStopping() || executor.isShutdown(); - } - /** */ private class Renewal implements Runnable { /** */ @@ -200,6 +186,12 @@ private class Renewal implements Runnable { /** */ private final String transports; + /** */ + private final GridKernalContext ctx; + + /** */ + private final IgniteLogger log; + /** */ private ScheduledFuture pending; @@ -207,9 +199,12 @@ private class Renewal implements Runnable { private int failures; /** */ - private Renewal(SslContextFactory factory, String transports) { + private Renewal(SslContextFactory factory, String transports, GridKernalContext ctx) { this.factory = factory; this.transports = transports; + this.ctx = ctx; + + log = ctx.log(SslFactoryRegistry.class); } /** {@inheritDoc} */ @@ -259,6 +254,11 @@ private void reload() { schedule(); } + /** */ + private boolean stopping() { + return ctx.isStopping() || executor.isShutdown(); + } + /** */ private void resetFailures() { consecutiveFailures.addAndGet(-failures); From 779e7f86e4cd688793b01a791d45a80628d4407c Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 21:11:27 +0300 Subject: [PATCH 227/242] IGNITE-29211 Keep one failure counter per renewal Each Renewal counts its consecutive failures, and ssl.ConsecutiveRenewalFailures sums these counters. The registry no longer mirrors them in a second counter that a reset had to subtract from. An attempt handles its outcomes in order: a failed load and a load that leaves the certificates due go to retry(), and any other result ends the failure streak and schedules the next renewal date. Co-Authored-By: Claude Opus 5.5 --- .../processors/ssl/SslFactoryRegistry.java | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 6157f2978f0c1..f05adc75417ad 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -26,6 +26,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; @@ -57,9 +58,6 @@ public class SslFactoryRegistry { /** */ private volatile ScheduledExecutorService executor; - /** */ - private final AtomicLong consecutiveFailures = new AtomicLong(); - /** */ private final AtomicLong manualReloadAttempts = new AtomicLong(); @@ -81,8 +79,6 @@ public synchronized Map> factories() { public synchronized void start(GridKernalContext ctx) { MetricRegistryImpl metrics = ctx.metric().registry("ssl"); - metrics.register("ConsecutiveRenewalFailures", consecutiveFailures::get, - "Sum of consecutive automatic certificate renewal failures across SSL factories."); metrics.register("ManualReloadAttempts", manualReloadAttempts::get, "Number of manual SSL factory reload attempts."); metrics.register("AutomaticRenewalAttempts", automaticRenewalAttempts::get, "Number of automatic SSL factory renewal attempts."); @@ -105,6 +101,9 @@ public synchronized void start(GridKernalContext ctx) { } } + metrics.register("ConsecutiveRenewalFailures", () -> renewals.values().stream().mapToLong(r -> r.failures.get()).sum(), + "Sum of consecutive automatic certificate renewal failures across SSL factories."); + if (renewals.isEmpty() || ctx.isStopping()) return; @@ -196,7 +195,7 @@ private class Renewal implements Runnable { private ScheduledFuture pending; /** */ - private int failures; + private final AtomicInteger failures = new AtomicInteger(); /** */ private Renewal(SslContextFactory factory, String transports, GridKernalContext ctx) { @@ -212,37 +211,38 @@ private Renewal(SslContextFactory factory, String transports, GridKernalContext if (stopping()) return; - Exception failure = null; - long nextAttempt = 0; + automaticRenewalAttempts.incrementAndGet(); try { - automaticRenewalAttempts.incrementAndGet(); - factory.renew(); - nextAttempt = factory.getRenewalTime(); - - if (nextAttempt != 0 && nextAttempt <= System.currentTimeMillis()) - throw new IgniteException("Loaded certificates are still due for renewal [renewalTime=" + new Date(nextAttempt) + ']'); } catch (Exception e) { - failure = e; + retry(e); + + return; } - if (stopping()) + long renewalTime = factory.getRenewalTime(); + + if (renewalTime != 0 && renewalTime <= System.currentTimeMillis()) { + retry(new IgniteException("Loaded certificates are still due for renewal [renewalTime=" + new Date(renewalTime) + ']')); + return; + } - if (failure == null) - resetFailures(); - else { - failures++; - consecutiveFailures.incrementAndGet(); + failures.set(0); + schedule(renewalTime); + } - long delay = Math.min(MINUTES.toMillis(1) << Math.min(failures - 1, 6), MINUTES.toMillis(60)); + /** */ + private void retry(Exception cause) { + if (stopping()) + return; - nextAttempt = System.currentTimeMillis() + delay + ThreadLocalRandom.current().nextLong(delay / 2 + 1); + long delay = Math.min(MINUTES.toMillis(1) << Math.min(failures.incrementAndGet() - 1, 6), MINUTES.toMillis(60)); + long nextAttempt = System.currentTimeMillis() + delay + ThreadLocalRandom.current().nextLong(delay / 2 + 1); - log.warning("TLS renewal failed [transports=" + transports + ", nextAttempt=" + new Date(nextAttempt) + ']', failure); - } + log.warning("TLS renewal failed [transports=" + transports + ", nextAttempt=" + new Date(nextAttempt) + ']', cause); schedule(nextAttempt); } @@ -250,7 +250,7 @@ private Renewal(SslContextFactory factory, String transports, GridKernalContext /** */ private void reload() { reloadFactory(factory); - resetFailures(); + failures.set(0); schedule(); } @@ -259,12 +259,6 @@ private boolean stopping() { return ctx.isStopping() || executor.isShutdown(); } - /** */ - private void resetFailures() { - consecutiveFailures.addAndGet(-failures); - failures = 0; - } - /** */ private void schedule() { schedule(factory.getRenewalTime()); From 0256992f5efba36cdb112ec87bcf4c861a5d82b1 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 21:17:34 +0300 Subject: [PATCH 228/242] IGNITE-29211 End the failure streak where renewal is scheduled scheduleRenewal() resets the failure count and schedules the attempt at the renewal time. The initial scheduling, a successful attempt and a manual reload all return to the regular schedule through it, so none of them resets the count separately. Co-Authored-By: Claude Opus 5.5 --- .../processors/ssl/SslFactoryRegistry.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index f05adc75417ad..be2f969cfd2fe 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -110,7 +110,7 @@ public synchronized void start(GridKernalContext ctx) { executor = newSingleThreadScheduledExecutor("ssl-renewal", ctx.igniteInstanceName()); try (Scope ignored = OperationContext.restoreSnapshot(null)) { - executor.execute(() -> renewals.values().forEach(Renewal::schedule)); + executor.execute(() -> renewals.values().forEach(Renewal::scheduleRenewal)); } } @@ -224,14 +224,10 @@ private Renewal(SslContextFactory factory, String transports, GridKernalContext long renewalTime = factory.getRenewalTime(); - if (renewalTime != 0 && renewalTime <= System.currentTimeMillis()) { + if (renewalTime != 0 && renewalTime <= System.currentTimeMillis()) retry(new IgniteException("Loaded certificates are still due for renewal [renewalTime=" + new Date(renewalTime) + ']')); - - return; - } - - failures.set(0); - schedule(renewalTime); + else + scheduleRenewal(); } /** */ @@ -250,8 +246,7 @@ private void retry(Exception cause) { /** */ private void reload() { reloadFactory(factory); - failures.set(0); - schedule(); + scheduleRenewal(); } /** */ @@ -259,8 +254,9 @@ private boolean stopping() { return ctx.isStopping() || executor.isShutdown(); } - /** */ - private void schedule() { + /** Ends the failure streak and schedules the next attempt at the renewal time. */ + private void scheduleRenewal() { + failures.set(0); schedule(factory.getRenewalTime()); } From d6d724b0b35dc8c2fb2e48efdf02feb84c35acbe Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 21:27:53 +0300 Subject: [PATCH 229/242] IGNITE-29211 Load manual reloads on the caller thread A manual reload loads the factory in the calling thread. The factory monitor keeps its loads one at a time. For a factory with automatic renewal, the renewal thread then only moves the schedule to the loaded certificates, so the reload no longer needs a future, cancellation or the registry monitor. start() publishes the renewals after it queues the initial scheduling. A manual reload that arrives earlier only loads the factory, and the initial scheduling reads the loaded renewal time. Co-Authored-By: Claude Opus 5.5 --- .../processors/ssl/SslFactoryRegistry.java | 58 +---- .../ssl/SslFactoryRegistryTest.java | 239 ++---------------- 2 files changed, 37 insertions(+), 260 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index be2f969cfd2fe..7200ba3ee59a3 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -17,11 +17,11 @@ package org.apache.ignite.internal.processors.ssl; +import java.util.Collections; import java.util.Date; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -30,15 +30,12 @@ import java.util.concurrent.atomic.AtomicLong; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; -import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; @@ -53,7 +50,7 @@ public class SslFactoryRegistry { private final Map> factories = new LinkedHashMap<>(); /** */ - private final Map, Renewal> renewals = new IdentityHashMap<>(); + private volatile Map, Renewal> renewals = Collections.emptyMap(); /** */ private volatile ScheduledExecutorService executor; @@ -79,10 +76,13 @@ public synchronized Map> factories() { public synchronized void start(GridKernalContext ctx) { MetricRegistryImpl metrics = ctx.metric().registry("ssl"); + metrics.register("ConsecutiveRenewalFailures", () -> renewals.values().stream().mapToLong(r -> r.failures.get()).sum(), + "Sum of consecutive automatic certificate renewal failures across SSL factories."); metrics.register("ManualReloadAttempts", manualReloadAttempts::get, "Number of manual SSL factory reload attempts."); metrics.register("AutomaticRenewalAttempts", automaticRenewalAttempts::get, "Number of automatic SSL factory renewal attempts."); Map> factories = factories(); + Map, Renewal> renewals = new IdentityHashMap<>(); for (Factory factory : factories.values()) { if (factory instanceof SslContextFactory sslFactory && sslFactory.isRenewalEnabled() && !renewals.containsKey(factory)) { @@ -101,9 +101,6 @@ public synchronized void start(GridKernalContext ctx) { } } - metrics.register("ConsecutiveRenewalFailures", () -> renewals.values().stream().mapToLong(r -> r.failures.get()).sum(), - "Sum of consecutive automatic certificate renewal failures across SSL factories."); - if (renewals.isEmpty() || ctx.isStopping()) return; @@ -112,6 +109,8 @@ public synchronized void start(GridKernalContext ctx) { try (Scope ignored = OperationContext.restoreSnapshot(null)) { executor.execute(() -> renewals.values().forEach(Renewal::scheduleRenewal)); } + + this.renewals = renewals; } /** */ @@ -121,10 +120,7 @@ public synchronized void stop() { if (executor == null) return; - for (Runnable task : executor.shutdownNow()) { - if (task instanceof Future fut) - fut.cancel(false); - } + executor.shutdownNow(); boolean interrupted = false; @@ -145,36 +141,16 @@ public synchronized void stop() { * Reloads the factory and resets its automatic renewal schedule and failure count. * * @param factory Factory to reload. - * @throws IgniteCheckedException If an asynchronous reload fails or is interrupted. */ - public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { - Future reload; - - synchronized (this) { - Renewal renewal = executor == null ? null : renewals.get(factory); - - reload = renewal == null ? null : executor.submit(renewal::reload); - } - - if (reload == null) - reloadFactory(factory); - else { - try { - U.get(reload); - } - catch (IgniteInterruptedCheckedException e) { - reload.cancel(true); - - throw e; - } - } - } - - /** */ - private void reloadFactory(AbstractSslContextFactory factory) { + public void reload(AbstractSslContextFactory factory) { manualReloadAttempts.incrementAndGet(); factory.init(); + + Renewal renewal = renewals.get(factory); + + if (renewal != null) + executor.execute(renewal::scheduleRenewal); } /** */ @@ -243,12 +219,6 @@ private void retry(Exception cause) { schedule(nextAttempt); } - /** */ - private void reload() { - reloadFactory(factory); - scheduleRenewal(); - } - /** */ private boolean stopping() { return ctx.isStopping() || executor.isShutdown(); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 2db94451d06d9..1bafd73416c9e 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,26 +17,18 @@ package org.apache.ignite.internal.processors.ssl; -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; -import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.processors.metric.GridMetricManager; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; @@ -52,7 +44,6 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; -import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -60,9 +51,7 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; -import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -73,7 +62,6 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -128,13 +116,6 @@ public void setUp() { return null; }).when(executor).execute(any(Runnable.class)); - when(executor.submit(any(Runnable.class))).thenAnswer(call -> { - FutureTask task = new FutureTask<>(call.getArgument(0), null); - - task.run(); - - return task; - }); when(executor.schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS))).thenAnswer(call -> { scheduled = call.getArgument(0); @@ -223,52 +204,31 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { /** */ @Test - public void testManualReloadFollowsInitialScheduling() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - AtomicReference caller = new AtomicReference<>(); - AtomicReference> reload = new AtomicReference<>(); + public void testManualReloadDuringStartupLeavesSchedulingToStartup() { + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); + AtomicBoolean reloaded = new AtomicBoolean(); - when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); doAnswer(call -> { - IgniteInternalFuture fut = runAsync(() -> { - caller.set(Thread.currentThread()); + renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); + return null; + }).when(factory).init(); + doAnswer(call -> { + if (reloaded.compareAndSet(false, true)) registry.reload(factory); - }); - - reload.set(fut); - - long deadline = System.nanoTime() + SECONDS.toNanos(5); - - assertTrue(waitForCondition(() -> { - if (fut.isDone()) - return true; - Thread thread = caller.get(); - ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); - - return info != null && info.getThreadState() == Thread.State.BLOCKED && - info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); - }, () -> System.nanoTime() < deadline)); + call.getArgument(0).run(); - return call.callRealMethod(); - }).when(renewalExecutor).execute(any(Runnable.class)); + return null; + }).when(executor).execute(any(Runnable.class)); registry.register("communication", factory); + registry.start(ctx); - try { - registry.start(ctx); - } - finally { - if (reload.get() != null) - reload.get().get(5_000); - } - - InOrder order = inOrder(renewalExecutor, factory); - - order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - order.verify(factory).init(); - order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + verify(factory).init(); + verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + assertTrue(delay > HOURS.toMillis(2)); } /** */ @@ -331,7 +291,7 @@ public void testRenewalLeavingCertificatesDueIsFailure() { /** */ @Test - public void testManualReloadInCurrentWindowAttemptsImmediately() throws Exception { + public void testManualReloadInCurrentWindowAttemptsImmediately() { registry.register("communication", factory); registry.start(ctx); @@ -346,7 +306,7 @@ public void testManualReloadInCurrentWindowAttemptsImmediately() throws Exceptio /** */ @Test - public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { + public void testManualReloadReplacesRetryAndResetsFailures() { doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); @@ -368,7 +328,7 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { /** */ @Test - public void testManualAttemptMetricCountsAppliedAndFailedLoads() throws Exception { + public void testManualAttemptMetricCountsAppliedAndFailedLoads() { when(factory.isRenewalEnabled()).thenReturn(false); doNothing().doThrow(new IgniteException("Source unavailable")).when(factory).init(); @@ -387,7 +347,7 @@ public void testManualAttemptMetricCountsAppliedAndFailedLoads() throws Exceptio /** */ @Test - public void testManualReloadInitializesGenericFactory() throws Exception { + public void testManualReloadInitializesGenericFactory() { AbstractSslContextFactory genericFactory = mock(AbstractSslContextFactory.class); registry.register("communication", genericFactory); @@ -413,10 +373,7 @@ public void testFailedManualReloadKeepsRetryAndFailures() { doThrow(err).when(factory).init(); - IgniteCheckedException actual = assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)); - - assertSame(err, actual.getCause()); - assertEquals(err.getMessage(), actual.getMessage()); + assertSame(err, assertThrows(IgniteException.class, () -> registry.reload(factory))); assertEquals(1, failures()); assertEquals(1, metric("ManualReloadAttempts")); verify(retry, never()).cancel(false); @@ -425,27 +382,7 @@ public void testFailedManualReloadKeepsRetryAndFailures() { /** */ @Test - public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - when(executor.submit(any(Runnable.class))).thenAnswer(call -> { - FutureTask reload = new FutureTask<>(call.getArgument(0), null); - - reload.run(); - scheduled.run(); - - return reload; - }); - - registry.register("communication", factory); - registry.start(ctx); - registry.reload(factory); - - assertEquals(1, failures()); - } - - /** */ - @Test - public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { + public void testMetricKeepsOtherFactoryFailuresAfterReload() { SslContextFactory otherFactory = mock(SslContextFactory.class); when(otherFactory.isRenewalEnabled()).thenReturn(true); @@ -490,7 +427,7 @@ public void testMetricResetDoesNotCorruptFailureCount() { /** */ @Test - public void testRenewalDoesNotInheritCallerContext() throws Exception { + public void testRenewalDoesNotInheritCallerContext() { OperationContextAttribute caller = OperationContextAttribute.newInstance(); AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); BlockingQueue> attemptCallers = new LinkedBlockingQueue<>(); @@ -608,126 +545,6 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { assertTrue(interrupted.get()); } - /** */ - @Test - public void testStopCancelsQueuedReload() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch attemptStarted = new CountDownLatch(1); - CountDownLatch allowCompletion = new CountDownLatch(1); - BlockingQueue> submissions = new LinkedBlockingQueue<>(); - - doAnswer(call -> { - attemptStarted.countDown(); - assertTrue(allowCompletion.await(10, SECONDS)); - - return null; - }).when(factory).renew(); - doAnswer(call -> { - Future fut = (Future)call.callRealMethod(); - - submissions.add(fut); - - return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); - - registry.register("communication", factory); - - IgniteInternalFuture reload = null; - Future queuedReload = null; - - try { - registry.start(ctx); - - assertTrue(attemptStarted.await(5, SECONDS)); - - reload = runAsync(() -> assertThrows(IgniteCheckedException.class, () -> registry.reload(factory))); - queuedReload = submissions.poll(5, SECONDS); - - assertNotNull(queuedReload); - - registry.stop(); - - assertTrue(reload.get(5_000).getCause() instanceof CancellationException); - verify(factory, never()).init(); - assertEquals(0, metric("ManualReloadAttempts")); - } - finally { - allowCompletion.countDown(); - - if (queuedReload != null) - queuedReload.cancel(true); - - if (reload != null) - reload.get(5_000); - } - } - - /** */ - @Test - public void testInterruptedCallerCancelsQueuedReload() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch attemptStarted = new CountDownLatch(1); - CountDownLatch allowCompletion = new CountDownLatch(1); - CountDownLatch reloadSubmitted = new CountDownLatch(1); - AtomicReference caller = new AtomicReference<>(); - AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); - - when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); - doAnswer(call -> { - attemptStarted.countDown(); - assertTrue(allowCompletion.await(10, SECONDS)); - renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); - - return null; - }).when(factory).renew(); - doAnswer(call -> { - Object fut = call.callRealMethod(); - - reloadSubmitted.countDown(); - - return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); - - registry.register("communication", factory); - - IgniteInternalFuture reload = null; - - try { - registry.start(ctx); - - assertTrue(attemptStarted.await(5, SECONDS)); - - reload = runAsync(() -> { - caller.set(Thread.currentThread()); - - assertThrows(IgniteInterruptedCheckedException.class, () -> registry.reload(factory)); - - return Thread.currentThread().isInterrupted(); - }); - - assertTrue(reloadSubmitted.await(5, SECONDS)); - - caller.get().interrupt(); - - assertTrue(reload.get(5_000)); - - allowCompletion.countDown(); - renewalExecutor.submit(() -> { }).get(5, SECONDS); - - verify(factory, never()).init(); - assertEquals(0, metric("ManualReloadAttempts")); - } - finally { - allowCompletion.countDown(); - - if (caller.get() != null) - caller.get().interrupt(); - - if (reload != null) - reload.get(5_000); - } - } - /** */ @Test public void testStoppingDuringLoadDoesNotCountFailure() { @@ -754,14 +571,4 @@ private long failures() { private long metric(String name) { return metrics.findMetric(name).value(); } - - /** */ - private ScheduledExecutorService useRealExecutor() { - ScheduledExecutorService renewalExecutor = spy(new IgniteScheduledThreadPoolExecutor("ssl-renewal", null, 1)); - - executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) - .thenReturn(renewalExecutor); - - return renewalExecutor; - } } From cedc54842539dd32b9c460da5c44d17833c12e98 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 21:40:47 +0300 Subject: [PATCH 230/242] IGNITE-29211 Separate replacement checks from renewal time createSslContext() applies two independent rules. A replacement of an existing context must have every certificate in its key chains valid now, whether renewal is enabled or not. With renewal enabled, the factory calculates the renewal time from the same chains. keyChains() reads the X.509 chains of the private keys, checkValidity() checks a replacement, and renewalTime() selects the chain that expires first. The factory no longer writes a zero renewal time when renewal is disabled: getRenewalTime() already returns zero in that case. Co-Authored-By: Claude Opus 5.5 --- .../apache/ignite/ssl/SslContextFactory.java | 99 +++++++++++++------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 21937c7f82191..58d6cbc2ecbc3 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -31,6 +31,8 @@ import java.util.Arrays; import java.util.Date; import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -160,9 +162,12 @@ public void renew() { @Override SSLContext createSslContext() throws SSLException { try { SSLContext ctx = super.createSslContext(); - boolean replacement = sslCtx.get() != null; - renewalTime = renewalEnabled || replacement ? certificateRenewalTime(loadedKeyStore, replacement) : 0; + if (sslCtx.get() != null) + checkValidity(keyChains(loadedKeyStore)); + + if (renewalEnabled) + renewalTime = renewalTime(keyChains(loadedKeyStore)); return ctx; } @@ -369,18 +374,13 @@ public static TrustManager getDisabledTrustManager() { } /** - * Validates the key chains and calculates the renewal date. - * * @param store Loaded key store. - * @param replacement Whether the certificates replace an existing context. - * @return Start of the renewal window, or {@code 0} if renewal is disabled. + * @return X.509 certificate chains of the private keys by alias. * @throws GeneralSecurityException If a key or certificate cannot be read. - * @throws SSLException If the certificates cannot be accepted. + * @throws SSLException If a private key has no X.509 certificate chain. */ - private long certificateRenewalTime(KeyStore store, boolean replacement) throws GeneralSecurityException, SSLException { - long earliestExpiry = Long.MAX_VALUE; - long selectedStart = Long.MAX_VALUE; - Date now = new Date(); + private Map keyChains(KeyStore store) throws GeneralSecurityException, SSLException { + Map chains = new LinkedHashMap<>(); for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { String alias = aliases.nextElement(); @@ -393,26 +393,68 @@ private long certificateRenewalTime(KeyStore store, boolean replacement) throws if (chain == null || chain.length == 0) throw new SSLException("Certificate chain is unavailable [alias=" + alias + "]. Remove the key or add its X.509 chain."); - long chainExpiry = Long.MAX_VALUE; + X509Certificate[] x509Chain = new X509Certificate[chain.length]; - for (Certificate cert : chain) { - if (!(cert instanceof X509Certificate x509)) + for (int i = 0; i < chain.length; i++) { + if (!(chain[i] instanceof X509Certificate cert)) throw new SSLException("Certificate chain is not X.509 [alias=" + alias + "]. Remove the key or add its X.509 chain."); - if (replacement) { - try { - x509.checkValidity(now); - } - catch (CertificateException e) { - throw new SSLException("Certificate is not currently valid [alias=" + alias + ", subject=" + - x509.getSubjectX500Principal() + ']', e); - } + x509Chain[i] = cert; + } + + chains.put(alias, x509Chain); + } + + return chains; + } + + /** + * Checks that every certificate in the key chains is valid now. + * + * @param chains Key chains by alias. + * @throws SSLException If a certificate is not valid or expires before the check completes. + */ + private static void checkValidity(Map chains) throws SSLException { + Date now = new Date(); + long earliestExpiry = Long.MAX_VALUE; + + for (Map.Entry chain : chains.entrySet()) { + for (X509Certificate cert : chain.getValue()) { + try { + cert.checkValidity(now); + } + catch (CertificateException e) { + throw new SSLException("Certificate is not currently valid [alias=" + chain.getKey() + ", subject=" + + cert.getSubjectX500Principal() + ']', e); } - chainExpiry = Math.min(chainExpiry, x509.getNotAfter().getTime()); + earliestExpiry = Math.min(earliestExpiry, cert.getNotAfter().getTime()); } + } + + if (earliestExpiry < System.currentTimeMillis()) + throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); + } + + /** + * @param chains Key chains by alias. + * @return Start of the renewal window of the chain that expires first. + * @throws SSLException If there are no key chains. + */ + private static long renewalTime(Map chains) throws SSLException { + if (chains.isEmpty()) + throw new SSLException("Certificate renewal requires a private key with an X.509 certificate chain."); + + long earliestExpiry = Long.MAX_VALUE; + long selectedStart = Long.MAX_VALUE; + + for (X509Certificate[] chain : chains.values()) { + long chainExpiry = Long.MAX_VALUE; + + for (X509Certificate cert : chain) + chainExpiry = Math.min(chainExpiry, cert.getNotAfter().getTime()); - long chainStart = ((X509Certificate)chain[0]).getNotBefore().getTime(); + long chainStart = chain[0].getNotBefore().getTime(); if (chainExpiry < earliestExpiry || chainExpiry == earliestExpiry && chainStart < selectedStart) { earliestExpiry = chainExpiry; @@ -420,14 +462,7 @@ private long certificateRenewalTime(KeyStore store, boolean replacement) throws } } - if (renewalEnabled && earliestExpiry == Long.MAX_VALUE) - throw new SSLException("Certificate renewal requires a private key with an X.509 certificate chain."); - - if (replacement && earliestExpiry < System.currentTimeMillis()) - throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); - - return renewalEnabled ? - earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION) : 0; + return earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION); } /** {@inheritDoc} */ From a6cf69f67323fc2548c3878065ae97d317b79467 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 21:48:11 +0300 Subject: [PATCH 231/242] IGNITE-29211 Return the stored renewal time Every load stores the renewal time of the loaded certificates, or zero when renewal is disabled, so getRenewalTime() returns the field as is. The registry plans only factories with renewal enabled, and renew() checks the setting itself. A load with renewal disabled now clears the time. Before this change, enabling renewal again for the same factory object in one JVM left the time of the certificates loaded before, and the node scheduled renewal from it. Co-Authored-By: Claude Opus 5.5 --- .../org/apache/ignite/ssl/SslContextFactory.java | 9 ++++----- .../ignite/ssl/SslContextFactoryRenewalTest.java | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 58d6cbc2ecbc3..4a037a3ccee69 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -126,11 +126,11 @@ public void setRenewalEnabled(boolean enabled) { * Its lifetime runs from the key certificate's {@code notBefore} to the earliest {@code notAfter} in its chain. * For chains with the same expiry, the longest lifetime is used. * - * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} if renewal is disabled - * or certificates have not yet been loaded with renewal enabled. + * @return Start of the renewal window in milliseconds since the epoch, or {@code 0} if the current certificates + * were loaded with renewal disabled or have not been loaded yet. */ public long getRenewalTime() { - return renewalEnabled ? renewalTime : 0; + return renewalTime; } /** @@ -166,8 +166,7 @@ public void renew() { if (sslCtx.get() != null) checkValidity(keyChains(loadedKeyStore)); - if (renewalEnabled) - renewalTime = renewalTime(keyChains(loadedKeyStore)); + renewalTime = renewalEnabled ? renewalTime(keyChains(loadedKeyStore)) : 0; return ctx; } diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 313b0bd6d47a4..a8bd07c7979fd 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -83,6 +83,20 @@ public void testDisabledRenewalAcceptsStoreWithoutPrivateKeys() throws Exception assertEquals(0, factory.getRenewalTime()); } + /** */ + @Test + public void testLoadWithRenewalDisabledClearsRenewalTime() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15))); + + factory.create(); + factory.setRenewalEnabled(false); + factory.init(); + factory.setRenewalEnabled(true); + + assertEquals(0, factory.getRenewalTime()); + } + /** */ @Test public void testRenewalWindowUsesLoadedKeyStore() throws Exception { From bbe71dfeca868cd8c9827d35c7238375938e69a2 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 22:34:55 +0300 Subject: [PATCH 232/242] IGNITE-29211 Check certificate dates on every load SslContextFactory checks that every certificate in the private-key chains is valid whenever it loads a key store: at startup, on manual reload and during renewal. A node with expired or not yet valid certificates does not start, and client applications that use the factory, including control.sh, reject such key stores before they connect. The fix is to supply valid certificates and start again. TcpDiscoverySslTrustedUntrustedTest.testExpired now expects the node with the expired certificate to fail at startup. testExpiredMismatchingCa is removed: that node fails at startup before it reaches a peer, so the test would check the same thing. Co-Authored-By: Claude Opus 5.5 --- docs/_docs/security/ssl-tls.adoc | 5 +-- .../apache/ignite/ssl/SslContextFactory.java | 11 ++++--- .../TcpDiscoverySslTrustedUntrustedTest.java | 17 +++------- .../ssl/SslContextFactoryRenewalTest.java | 32 ++++--------------- 4 files changed, 21 insertions(+), 44 deletions(-) diff --git a/docs/_docs/security/ssl-tls.adoc b/docs/_docs/security/ssl-tls.adoc index fdd7f04a4f425..8451d4ad36beb 100644 --- a/docs/_docs/security/ssl-tls.adoc +++ b/docs/_docs/security/ssl-tls.adoc @@ -176,10 +176,11 @@ The command asks for confirmation; use `--yes` to skip it. . Check the command result and the reload logs of any failed nodes, then verify a new connection with the replacement certificates. Established connections continue using their existing TLS context. -On reload, `SslContextFactory` checks that every certificate in each private-key chain is valid at that moment. +`SslContextFactory` checks that every certificate in each private-key chain is valid whenever it loads a key store: at node startup, on reload and during automatic renewal. +A node with expired or not yet valid certificates does not start, and a reload of such certificates keeps the current ones. +Client applications and `control.sh` that use `SslContextFactory` apply the same check to their own key stores. Replacement certificates may expire earlier than the current ones or have the same expiry date. You can also update the trust store without replacing the node's certificate. -These checks apply even when automatic renewal is disabled. If a factory fails to reload, it keeps its previous context, and that node stops reloading its remaining factories. Successful reloads remain in effect; other nodes continue independently. diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 4a037a3ccee69..fb25dd3d2dde1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -47,6 +47,7 @@ /** * SSL context factory that provides SSL context configuration with specified key and trust stores. + * Every certificate in the private-key chains of the key store must be valid when the factory loads it. *

* In some cases it is useful to disable certificate validation of client side (e.g. when connecting * to a server with self-signed certificate). This can be achieved by setting a disabled trust manager @@ -162,11 +163,11 @@ public void renew() { @Override SSLContext createSslContext() throws SSLException { try { SSLContext ctx = super.createSslContext(); + Map chains = keyChains(loadedKeyStore); - if (sslCtx.get() != null) - checkValidity(keyChains(loadedKeyStore)); + checkValidity(chains); - renewalTime = renewalEnabled ? renewalTime(keyChains(loadedKeyStore)) : 0; + renewalTime = renewalEnabled ? renewalTime(chains) : 0; return ctx; } @@ -432,7 +433,7 @@ private static void checkValidity(Map chains) throws } if (earliestExpiry < System.currentTimeMillis()) - throw new SSLException("Replacement certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); + throw new SSLException("Certificates expired while loading the SSL context [expiry=" + earliestExpiry + ']'); } /** @@ -461,7 +462,7 @@ private static long renewalTime(Map chains) throws SS } } - return earliestExpiry - (long)(Math.max(0, earliestExpiry - (double)selectedStart) * RENEWAL_WINDOW_FRACTION); + return earliestExpiry - (long)((earliestExpiry - selectedStart) * RENEWAL_WINDOW_FRACTION); } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslTrustedUntrustedTest.java b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslTrustedUntrustedTest.java index e44db54804f54..9a78ae696e6d5 100644 --- a/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslTrustedUntrustedTest.java +++ b/modules/core/src/test/java/org/apache/ignite/spi/discovery/tcp/TcpDiscoverySslTrustedUntrustedTest.java @@ -121,20 +121,13 @@ public void testMismatchingCaFirst() throws Exception { checkDiscoveryFailure("node02", "trusttwo", "node01", "trustboth"); } - /** - * @throws Exception If failed. - */ + /** */ @Test - public void testExpired() throws Exception { - checkDiscoveryFailure("node02old", "trusttwo", "node03", "trusttwo"); - } + public void testExpired() { + keyStore = "node02old"; + trustStore = "trusttwo"; - /** - * @throws Exception If failed. - */ - @Test - public void testExpiredMismatchingCa() throws Exception { - checkDiscoveryFailure("node01", "trustboth", "node02old", "trusttwo"); + GridTestUtils.assertThrows(log, () -> startGrid(0), IgniteCheckedException.class, "Certificate is not currently valid"); } /** diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index a8bd07c7979fd..1ddf21f74ec65 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -121,18 +121,6 @@ public void testIssuerExpiryStartsRenewalWindow() throws Exception { assertEquals(now, factory.getRenewalTime()); } - /** */ - @Test - public void testIssuerExpiredBeforeLeafStartIsAlreadyDue() throws Exception { - TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("issuer", now - DAYS.toMillis(365), now - 1_000); - InMemorySslContextFactory factory = new InMemorySslContextFactory( - expiredIssuer.keyStore("node", now + DAYS.toMillis(100), now + DAYS.toMillis(200))); - - factory.create(); - - assertEquals(now - 1_000, factory.getRenewalTime()); - } - /** */ @Test public void testEarliestChainDeterminesRenewalWindow() throws Exception { @@ -238,16 +226,15 @@ public void testInterruptWhileWaitingForFactorySkipsRenewal() throws Exception { /** */ @Test - public void testRenewReplacesExpiredContext() throws Exception { + public void testFirstLoadRejectsExpiredCertificates() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1))); - SSLContext ctx = factory.create(); - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - factory.renew(); + factory.setRenewalEnabled(false); - assertNotSame(ctx, factory.create()); - assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); + IgniteException err = assertThrows(IgniteException.class, factory::create); + + assertTrue(err.getCause().getMessage().contains("not currently valid")); } /** */ @@ -426,12 +413,8 @@ public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Except /** */ @Test public void testRenewRejectsCertificatesExpiredDuringDateValidation() throws Exception { - KeyStore curKeys = authority.keyStore("expired", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); - KeyStore validKeys = authority.keyStore("valid", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - - curKeys.setKeyEntry("valid", validKeys.getKey("valid", PASSWORD), PASSWORD, validKeys.getCertificateChain("valid")); - - InMemorySslContextFactory factory = new InMemorySslContextFactory(curKeys); + InMemorySslContextFactory factory = new InMemorySslContextFactory( + authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); long renewalTime = factory.getRenewalTime(); long expiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; @@ -456,7 +439,6 @@ public void testRenewRejectsCertificatesExpiredDuringDateValidation() throws Exc IgniteException err = assertThrows(IgniteException.class, factory::renew); assertTrue(err.getCause().getMessage().contains("expired while loading")); - ((X509Certificate)curKeys.getCertificate("valid")).checkValidity(); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); } From 705dbe4a9f7c4f4a48f1f6117182c30645361b74 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 22:45:54 +0300 Subject: [PATCH 233/242] IGNITE-29211 Renew without a separate factory lock renew() calls init() when renewal is enabled and due. init() already loads under the factory monitor, so loads stay one at a time. Without the extra lock, an attempt that waited for another load of the same factory may read the source again, and node shutdown may wait for that load. Both happen only when loads race, and the registry still judges the result by the loaded renewal time. Co-Authored-By: Claude Opus 5.5 --- .../apache/ignite/ssl/SslContextFactory.java | 16 +-------- .../ssl/SslContextFactoryRenewalTest.java | 35 ------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index fb25dd3d2dde1..9f9d447fba0c2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -41,7 +41,6 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import org.apache.ignite.IgniteException; -import org.apache.ignite.IgniteInterruptedException; import org.apache.ignite.internal.systemview.SensitiveProperty; import org.apache.ignite.internal.util.typedef.internal.A; @@ -137,26 +136,13 @@ public long getRenewalTime() { /** * Replaces the current SSL context when the renewal window has started. * Does nothing when renewal is disabled, no renewal date has been loaded, or the window has not started. - *

- * Every certificate in the replacement's private-key chains must be valid now. * A failed replacement leaves the current context and renewal time unchanged. * - * @throws IgniteInterruptedException If the thread is interrupted before a due replacement starts loading. * @throws IgniteException If the replacement cannot be loaded or accepted. */ public void renew() { - if (!renewalEnabled) - return; - - synchronized (this) { - if (renewalTime == 0 || renewalTime > System.currentTimeMillis()) - return; - - if (Thread.currentThread().isInterrupted()) - throw new IgniteInterruptedException("Certificate renewal was interrupted before loading."); - + if (renewalEnabled && renewalTime != 0 && renewalTime <= System.currentTimeMillis()) init(); - } } /** {@inheritDoc} */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 1ddf21f74ec65..6513eb36ca1f8 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -26,12 +26,9 @@ import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import org.apache.ignite.IgniteException; -import org.apache.ignite.IgniteInterruptedException; -import org.apache.ignite.testframework.GridTestThread; import org.junit.Before; import org.junit.Test; -import static java.lang.Thread.State.BLOCKED; import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.HOURS; import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; @@ -192,38 +189,6 @@ public void testRenewBeforeWindowDoesNotLoad() throws Exception { assertEquals(1, factory.keyStoreLoads); } - /** */ - @Test - public void testInterruptWhileWaitingForFactorySkipsRenewal() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - - factory.keyStore = authority.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - - GridTestThread attempt = new GridTestThread(() -> assertThrows(IgniteInterruptedException.class, factory::renew)); - - try { - synchronized (factory) { - attempt.start(); - - long deadline = System.currentTimeMillis() + 5_000; - - assertTrue(waitForCondition(() -> attempt.getState() == BLOCKED, () -> System.currentTimeMillis() < deadline)); - - attempt.interrupt(); - } - } - finally { - attempt.join(5_000); - } - - assertFalse(attempt.isAlive()); - attempt.checkError(); - assertEquals(1, factory.keyStoreLoads); - assertSame(ctx, factory.create()); - } - /** */ @Test public void testFirstLoadRejectsExpiredCertificates() throws Exception { From c68b577b8d2c783d208610f5d547e3f6bbbfe768 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 22:55:18 +0300 Subject: [PATCH 234/242] IGNITE-29211 Stop renewal by the registry executor Renewal attempts check executor.isShutdown() for node stop. IgniteKernal stops the registry right after it sets the node stop flag, so attempts still do not start or count failures after shutdown begins, and Renewal no longer needs the kernal context. schedule() relies on the rejected task after shutdown instead of checking first, and start() checks for stop once after it prepares the renewals. Tests that repeated renew() checks through a manual reload are removed: renew() is init() once renewal is due. The metric reset test is removed because the failure metric is calculated from the counters. Co-Authored-By: Claude Opus 5.5 --- .../processors/ssl/SslFactoryRegistry.java | 29 ++----- .../ssl/SslFactoryRegistryTest.java | 29 +------ .../ssl/SslContextFactoryRenewalTest.java | 75 +------------------ 3 files changed, 10 insertions(+), 123 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 7200ba3ee59a3..3fd3bcf17ca3b 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -83,12 +83,10 @@ public synchronized void start(GridKernalContext ctx) { Map> factories = factories(); Map, Renewal> renewals = new IdentityHashMap<>(); + IgniteLogger log = ctx.log(getClass()); for (Factory factory : factories.values()) { if (factory instanceof SslContextFactory sslFactory && sslFactory.isRenewalEnabled() && !renewals.containsKey(factory)) { - if (ctx.isStopping()) - return; - if (sslFactory.getRenewalTime() == 0) sslFactory.init(); @@ -97,7 +95,7 @@ public synchronized void start(GridKernalContext ctx) { .map(Map.Entry::getKey) .collect(joining(", ")); - renewals.put(factory, new Renewal(sslFactory, transports, ctx)); + renewals.put(factory, new Renewal(sslFactory, transports, log)); } } @@ -161,9 +159,6 @@ private class Renewal implements Runnable { /** */ private final String transports; - /** */ - private final GridKernalContext ctx; - /** */ private final IgniteLogger log; @@ -174,17 +169,15 @@ private class Renewal implements Runnable { private final AtomicInteger failures = new AtomicInteger(); /** */ - private Renewal(SslContextFactory factory, String transports, GridKernalContext ctx) { + private Renewal(SslContextFactory factory, String transports, IgniteLogger log) { this.factory = factory; this.transports = transports; - this.ctx = ctx; - - log = ctx.log(SslFactoryRegistry.class); + this.log = log; } /** {@inheritDoc} */ @Override public void run() { - if (stopping()) + if (executor.isShutdown()) return; automaticRenewalAttempts.incrementAndGet(); @@ -208,7 +201,7 @@ private Renewal(SslContextFactory factory, String transports, GridKernalContext /** */ private void retry(Exception cause) { - if (stopping()) + if (executor.isShutdown()) return; long delay = Math.min(MINUTES.toMillis(1) << Math.min(failures.incrementAndGet() - 1, 6), MINUTES.toMillis(60)); @@ -219,11 +212,6 @@ private void retry(Exception cause) { schedule(nextAttempt); } - /** */ - private boolean stopping() { - return ctx.isStopping() || executor.isShutdown(); - } - /** Ends the failure streak and schedules the next attempt at the renewal time. */ private void scheduleRenewal() { failures.set(0); @@ -232,9 +220,6 @@ private void scheduleRenewal() { /** */ private void schedule(long time) { - if (stopping()) - return; - if (pending != null) pending.cancel(false); @@ -245,7 +230,7 @@ private void schedule(long time) { pending = executor.schedule(this, Math.max(0, time - System.currentTimeMillis()), MILLISECONDS); } catch (RejectedExecutionException e) { - if (!stopping()) + if (!executor.isShutdown()) throw e; return; diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 1bafd73416c9e..8ae2194a2a31f 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -339,10 +339,6 @@ public void testManualAttemptMetricCountsAppliedAndFailedLoads() { assertThrows(IgniteException.class, () -> registry.reload(factory)); assertEquals(2, metric("ManualReloadAttempts")); assertEquals(0, metric("AutomaticRenewalAttempts")); - - metrics.reset(); - - assertEquals(2, metric("ManualReloadAttempts")); } /** */ @@ -406,25 +402,6 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() { assertEquals(1, failures()); } - /** */ - @Test - public void testMetricResetDoesNotCorruptFailureCount() { - doThrow(new IgniteException("Source unavailable")).doNothing().when(factory).renew(); - - registry.register("communication", factory); - registry.start(ctx); - scheduled.run(); - metrics.reset(); - - assertEquals(1, failures()); - - when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - - scheduled.run(); - - assertEquals(0, failures()); - } - /** */ @Test public void testRenewalDoesNotInheritCallerContext() { @@ -473,12 +450,10 @@ public void testRenewalDoesNotInheritCallerContext() { @Test public void testStoppingNodeDoesNotStartRenewal() { when(ctx.isStopping()).thenReturn(true); - when(factory.getRenewalTime()).thenReturn(0L); registry.register("communication", factory); registry.start(ctx); - verify(factory, never()).init(); executorFactory.verifyNoInteractions(); } @@ -525,8 +500,6 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { assertTrue(attemptStarted.await(5, SECONDS)); - when(ctx.isStopping()).thenReturn(true); - stop = runAsync(() -> { registry.stop(); @@ -549,7 +522,7 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { @Test public void testStoppingDuringLoadDoesNotCountFailure() { doAnswer(call -> { - when(ctx.isStopping()).thenReturn(true); + when(executor.isShutdown()).thenReturn(true); throw new IgniteException("Load interrupted by node stop"); }).when(factory).renew(); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 6513eb36ca1f8..4a789bd42a495 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -202,22 +202,6 @@ public void testFirstLoadRejectsExpiredCertificates() throws Exception { assertTrue(err.getCause().getMessage().contains("not currently valid")); } - /** */ - @Test - public void testManualReloadAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { - for (long remainingHours : new long[] {10, 5}) { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(remainingHours)); - - factory.init(); - - assertNotSame(ctx, factory.create()); - } - } - /** */ @Test public void testRenewAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { @@ -260,6 +244,7 @@ public void testRenewRejectsExpiredIssuer() throws Exception { InMemorySslContextFactory factory = new InMemorySslContextFactory( authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", now - HOURS.toMillis(100), now - 1_000); factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); @@ -268,6 +253,7 @@ public void testRenewRejectsExpiredIssuer() throws Exception { assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); assertSame(ctx, factory.create()); + assertEquals(renewalTime, factory.getRenewalTime()); } /** */ @@ -292,63 +278,6 @@ public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { } } - /** */ - @Test - public void testManualReloadAcceptsEarlierExpiryAfterRenewalFailure() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - - factory.keyStore = authority.keyStore("expired", now - HOURS.toMillis(101), now - HOURS.toMillis(1)); - - IgniteException err = assertThrows(IgniteException.class, factory::renew); - - assertTrue(err.getCause().getMessage().contains("not currently valid")); - - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(95), now + HOURS.toMillis(5)); - factory.init(); - - assertNotSame(ctx, factory.create()); - assertEquals(now - HOURS.toMillis(10), factory.getRenewalTime()); - } - - /** */ - @Test - public void testManualReloadRejectsExpiredIssuer() throws Exception { - KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10)); - InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); - TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", now - HOURS.toMillis(100), now - 1_000); - - factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - - IgniteException err = assertThrows(IgniteException.class, factory::init); - - assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); - assertSame(ctx, factory.create()); - assertEquals(renewalTime, factory.getRenewalTime()); - } - - /** */ - @Test - public void testManualReloadRejectsFutureCertificateWithRenewalDisabled() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - - factory.setRenewalEnabled(false); - - SSLContext ctx = factory.create(); - - factory.keyStore = authority.keyStore("future", now + HOURS.toMillis(1), now + HOURS.toMillis(100)); - - IgniteException err = assertThrows(IgniteException.class, factory::init); - - assertTrue(err.getCause().getMessage().contains("not currently valid")); - assertSame(ctx, factory.create()); - assertEquals(0, factory.getRenewalTime()); - } - /** */ @Test public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Exception { From f07d9e9c75b152388a5623590c005444e033ece8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 12 Nov 2026 23:42:50 +0300 Subject: [PATCH 235/242] IGNITE-29211 Keep reload and renewal scheduling in one queue --- .../processors/ssl/SslFactoryRegistry.java | 50 ++- .../ssl/SslFactoryRegistryTest.java | 344 ++++++++++++++++-- 2 files changed, 362 insertions(+), 32 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java index 3fd3bcf17ca3b..8a555a9058905 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistry.java @@ -22,6 +22,7 @@ import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -30,12 +31,15 @@ import java.util.concurrent.atomic.AtomicLong; import javax.cache.configuration.Factory; import javax.net.ssl.SSLContext; +import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.ssl.AbstractSslContextFactory; import org.apache.ignite.ssl.SslContextFactory; @@ -118,7 +122,10 @@ public synchronized void stop() { if (executor == null) return; - executor.shutdownNow(); + for (Runnable task : executor.shutdownNow()) { + if (task instanceof Future fut) + fut.cancel(false); + } boolean interrupted = false; @@ -139,16 +146,39 @@ public synchronized void stop() { * Reloads the factory and resets its automatic renewal schedule and failure count. * * @param factory Factory to reload. + * @throws IgniteCheckedException If renewal has not started, or an asynchronous reload fails or is interrupted. */ - public void reload(AbstractSslContextFactory factory) { - manualReloadAttempts.incrementAndGet(); + public void reload(AbstractSslContextFactory factory) throws IgniteCheckedException { + Future reload; - factory.init(); + synchronized (this) { + Renewal renewal = renewals.get(factory); + + if (renewal == null && factory instanceof SslContextFactory sslFactory && sslFactory.isRenewalEnabled()) + throw new IgniteCheckedException("Automatic TLS renewal has not started."); + + reload = renewal == null ? null : executor.submit(renewal::reload); + } - Renewal renewal = renewals.get(factory); + if (reload == null) + reloadFactory(factory); + else { + try { + U.get(reload); + } + catch (IgniteInterruptedCheckedException e) { + reload.cancel(true); - if (renewal != null) - executor.execute(renewal::scheduleRenewal); + throw e; + } + } + } + + /** */ + private void reloadFactory(AbstractSslContextFactory factory) { + manualReloadAttempts.incrementAndGet(); + + factory.init(); } /** */ @@ -212,6 +242,12 @@ private void retry(Exception cause) { schedule(nextAttempt); } + /** */ + private void reload() { + reloadFactory(factory); + scheduleRenewal(); + } + /** Ends the failure streak and schedules the next attempt at the renewal time. */ private void scheduleRenewal() { failures.set(0); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index 8ae2194a2a31f..f10ee4030f89e 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,18 +17,27 @@ package org.apache.ignite.internal.processors.ssl; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteInternalFuture; +import org.apache.ignite.internal.IgniteInterruptedCheckedException; import org.apache.ignite.internal.processors.metric.GridMetricManager; import org.apache.ignite.internal.processors.metric.MetricRegistryImpl; import org.apache.ignite.internal.thread.context.OperationContext; @@ -44,6 +53,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -51,7 +61,9 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; +import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -62,6 +74,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -116,6 +129,13 @@ public void setUp() { return null; }).when(executor).execute(any(Runnable.class)); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask task = new FutureTask<>(call.getArgument(0), null); + + task.run(); + + return task; + }); when(executor.schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS))).thenAnswer(call -> { scheduled = call.getArgument(0); @@ -204,31 +224,68 @@ public void testEnablingRenewalRejectsInvalidMaterialAtStartup() { /** */ @Test - public void testManualReloadDuringStartupLeavesSchedulingToStartup() { - AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis() + HOURS.toMillis(2)); - AtomicBoolean reloaded = new AtomicBoolean(); + public void testManualReloadBeforeRenewalStartDoesNotLoadFactory() { + registry.register("communication", factory); - when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); - doAnswer(call -> { - renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(3)); + assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)); + verify(factory, never()).init(); - return null; - }).when(factory).init(); - doAnswer(call -> { - if (reloaded.compareAndSet(false, true)) - registry.reload(factory); + registry.start(ctx); - call.getArgument(0).run(); + assertEquals(0, metric("ManualReloadAttempts")); + } - return null; - }).when(executor).execute(any(Runnable.class)); + /** */ + @Test + public void testManualReloadFollowsInitialScheduling() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + AtomicReference caller = new AtomicReference<>(); + AtomicReference> reload = new AtomicReference<>(); + + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); registry.register("communication", factory); - registry.start(ctx); - verify(factory).init(); - verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - assertTrue(delay > HOURS.toMillis(2)); + AbstractSslContextFactory registeredFactory = (AbstractSslContextFactory)registry.factories().get("communication"); + + doAnswer(call -> { + IgniteInternalFuture fut = runAsync(() -> { + caller.set(Thread.currentThread()); + + registry.reload(registeredFactory); + }); + + reload.set(fut); + + long deadline = System.nanoTime() + SECONDS.toNanos(5); + + assertTrue(waitForCondition(() -> { + if (fut.isDone()) + return true; + + Thread thread = caller.get(); + ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); + + return info != null && info.getThreadState() == Thread.State.BLOCKED && + info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); + }, () -> System.nanoTime() < deadline)); + + return call.callRealMethod(); + }).when(renewalExecutor).execute(any(Runnable.class)); + + try { + registry.start(ctx); + } + finally { + if (reload.get() != null) + reload.get().get(5_000); + } + + InOrder order = inOrder(renewalExecutor, factory); + + order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + order.verify(factory).init(); + order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } /** */ @@ -291,7 +348,7 @@ public void testRenewalLeavingCertificatesDueIsFailure() { /** */ @Test - public void testManualReloadInCurrentWindowAttemptsImmediately() { + public void testManualReloadInCurrentWindowAttemptsImmediately() throws Exception { registry.register("communication", factory); registry.start(ctx); @@ -306,7 +363,7 @@ public void testManualReloadInCurrentWindowAttemptsImmediately() { /** */ @Test - public void testManualReloadReplacesRetryAndResetsFailures() { + public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); @@ -328,7 +385,7 @@ public void testManualReloadReplacesRetryAndResetsFailures() { /** */ @Test - public void testManualAttemptMetricCountsAppliedAndFailedLoads() { + public void testManualAttemptMetricCountsAppliedAndFailedLoads() throws Exception { when(factory.isRenewalEnabled()).thenReturn(false); doNothing().doThrow(new IgniteException("Source unavailable")).when(factory).init(); @@ -343,7 +400,7 @@ public void testManualAttemptMetricCountsAppliedAndFailedLoads() { /** */ @Test - public void testManualReloadInitializesGenericFactory() { + public void testManualReloadInitializesGenericFactory() throws Exception { AbstractSslContextFactory genericFactory = mock(AbstractSslContextFactory.class); registry.register("communication", genericFactory); @@ -369,7 +426,7 @@ public void testFailedManualReloadKeepsRetryAndFailures() { doThrow(err).when(factory).init(); - assertSame(err, assertThrows(IgniteException.class, () -> registry.reload(factory))); + assertSame(err, assertThrows(IgniteCheckedException.class, () -> registry.reload(factory)).getCause()); assertEquals(1, failures()); assertEquals(1, metric("ManualReloadAttempts")); verify(retry, never()).cancel(false); @@ -378,7 +435,60 @@ public void testFailedManualReloadKeepsRetryAndFailures() { /** */ @Test - public void testMetricKeepsOtherFactoryFailuresAfterReload() { + public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { + doThrow(new IgniteException("Source unavailable")).when(factory).renew(); + when(executor.submit(any(Runnable.class))).thenAnswer(call -> { + FutureTask reload = new FutureTask<>(call.getArgument(0), null); + + reload.run(); + scheduled.run(); + + return reload; + }); + + registry.register("communication", factory); + registry.start(ctx); + + doAnswer(call -> { + scheduled.run(); + call.getArgument(0).run(); + + return null; + }).when(executor).execute(any(Runnable.class)); + + long before = System.currentTimeMillis(); + + registry.reload(factory); + + verify(factory).renew(); + assertEquals(1, metric("AutomaticRenewalAttempts")); + assertEquals(1, failures()); + assertTrue(delay >= MINUTES.toMillis(1) - (System.currentTimeMillis() - before)); + } + + /** */ + @Test + public void testStoppingAfterManualLoadDoesNotReportFailure() throws Exception { + registry.register("communication", factory); + registry.start(ctx); + + doAnswer(call -> { + when(executor.isShutdown()).thenReturn(true); + + return null; + }).when(factory).init(); + doThrow(new RejectedExecutionException()).when(executor).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException()).when(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); + + registry.reload(factory); + + verify(factory).init(); + assertEquals(1, metric("ManualReloadAttempts")); + } + + /** */ + @Test + public void testMetricKeepsOtherFactoryFailuresAfterReload() throws Exception { SslContextFactory otherFactory = mock(SslContextFactory.class); when(otherFactory.isRenewalEnabled()).thenReturn(true); @@ -404,7 +514,7 @@ public void testMetricKeepsOtherFactoryFailuresAfterReload() { /** */ @Test - public void testRenewalDoesNotInheritCallerContext() { + public void testRenewalDoesNotInheritCallerContext() throws Exception { OperationContextAttribute caller = OperationContextAttribute.newInstance(); AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); BlockingQueue> attemptCallers = new LinkedBlockingQueue<>(); @@ -518,6 +628,180 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { assertTrue(interrupted.get()); } + /** */ + @Test + public void testStopInterruptsAndWaitsForManualReload() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch loadStarted = new CountDownLatch(1); + CountDownLatch loadInterrupted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + CountDownLatch terminationWaitEntered = new CountDownLatch(1); + + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); + doAnswer(call -> { + terminationWaitEntered.countDown(); + + return call.callRealMethod(); + }).when(renewalExecutor).awaitTermination(anyLong(), eq(MILLISECONDS)); + doAnswer(call -> { + loadStarted.countDown(); + + try { + assertTrue(allowCompletion.await(30, SECONDS)); + } + catch (InterruptedException e) { + loadInterrupted.countDown(); + + assertTrue(allowCompletion.await(30, SECONDS)); + } + + return null; + }).when(factory).init(); + + registry.register("communication", factory); + registry.start(ctx); + + IgniteInternalFuture stop = null; + IgniteInternalFuture reload = runAsync(() -> registry.reload(factory)); + + try { + assertTrue(loadStarted.await(5, SECONDS)); + + stop = runAsync(registry::stop); + + assertTrue(loadInterrupted.await(5, SECONDS)); + assertTrue(terminationWaitEntered.await(5, SECONDS)); + } + finally { + allowCompletion.countDown(); + + if (stop != null) + stop.get(5_000); + + reload.get(5_000); + } + } + + /** */ + @Test + public void testStopCancelsQueuedReload() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + BlockingQueue> submissions = new LinkedBlockingQueue<>(); + + doAnswer(call -> { + attemptStarted.countDown(); + assertTrue(allowCompletion.await(10, SECONDS)); + + return null; + }).when(factory).renew(); + doAnswer(call -> { + Future fut = (Future)call.callRealMethod(); + + submissions.add(fut); + + return fut; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + + IgniteInternalFuture reload = null; + Future queuedReload = null; + + try { + registry.start(ctx); + + assertTrue(attemptStarted.await(5, SECONDS)); + + reload = runAsync(() -> assertThrows(IgniteCheckedException.class, () -> registry.reload(factory))); + queuedReload = submissions.poll(5, SECONDS); + + assertNotNull(queuedReload); + + registry.stop(); + + assertTrue(reload.get(5_000).getCause() instanceof CancellationException); + verify(factory, never()).init(); + assertEquals(0, metric("ManualReloadAttempts")); + } + finally { + allowCompletion.countDown(); + + if (queuedReload != null) + queuedReload.cancel(true); + + if (reload != null) + reload.get(5_000); + } + } + + /** */ + @Test + public void testInterruptedCallerCancelsQueuedReload() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); + CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + CountDownLatch reloadSubmitted = new CountDownLatch(1); + AtomicReference caller = new AtomicReference<>(); + AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); + + when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); + doAnswer(call -> { + attemptStarted.countDown(); + assertTrue(allowCompletion.await(10, SECONDS)); + renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); + + return null; + }).when(factory).renew(); + doAnswer(call -> { + Object fut = call.callRealMethod(); + + reloadSubmitted.countDown(); + + return fut; + }).when(renewalExecutor).submit(any(Runnable.class)); + + registry.register("communication", factory); + + IgniteInternalFuture reload = null; + + try { + registry.start(ctx); + + assertTrue(attemptStarted.await(5, SECONDS)); + + reload = runAsync(() -> { + caller.set(Thread.currentThread()); + + assertThrows(IgniteInterruptedCheckedException.class, () -> registry.reload(factory)); + + return Thread.currentThread().isInterrupted(); + }); + + assertTrue(reloadSubmitted.await(5, SECONDS)); + + caller.get().interrupt(); + + assertTrue(reload.get(5_000)); + + allowCompletion.countDown(); + renewalExecutor.submit(() -> { }).get(5, SECONDS); + + verify(factory, never()).init(); + assertEquals(0, metric("ManualReloadAttempts")); + } + finally { + allowCompletion.countDown(); + + if (caller.get() != null) + caller.get().interrupt(); + + if (reload != null) + reload.get(5_000); + } + } + /** */ @Test public void testStoppingDuringLoadDoesNotCountFailure() { @@ -544,4 +828,14 @@ private long failures() { private long metric(String name) { return metrics.findMetric(name).value(); } + + /** */ + private ScheduledExecutorService useRealExecutor() { + ScheduledExecutorService renewalExecutor = spy(new IgniteScheduledThreadPoolExecutor("ssl-renewal", null, 1)); + + executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) + .thenReturn(renewalExecutor); + + return renewalExecutor; + } } From 2d176c9655100f7db4ae4f437849f391c0264a5a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 00:01:11 +0300 Subject: [PATCH 236/242] IGNITE-29211 Make SSL key store loading explicit --- .../ignite/ssl/AbstractSslContextFactory.java | 5 ++++ .../apache/ignite/ssl/SslContextFactory.java | 23 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 6babf0267f3d9..259e9bf03e4e9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -120,6 +120,11 @@ SSLContext createSslContext() throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); + return createSslContext(keyMgrs, trustMgrs); + } + + /** */ + final SSLContext createSslContext(KeyManager[] keyMgrs, TrustManager[] trustMgrs) throws SSLException { try { SSLContext ctx = SSLContext.getInstance(proto); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 9f9d447fba0c2..62281b9fb16a7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -103,9 +103,6 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; - /** Key store read by the load in progress. */ - private transient KeyStore loadedKeyStore; - /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -147,9 +144,13 @@ public void renew() { /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { + checkParameters(); + try { - SSLContext ctx = super.createSslContext(); - Map chains = keyChains(loadedKeyStore); + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + KeyManager[] keyMgrs = createKeyManagers(keyStore); + SSLContext ctx = createSslContext(keyMgrs, createTrustManagers()); + Map chains = keyChains(keyStore); checkValidity(chains); @@ -160,9 +161,6 @@ public void renew() { catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } - finally { - loadedKeyStore = null; - } } /** @@ -339,13 +337,14 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { + return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); + } + + /** */ + private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - loadedKeyStore = keyStore; - keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); From c95b5c29b2b55217f3e25777176ae514a907fce0 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 00:15:24 +0300 Subject: [PATCH 237/242] Revert "IGNITE-29211 Make SSL key store loading explicit" This reverts commit 2d176c9655100f7db4ae4f437849f391c0264a5a. --- .../ignite/ssl/AbstractSslContextFactory.java | 5 ---- .../apache/ignite/ssl/SslContextFactory.java | 23 ++++++++++--------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 259e9bf03e4e9..6babf0267f3d9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -120,11 +120,6 @@ SSLContext createSslContext() throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); - return createSslContext(keyMgrs, trustMgrs); - } - - /** */ - final SSLContext createSslContext(KeyManager[] keyMgrs, TrustManager[] trustMgrs) throws SSLException { try { SSLContext ctx = SSLContext.getInstance(proto); diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 62281b9fb16a7..9f9d447fba0c2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -103,6 +103,9 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; + /** Key store read by the load in progress. */ + private transient KeyStore loadedKeyStore; + /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -144,13 +147,9 @@ public void renew() { /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { - checkParameters(); - try { - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - KeyManager[] keyMgrs = createKeyManagers(keyStore); - SSLContext ctx = createSslContext(keyMgrs, createTrustManagers()); - Map chains = keyChains(keyStore); + SSLContext ctx = super.createSslContext(); + Map chains = keyChains(loadedKeyStore); checkValidity(chains); @@ -161,6 +160,9 @@ public void renew() { catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } + finally { + loadedKeyStore = null; + } } /** @@ -337,14 +339,13 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { - return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); - } - - /** */ - private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + + loadedKeyStore = keyStore; + keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); From f2d0501166c31c1ceb46c3e72cdb6f796dbbde66 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 00:27:36 +0300 Subject: [PATCH 238/242] IGNITE-29211 Build the SSL context from a local key store --- .../apache/ignite/ssl/SslContextFactory.java | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 9f9d447fba0c2..04caed081d6b0 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; +import java.security.KeyManagementException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; @@ -37,6 +38,7 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; +import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; @@ -103,9 +105,6 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; - /** Key store read by the load in progress. */ - private transient KeyStore loadedKeyStore; - /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -147,9 +146,39 @@ public void renew() { /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { + checkParameters(); + + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + KeyManager[] keyMgrs = createKeyManagers(keyStore); + TrustManager[] trustMgrs = createTrustManagers(); + SSLContext ctx; + try { - SSLContext ctx = super.createSslContext(); - Map chains = keyChains(loadedKeyStore); + ctx = SSLContext.getInstance(proto); + + if (cipherSuites != null || protocols != null) { + SSLParameters sslParameters = new SSLParameters(); + + if (cipherSuites != null) + sslParameters.setCipherSuites(cipherSuites); + + if (protocols != null) + sslParameters.setProtocols(protocols); + + ctx = new SSLContextWrapper(ctx, sslParameters); + } + + ctx.init(keyMgrs, trustMgrs, null); + } + catch (NoSuchAlgorithmException e) { + throw new SSLException("Unsupported SSL protocol: " + proto, e); + } + catch (KeyManagementException e) { + throw new SSLException("Failed to initialized SSL context.", e); + } + + try { + Map chains = keyChains(keyStore); checkValidity(chains); @@ -160,9 +189,6 @@ public void renew() { catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } - finally { - loadedKeyStore = null; - } } /** @@ -339,13 +365,14 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { + return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); + } + + /** */ + private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - loadedKeyStore = keyStore; - keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); From 0d957f7d15c9503a03fbecd0fddf215a839f805f Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 00:29:45 +0300 Subject: [PATCH 239/242] Revert "IGNITE-29211 Build the SSL context from a local key store" This reverts commit f2d0501166c31c1ceb46c3e72cdb6f796dbbde66. --- .../apache/ignite/ssl/SslContextFactory.java | 51 +++++-------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 04caed081d6b0..9f9d447fba0c2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -22,7 +22,6 @@ import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; -import java.security.KeyManagementException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; @@ -38,7 +37,6 @@ import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; -import javax.net.ssl.SSLParameters; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; @@ -105,6 +103,9 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; + /** Key store read by the load in progress. */ + private transient KeyStore loadedKeyStore; + /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -146,39 +147,9 @@ public void renew() { /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { - checkParameters(); - - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - KeyManager[] keyMgrs = createKeyManagers(keyStore); - TrustManager[] trustMgrs = createTrustManagers(); - SSLContext ctx; - try { - ctx = SSLContext.getInstance(proto); - - if (cipherSuites != null || protocols != null) { - SSLParameters sslParameters = new SSLParameters(); - - if (cipherSuites != null) - sslParameters.setCipherSuites(cipherSuites); - - if (protocols != null) - sslParameters.setProtocols(protocols); - - ctx = new SSLContextWrapper(ctx, sslParameters); - } - - ctx.init(keyMgrs, trustMgrs, null); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported SSL protocol: " + proto, e); - } - catch (KeyManagementException e) { - throw new SSLException("Failed to initialized SSL context.", e); - } - - try { - Map chains = keyChains(keyStore); + SSLContext ctx = super.createSslContext(); + Map chains = keyChains(loadedKeyStore); checkValidity(chains); @@ -189,6 +160,9 @@ public void renew() { catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } + finally { + loadedKeyStore = null; + } } /** @@ -365,14 +339,13 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { - return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); - } - - /** */ - private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + + loadedKeyStore = keyStore; + keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); From e7915544847c668e3e398ed44ed002daaaa26777 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 13:13:14 +0300 Subject: [PATCH 240/242] IGNITE-29211 Check each renewal rule in one test Each rule is checked once, at the level that decides it. Renewal window cases are rows of one factory test, and rejected replacements share one check that the current context and renewal time stay. One registry test checks that stop interrupts the attempt, cancels the queued reload and waits for the executor. The startup order test is removed: attempts read the factory date when they are scheduled, so the order does not change the schedule. The node test checks the renewed context through a new connection; SslContextReloadTest already covers established connections. Co-Authored-By: Claude Opus 5.5 --- .../ssl/SslFactoryRegistryTest.java | 355 +++--------------- .../ignite/ssl/SslCertificateRenewalTest.java | 80 +--- .../ssl/SslContextFactoryRenewalTest.java | 232 +++++------- .../ignite/ssl/TestCertificateAuthority.java | 13 + 4 files changed, 159 insertions(+), 521 deletions(-) diff --git a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java index f10ee4030f89e..760451fc2c400 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/processors/ssl/SslFactoryRegistryTest.java @@ -17,8 +17,6 @@ package org.apache.ignite.internal.processors.ssl; -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadInfo; import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CancellationException; @@ -29,9 +27,7 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteLogger; @@ -53,7 +49,6 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; -import org.mockito.InOrder; import org.mockito.MockedStatic; import static java.util.concurrent.TimeUnit.HOURS; @@ -61,7 +56,6 @@ import static java.util.concurrent.TimeUnit.MINUTES; import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.testframework.GridTestUtils.runAsync; -import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; @@ -73,8 +67,8 @@ import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -187,17 +181,7 @@ public void testDisabledFactoryDoesNotStartRenewal() { /** */ @Test - public void testSharedFactoryIsScheduledOnce() { - registry.register("communication", factory); - registry.register("discovery", factory); - registry.start(ctx); - - verify(executor).schedule(any(Runnable.class), eq(0L), eq(MILLISECONDS)); - } - - /** */ - @Test - public void testEnablingRenewalOnRestartInitializesSharedFactoryOnce() { + public void testEnablingRenewalOnRestartInitializesAndSchedulesSharedFactoryOnce() { when(factory.getRenewalTime()).thenReturn(0L, System.currentTimeMillis() + HOURS.toMillis(2)); registry.register("communication", factory); @@ -205,6 +189,7 @@ public void testEnablingRenewalOnRestartInitializesSharedFactoryOnce() { registry.start(ctx); verify(factory).init(); + verify(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); assertTrue(delay > HOURS.toMillis(1)); assertEquals(0, metric("ManualReloadAttempts")); assertEquals(0, metric("AutomaticRenewalAttempts")); @@ -235,59 +220,6 @@ public void testManualReloadBeforeRenewalStartDoesNotLoadFactory() { assertEquals(0, metric("ManualReloadAttempts")); } - /** */ - @Test - public void testManualReloadFollowsInitialScheduling() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - AtomicReference caller = new AtomicReference<>(); - AtomicReference> reload = new AtomicReference<>(); - - when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - - registry.register("communication", factory); - - AbstractSslContextFactory registeredFactory = (AbstractSslContextFactory)registry.factories().get("communication"); - - doAnswer(call -> { - IgniteInternalFuture fut = runAsync(() -> { - caller.set(Thread.currentThread()); - - registry.reload(registeredFactory); - }); - - reload.set(fut); - - long deadline = System.nanoTime() + SECONDS.toNanos(5); - - assertTrue(waitForCondition(() -> { - if (fut.isDone()) - return true; - - Thread thread = caller.get(); - ThreadInfo info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); - - return info != null && info.getThreadState() == Thread.State.BLOCKED && - info.getLockInfo().getIdentityHashCode() == System.identityHashCode(registry); - }, () -> System.nanoTime() < deadline)); - - return call.callRealMethod(); - }).when(renewalExecutor).execute(any(Runnable.class)); - - try { - registry.start(ctx); - } - finally { - if (reload.get() != null) - reload.get().get(5_000); - } - - InOrder order = inOrder(renewalExecutor, factory); - - order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - order.verify(factory).init(); - order.verify(renewalExecutor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); - } - /** */ @Test public void testFailuresIncreaseRetryDelayUntilLimit() { @@ -348,22 +280,7 @@ public void testRenewalLeavingCertificatesDueIsFailure() { /** */ @Test - public void testManualReloadInCurrentWindowAttemptsImmediately() throws Exception { - registry.register("communication", factory); - registry.start(ctx); - - ScheduledFuture previous = pending; - - registry.reload(factory); - - verify(factory).init(); - verify(previous).cancel(false); - assertEquals(0, delay); - } - - /** */ - @Test - public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { + public void testManualReloadReplacesRetryWithLoadedRenewalTime() throws Exception { doThrow(new IgniteException("Source unavailable")).when(factory).renew(); registry.register("communication", factory); @@ -374,42 +291,38 @@ public void testManualReloadReplacesRetryAndResetsFailures() throws Exception { ScheduledFuture retry = pending; - when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - registry.reload(factory); verify(retry).cancel(false); assertEquals(0, failures()); - assertTrue(delay > HOURS.toMillis(1)); - } + assertTrue(delay <= 0); - /** */ - @Test - public void testManualAttemptMetricCountsAppliedAndFailedLoads() throws Exception { - when(factory.isRenewalEnabled()).thenReturn(false); - doNothing().doThrow(new IgniteException("Source unavailable")).when(factory).init(); + when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - registry.register("communication", factory); - registry.start(ctx); registry.reload(factory); - assertThrows(IgniteException.class, () -> registry.reload(factory)); - assertEquals(2, metric("ManualReloadAttempts")); - assertEquals(0, metric("AutomaticRenewalAttempts")); + verify(factory, times(2)).init(); + assertTrue(delay > HOURS.toMillis(1)); } /** */ @Test - public void testManualReloadInitializesGenericFactory() throws Exception { + public void testManualReloadWithoutRenewalCountsAppliedAndFailedLoads() throws Exception { AbstractSslContextFactory genericFactory = mock(AbstractSslContextFactory.class); - registry.register("communication", genericFactory); - registry.start(ctx); + when(factory.isRenewalEnabled()).thenReturn(false); + doNothing().doThrow(new IgniteException("Source unavailable")).when(factory).init(); + registry.register("communication", factory); + registry.register("client connector", genericFactory); + registry.start(ctx); + registry.reload(factory); registry.reload(genericFactory); + assertThrows(IgniteException.class, () -> registry.reload(factory)); verify(genericFactory).init(); - assertEquals(1, metric("ManualReloadAttempts")); + assertEquals(3, metric("ManualReloadAttempts")); + assertEquals(0, metric("AutomaticRenewalAttempts")); } /** */ @@ -433,39 +346,6 @@ public void testFailedManualReloadKeepsRetryAndFailures() { verify(executor, times(2)).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); } - /** */ - @Test - public void testAutomaticFailureAfterManualReloadRemainsCounted() throws Exception { - doThrow(new IgniteException("Source unavailable")).when(factory).renew(); - when(executor.submit(any(Runnable.class))).thenAnswer(call -> { - FutureTask reload = new FutureTask<>(call.getArgument(0), null); - - reload.run(); - scheduled.run(); - - return reload; - }); - - registry.register("communication", factory); - registry.start(ctx); - - doAnswer(call -> { - scheduled.run(); - call.getArgument(0).run(); - - return null; - }).when(executor).execute(any(Runnable.class)); - - long before = System.currentTimeMillis(); - - registry.reload(factory); - - verify(factory).renew(); - assertEquals(1, metric("AutomaticRenewalAttempts")); - assertEquals(1, failures()); - assertTrue(delay >= MINUTES.toMillis(1) - (System.currentTimeMillis() - before)); - } - /** */ @Test public void testStoppingAfterManualLoadDoesNotReportFailure() throws Exception { @@ -477,7 +357,6 @@ public void testStoppingAfterManualLoadDoesNotReportFailure() throws Exception { return null; }).when(factory).init(); - doThrow(new RejectedExecutionException()).when(executor).execute(any(Runnable.class)); doThrow(new RejectedExecutionException()).when(executor).schedule(any(Runnable.class), anyLong(), eq(MILLISECONDS)); registry.reload(factory); @@ -569,20 +448,14 @@ public void testStoppingNodeDoesNotStartRenewal() { /** */ @Test - public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { + public void testStopInterruptsAttemptCancelsQueuedReloadAndWaits() throws Exception { + ScheduledExecutorService renewalExecutor = useRealExecutor(); CountDownLatch attemptStarted = new CountDownLatch(1); + CountDownLatch attemptInterrupted = new CountDownLatch(1); CountDownLatch allowCompletion = new CountDownLatch(1); CountDownLatch terminationWaitEntered = new CountDownLatch(1); - AtomicBoolean interrupted = new AtomicBoolean(); - ScheduledExecutorService renewalExecutor = spy(new IgniteScheduledThreadPoolExecutor("ssl-renewal", null, 1)); - - executorFactory.when(() -> IgniteScheduledThreadPoolExecutor.newSingleThreadScheduledExecutor("ssl-renewal", null)) - .thenReturn(renewalExecutor); - doAnswer(call -> { - terminationWaitEntered.countDown(); + BlockingQueue> submissions = new LinkedBlockingQueue<>(); - return call.callRealMethod(); - }).when(renewalExecutor).awaitTermination(anyLong(), eq(MILLISECONDS)); doAnswer(call -> { attemptStarted.countDown(); @@ -590,110 +463,11 @@ public void testStopInterruptsAndWaitsForRunningAttempt() throws Exception { assertTrue(allowCompletion.await(30, SECONDS)); } catch (InterruptedException e) { - interrupted.set(true); + attemptInterrupted.countDown(); assertTrue(allowCompletion.await(30, SECONDS)); } - if (Thread.currentThread().isInterrupted()) - interrupted.set(true); - - return null; - }).when(factory).renew(); - - registry.register("communication", factory); - - IgniteInternalFuture stop = null; - - try { - registry.start(ctx); - - assertTrue(attemptStarted.await(5, SECONDS)); - - stop = runAsync(() -> { - registry.stop(); - - assertTrue(renewalExecutor.isTerminated()); - }); - - assertTrue(terminationWaitEntered.await(5, SECONDS)); - } - finally { - allowCompletion.countDown(); - - if (stop != null) - stop.get(5_000); - } - - assertTrue(interrupted.get()); - } - - /** */ - @Test - public void testStopInterruptsAndWaitsForManualReload() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch loadStarted = new CountDownLatch(1); - CountDownLatch loadInterrupted = new CountDownLatch(1); - CountDownLatch allowCompletion = new CountDownLatch(1); - CountDownLatch terminationWaitEntered = new CountDownLatch(1); - - when(factory.getRenewalTime()).thenReturn(System.currentTimeMillis() + HOURS.toMillis(2)); - doAnswer(call -> { - terminationWaitEntered.countDown(); - - return call.callRealMethod(); - }).when(renewalExecutor).awaitTermination(anyLong(), eq(MILLISECONDS)); - doAnswer(call -> { - loadStarted.countDown(); - - try { - assertTrue(allowCompletion.await(30, SECONDS)); - } - catch (InterruptedException e) { - loadInterrupted.countDown(); - - assertTrue(allowCompletion.await(30, SECONDS)); - } - - return null; - }).when(factory).init(); - - registry.register("communication", factory); - registry.start(ctx); - - IgniteInternalFuture stop = null; - IgniteInternalFuture reload = runAsync(() -> registry.reload(factory)); - - try { - assertTrue(loadStarted.await(5, SECONDS)); - - stop = runAsync(registry::stop); - - assertTrue(loadInterrupted.await(5, SECONDS)); - assertTrue(terminationWaitEntered.await(5, SECONDS)); - } - finally { - allowCompletion.countDown(); - - if (stop != null) - stop.get(5_000); - - reload.get(5_000); - } - } - - /** */ - @Test - public void testStopCancelsQueuedReload() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch attemptStarted = new CountDownLatch(1); - CountDownLatch allowCompletion = new CountDownLatch(1); - BlockingQueue> submissions = new LinkedBlockingQueue<>(); - - doAnswer(call -> { - attemptStarted.countDown(); - assertTrue(allowCompletion.await(10, SECONDS)); - return null; }).when(factory).renew(); doAnswer(call -> { @@ -703,10 +477,16 @@ public void testStopCancelsQueuedReload() throws Exception { return fut; }).when(renewalExecutor).submit(any(Runnable.class)); + doAnswer(call -> { + terminationWaitEntered.countDown(); + + return call.callRealMethod(); + }).when(renewalExecutor).awaitTermination(anyLong(), eq(MILLISECONDS)); registry.register("communication", factory); IgniteInternalFuture reload = null; + IgniteInternalFuture stop = null; Future queuedReload = null; try { @@ -719,11 +499,11 @@ public void testStopCancelsQueuedReload() throws Exception { assertNotNull(queuedReload); - registry.stop(); + stop = runAsync(registry::stop); + assertTrue(attemptInterrupted.await(5, SECONDS)); + assertTrue(terminationWaitEntered.await(5, SECONDS)); assertTrue(reload.get(5_000).getCause() instanceof CancellationException); - verify(factory, never()).init(); - assertEquals(0, metric("ManualReloadAttempts")); } finally { allowCompletion.countDown(); @@ -731,75 +511,32 @@ public void testStopCancelsQueuedReload() throws Exception { if (queuedReload != null) queuedReload.cancel(true); + if (stop != null) + stop.get(5_000); + if (reload != null) reload.get(5_000); } + + assertTrue(renewalExecutor.isTerminated()); + verify(factory, never()).init(); + assertEquals(0, metric("ManualReloadAttempts")); } /** */ @Test public void testInterruptedCallerCancelsQueuedReload() throws Exception { - ScheduledExecutorService renewalExecutor = useRealExecutor(); - CountDownLatch attemptStarted = new CountDownLatch(1); - CountDownLatch allowCompletion = new CountDownLatch(1); - CountDownLatch reloadSubmitted = new CountDownLatch(1); - AtomicReference caller = new AtomicReference<>(); - AtomicLong renewalTime = new AtomicLong(System.currentTimeMillis()); - - when(factory.getRenewalTime()).thenAnswer(call -> renewalTime.get()); - doAnswer(call -> { - attemptStarted.countDown(); - assertTrue(allowCompletion.await(10, SECONDS)); - renewalTime.set(System.currentTimeMillis() + HOURS.toMillis(2)); + Future queuedReload = mock(Future.class); - return null; - }).when(factory).renew(); - doAnswer(call -> { - Object fut = call.callRealMethod(); - - reloadSubmitted.countDown(); - - return fut; - }).when(renewalExecutor).submit(any(Runnable.class)); + when(queuedReload.get()).thenThrow(new InterruptedException()); + doReturn(queuedReload).when(executor).submit(any(Runnable.class)); registry.register("communication", factory); + registry.start(ctx); - IgniteInternalFuture reload = null; - - try { - registry.start(ctx); - - assertTrue(attemptStarted.await(5, SECONDS)); - - reload = runAsync(() -> { - caller.set(Thread.currentThread()); - - assertThrows(IgniteInterruptedCheckedException.class, () -> registry.reload(factory)); - - return Thread.currentThread().isInterrupted(); - }); - - assertTrue(reloadSubmitted.await(5, SECONDS)); - - caller.get().interrupt(); - - assertTrue(reload.get(5_000)); - - allowCompletion.countDown(); - renewalExecutor.submit(() -> { }).get(5, SECONDS); - - verify(factory, never()).init(); - assertEquals(0, metric("ManualReloadAttempts")); - } - finally { - allowCompletion.countDown(); - - if (caller.get() != null) - caller.get().interrupt(); - - if (reload != null) - reload.get(5_000); - } + assertThrows(IgniteInterruptedCheckedException.class, () -> registry.reload(factory)); + assertTrue(Thread.interrupted()); + verify(queuedReload).cancel(true); } /** */ diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java index 45e835ad0d90e..240a78b027006 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslCertificateRenewalTest.java @@ -17,12 +17,8 @@ package org.apache.ignite.ssl; -import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; import org.apache.ignite.Ignition; import org.apache.ignite.client.ClientRetryNonePolicy; @@ -37,7 +33,6 @@ import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.MINUTES; -import static java.util.concurrent.TimeUnit.SECONDS; import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; import static org.apache.ignite.ssl.TestCertificateAuthority.save; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; @@ -46,50 +41,26 @@ public class SslCertificateRenewalTest extends GridCommonAbstractTest { /** */ @Test - public void testRenewalUpdatesNewConnectionsAndPreservesEstablishedConnection() throws Exception { + public void testNodeRenewsCertificatesForNewConnections() throws Exception { long now = System.currentTimeMillis(); TestCertificateAuthority ca1 = new TestCertificateAuthority("ca1", now - DAYS.toMillis(1), now + DAYS.toMillis(10)); TestCertificateAuthority ca2 = new TestCertificateAuthority("ca2", now - DAYS.toMillis(1), now + DAYS.toMillis(10)); Path dir = Files.createTempDirectory("ssl-renewal"); Path keys = dir.resolve("keys.jks"); Path trust = dir.resolve("trust.jks"); - AtomicBoolean blockRenewal = new AtomicBoolean(); - CountDownLatch renewalStarted = new CountDownLatch(1); - CountDownLatch allowRenewal = new CountDownLatch(1); - - SslContextFactory serverFactory = new SslContextFactory() { - /** */ - private static final long serialVersionUID = 0L; - - /** {@inheritDoc} */ - @Override protected InputStream openFileInputStream(String path) throws IOException { - if (keys.toString().equals(path) && blockRenewal.get()) { - renewalStarted.countDown(); - - try { - if (!allowRenewal.await(30, SECONDS)) - throw new IOException("Timed out waiting for the replacement key store"); - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - - throw new IOException(e); - } - } - - return super.openFileInputStream(path); - } - }; try { save(ca1.keyStore("node", now - MINUTES.toMillis(110), now + MINUTES.toMillis(10)), keys); save(ca1.trustStore(), trust); - configure(serverFactory, keys, trust); + + SslContextFactory serverFactory = configure(new SslContextFactory(), keys, trust); + serverFactory.setRenewalEnabled(true); SSLContext initCtx = serverFactory.create(); - blockRenewal.set(true); + save(ca2.keyStore("node", now - MINUTES.toMillis(1), now + DAYS.toMillis(1)), keys); + save(ca2.trustStore(), trust); IgniteEx node = startGrid(0, cfg -> { cfg.setClientConnectorConfiguration(new ClientConnectorConfiguration() @@ -99,45 +70,26 @@ public void testRenewalUpdatesNewConnectionsAndPreservesEstablishedConnection() .setSslContextFactory(serverFactory)); }); - assertTrue("Automatic renewal did not start", renewalStarted.await(10, SECONDS)); + assertTrue("The renewed context was not installed", waitForCondition(() -> serverFactory.create() != initCtx, 10_000)); - ClientConfiguration clientCfg = clientConfiguration(node, configure(new SslContextFactory(), keys, trust)); + ClientConfiguration clientCfg = new ClientConfiguration() + .setAddresses("127.0.0.1:" + node.context().clientListener().port()) + .setSslMode(SslMode.REQUIRED) + .setSslContextFactory(configure(new SslContextFactory(), keys, trust)) + .setRetryPolicy(new ClientRetryNonePolicy()) + .setPartitionAwarenessEnabled(false) + .setClusterDiscoveryEnabled(false); - try (IgniteClient connected = Ignition.startClient(clientCfg)) { - connected.cacheNames(); - - save(ca2.keyStore("node", now - MINUTES.toMillis(1), now + DAYS.toMillis(1)), keys); - save(ca2.trustStore(), trust); - allowRenewal.countDown(); - - assertTrue("The renewed context was not installed", waitForCondition(() -> serverFactory.create() != initCtx, 10_000)); - - try (IgniteClient newConnection = Ignition.startClient(clientConfiguration(node, - configure(new SslContextFactory(), keys, trust)))) { - newConnection.cacheNames(); - } - - connected.cacheNames(); + try (IgniteClient client = Ignition.startClient(clientCfg)) { + client.cacheNames(); } } finally { - allowRenewal.countDown(); stopAllGrids(); U.delete(dir); } } - /** */ - private static ClientConfiguration clientConfiguration(IgniteEx node, SslContextFactory factory) { - return new ClientConfiguration() - .setAddresses("127.0.0.1:" + node.context().clientListener().port()) - .setSslMode(SslMode.REQUIRED) - .setSslContextFactory(factory) - .setRetryPolicy(new ClientRetryNonePolicy()) - .setPartitionAwarenessEnabled(false) - .setClusterDiscoveryEnabled(false); - } - /** */ private static SslContextFactory configure(SslContextFactory factory, Path keys, Path trust) { factory.setKeyStoreFilePath(keys.toString()); diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java index 4a789bd42a495..f5c803468797b 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextFactoryRenewalTest.java @@ -32,6 +32,7 @@ import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.HOURS; import static org.apache.ignite.ssl.TestCertificateAuthority.PASSWORD; +import static org.apache.ignite.ssl.TestCertificateAuthority.merge; import static org.apache.ignite.testframework.GridTestUtils.waitForCondition; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -83,8 +84,7 @@ public void testDisabledRenewalAcceptsStoreWithoutPrivateKeys() throws Exception /** */ @Test public void testLoadWithRenewalDisabledClearsRenewalTime() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15))); + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.keyStore("node", at(-85), at(15))); factory.create(); factory.setRenewalEnabled(false); @@ -96,93 +96,51 @@ public void testLoadWithRenewalDisabledClearsRenewalTime() throws Exception { /** */ @Test - public void testRenewalWindowUsesLoadedKeyStore() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15))); - - factory.create(); - - assertEquals(now, factory.getRenewalTime()); - assertEquals(1, factory.keyStoreLoads); - } - - /** */ - @Test - public void testIssuerExpiryStartsRenewalWindow() throws Exception { - TestCertificateAuthority issuer = new TestCertificateAuthority("issuer", now - DAYS.toMillis(365), now + HOURS.toMillis(15)); - InMemorySslContextFactory factory = new InMemorySslContextFactory( - issuer.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(100))); - - factory.create(); - - assertEquals(now, factory.getRenewalTime()); - } - - /** */ - @Test - public void testEarliestChainDeterminesRenewalWindow() throws Exception { - KeyStore keys = authority.keyStore("first", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); - KeyStore other = authority.keyStore("second", now - HOURS.toMillis(200), now + HOURS.toMillis(100)); - - keys.setKeyEntry("second", other.getKey("second", PASSWORD), PASSWORD, other.getCertificateChain("second")); - - InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); - - factory.create(); - - assertEquals(now, factory.getRenewalTime()); + public void testRenewalWindowStartsAtEarliestChainWindow() throws Exception { + TestCertificateAuthority shortIssuer = new TestCertificateAuthority("short-issuer", now - DAYS.toMillis(365), at(15)); + KeyStore node = authority.keyStore("node", at(-85), at(15)); + KeyStore withSecretKey = KeyStore.getInstance("JCEKS"); + + withSecretKey.load(null, PASSWORD); + withSecretKey.setKeyEntry("node", node.getKey("node", PASSWORD), PASSWORD, node.getCertificateChain("node")); + withSecretKey.setKeyEntry("secret", new SecretKeySpec(new byte[16], "AES"), PASSWORD, null); + + assertRenewalWindowStartsNow(node); + assertRenewalWindowStartsNow(shortIssuer.keyStore("node", at(-85), at(100))); + assertRenewalWindowStartsNow(merge(authority.keyStore("first", at(-85), at(15)), authority.keyStore("second", at(-200), at(100)))); + assertRenewalWindowStartsNow(merge(authority.keyStore("first", at(-200), at(100)), authority.keyStore("second", at(-85), at(15)))); + assertRenewalWindowStartsNow(merge(authority.keyStore("first", at(-5), at(15)), authority.keyStore("second", at(-85), at(15)))); + assertRenewalWindowStartsNow(merge(authority.keyStore("first", at(-85), at(15)), authority.keyStore("second", at(-5), at(15)))); + assertRenewalWindowStartsNow(withSecretKey); } /** */ @Test - public void testEqualExpiriesUseLongestLifetime() throws Exception { - for (String longerAlias : new String[] {"first", "second"}) { - String shorterAlias = longerAlias.equals("first") ? "second" : "first"; - KeyStore keys = authority.keyStore(shorterAlias, now - HOURS.toMillis(5), now + HOURS.toMillis(15)); - KeyStore longer = authority.keyStore(longerAlias, now - HOURS.toMillis(85), now + HOURS.toMillis(15)); - - keys.setKeyEntry(longerAlias, longer.getKey(longerAlias, PASSWORD), PASSWORD, longer.getCertificateChain(longerAlias)); - - InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); - - factory.create(); + public void testEnabledRenewalRequiresPrivateKey() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.trustStore()); + IgniteException err = assertThrows(IgniteException.class, factory::create); - assertEquals(now, factory.getRenewalTime()); - } + assertTrue(err.getCause().getMessage().contains("private key with an X.509 certificate chain")); + assertEquals(0, factory.getRenewalTime()); } /** */ @Test - public void testSecretKeysDoNotAffectRenewalWindow() throws Exception { - KeyStore keys = authority.keyStore("node", now - HOURS.toMillis(85), now + HOURS.toMillis(15)); - KeyStore mixed = KeyStore.getInstance("JCEKS"); - - mixed.load(null, PASSWORD); - mixed.setKeyEntry("node", keys.getKey("node", PASSWORD), PASSWORD, keys.getCertificateChain("node")); - mixed.setKeyEntry("secret", new SecretKeySpec(new byte[16], "AES"), PASSWORD, null); - - InMemorySslContextFactory factory = new InMemorySslContextFactory(mixed); - - factory.create(); + public void testFirstLoadRejectsExpiredCertificates() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.keyStore("node", at(-101), at(-1))); - assertEquals(now, factory.getRenewalTime()); - } + factory.setRenewalEnabled(false); - /** */ - @Test - public void testEnabledRenewalRequiresPrivateKey() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.trustStore()); IgniteException err = assertThrows(IgniteException.class, factory::create); - assertTrue(err.getCause().getMessage().contains("private key with an X.509 certificate chain")); - assertEquals(0, factory.getRenewalTime()); + assertTrue(err.getCause().getMessage().contains("not currently valid")); } /** */ @Test public void testRenewBeforeWindowDoesNotLoad() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99))); + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.keyStore("node", at(-1), at(99))); + factory.create(); factory.renew(); @@ -191,37 +149,18 @@ public void testRenewBeforeWindowDoesNotLoad() throws Exception { /** */ @Test - public void testFirstLoadRejectsExpiredCertificates() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(101), now - HOURS.toMillis(1))); - - factory.setRenewalEnabled(false); - - IgniteException err = assertThrows(IgniteException.class, factory::create); - - assertTrue(err.getCause().getMessage().contains("not currently valid")); - } - - /** */ - @Test - public void testRenewAcceptsDifferentCertificatesWithSameOrEarlierExpiry() throws Exception { - for (long remainingHours : new long[] {10, 5}) { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + public void testRenewAcceptsAnyValidReplacement() throws Exception { + for (long expiry : new long[] {at(10), at(5)}) { + InMemorySslContextFactory factory = dueFactory(); SSLContext ctx = factory.create(); - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(remainingHours)); + factory.keyStore = authority.keyStore("node", at(-1), expiry); factory.renew(); assertNotSame(ctx, factory.create()); } - } - /** */ - @Test - public void testManualReloadAppliesTrustStoreChanges() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.keyStore("node", at(-90), at(10))); factory.trustStore = authority.trustStore(); factory.setTrustManagers((TrustManager[])null); @@ -232,87 +171,53 @@ public void testManualReloadAppliesTrustStoreChanges() throws Exception { TestCertificateAuthority otherIssuer = new TestCertificateAuthority("other-issuer", now - DAYS.toMillis(1), now + DAYS.toMillis(1)); factory.trustStore.setCertificateEntry("other-issuer", otherIssuer.trustStore().getCertificate("CN=other-issuer")); - - factory.init(); + factory.renew(); assertNotSame(ctx, factory.create()); } /** */ @Test - public void testRenewRejectsExpiredIssuer() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); - TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", now - HOURS.toMillis(100), now - 1_000); + public void testRenewRejectsCertificatesThatAreNotValid() throws Exception { + TestCertificateAuthority expiredIssuer = new TestCertificateAuthority("expired-issuer", at(-100), now - 1_000); + InMemorySslContextFactory factory = dueFactory(); - factory.keyStore = expiredIssuer.keyStore("replacement", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + factory.keyStore = expiredIssuer.keyStore("replacement", at(-1), at(99)); + assertRenewKeepsCurrentState(factory, "subject=CN=expired-issuer"); - IgniteException err = assertThrows(IgniteException.class, factory::renew); + factory.keyStore = merge(authority.keyStore("first", at(1), at(100)), authority.keyStore("second", at(-1), at(99))); + assertRenewKeepsCurrentState(factory, "alias=first"); - assertTrue(err.getCause().getMessage().contains("subject=CN=expired-issuer")); - assertSame(ctx, factory.create()); - assertEquals(renewalTime, factory.getRenewalTime()); + factory.keyStore = merge(authority.keyStore("first", at(-1), at(99)), authority.keyStore("second", at(1), at(100))); + assertRenewKeepsCurrentState(factory, "alias=second"); } /** */ @Test - public void testRenewRejectsFutureCertificateInOtherChain() throws Exception { - for (String notYetValidAlias : new String[] {"first", "second"}) { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - String validAlias = notYetValidAlias.equals("first") ? "second" : "first"; - KeyStore replacement = authority.keyStore(validAlias, now - HOURS.toMillis(1), now + HOURS.toMillis(99)); - KeyStore notYetValidKeys = authority.keyStore(notYetValidAlias, now + HOURS.toMillis(1), now + HOURS.toMillis(100)); - - replacement.setKeyEntry(notYetValidAlias, notYetValidKeys.getKey(notYetValidAlias, PASSWORD), PASSWORD, - notYetValidKeys.getCertificateChain(notYetValidAlias)); - factory.keyStore = replacement; - - IgniteException err = assertThrows(IgniteException.class, factory::renew); - - assertTrue(err.getCause().getMessage().contains("alias=" + notYetValidAlias)); - assertSame(ctx, factory.create()); - } - } - - /** */ - @Test - public void testFailedTrustStoreLoadKeepsContextAndRenewalWindow() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); + public void testRenewRecoversAfterFailedTrustStoreLoad() throws Exception { + InMemorySslContextFactory factory = dueFactory(); SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); - factory.keyStore = authority.keyStore("node", now - HOURS.toMillis(1), now + HOURS.toMillis(99)); + factory.keyStore = authority.keyStore("node", at(-1), at(99)); factory.setTrustManagers((TrustManager[])null); factory.setTrustStoreFilePath("missing-trust-store"); factory.setTrustStorePassword(PASSWORD); - IgniteException err = assertThrows(IgniteException.class, factory::renew); - - assertTrue(err.getCause().getMessage().contains("missing-trust-store")); - assertSame(ctx, factory.create()); - assertEquals(renewalTime, factory.getRenewalTime()); + assertRenewKeepsCurrentState(factory, "missing-trust-store"); factory.setTrustManagers(SslContextFactory.getDisabledTrustManager()); factory.renew(); assertNotSame(ctx, factory.create()); - assertEquals(now + HOURS.toMillis(84), factory.getRenewalTime()); + assertEquals(at(84), factory.getRenewalTime()); } /** */ @Test public void testRenewRejectsCertificatesExpiredDuringDateValidation() throws Exception { - InMemorySslContextFactory factory = new InMemorySslContextFactory( - authority.keyStore("node", now - HOURS.toMillis(90), now + HOURS.toMillis(10))); - SSLContext ctx = factory.create(); - long renewalTime = factory.getRenewalTime(); + InMemorySslContextFactory factory = dueFactory(); long expiry = System.currentTimeMillis() / 1_000 * 1_000 + 5_000; - KeyStore replacement = authority.keyStore("replacement", now - HOURS.toMillis(1), expiry); + KeyStore replacement = authority.keyStore("replacement", at(-1), expiry); Certificate[] chain = replacement.getCertificateChain("replacement"); X509Certificate cert = spy((X509Certificate)chain[0]); @@ -330,20 +235,51 @@ public void testRenewRejectsCertificatesExpiredDuringDateValidation() throws Exc replacement.setKeyEntry("replacement", replacement.getKey("replacement", PASSWORD), PASSWORD, chain); factory.keyStore = replacement; + assertRenewKeepsCurrentState(factory, "expired while loading"); + } + + /** @return Factory that has loaded certificates whose renewal window has started. */ + private InMemorySslContextFactory dueFactory() throws Exception { + InMemorySslContextFactory factory = new InMemorySslContextFactory(authority.keyStore("node", at(-90), at(10))); + + factory.create(); + + return factory; + } + + /** */ + private void assertRenewalWindowStartsNow(KeyStore keys) { + InMemorySslContextFactory factory = new InMemorySslContextFactory(keys); + + factory.create(); + + assertEquals(now, factory.getRenewalTime()); + assertEquals(1, factory.keyStoreLoads); + } + + /** */ + private static void assertRenewKeepsCurrentState(SslContextFactory factory, String reason) { + SSLContext ctx = factory.create(); + long renewalTime = factory.getRenewalTime(); IgniteException err = assertThrows(IgniteException.class, factory::renew); - assertTrue(err.getCause().getMessage().contains("expired while loading")); + assertTrue(err.getCause().getMessage(), err.getCause().getMessage().contains(reason)); assertSame(ctx, factory.create()); assertEquals(renewalTime, factory.getRenewalTime()); } + /** */ + private long at(long hours) { + return now + HOURS.toMillis(hours); + } + /** */ private static class InMemorySslContextFactory extends SslContextFactory { /** */ private static final long serialVersionUID = 0L; /** */ - protected KeyStore keyStore; + private KeyStore keyStore; /** */ private KeyStore trustStore; diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java b/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java index 417757a7c8e8b..b7f67fb8d21d5 100644 --- a/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java +++ b/modules/core/src/test/java/org/apache/ignite/ssl/TestCertificateAuthority.java @@ -26,6 +26,7 @@ import java.security.KeyStore; import java.security.cert.X509Certificate; import java.security.spec.ECGenParameterSpec; +import java.util.Collections; import java.util.Date; import javax.security.auth.x500.X500Principal; import org.bouncycastle.asn1.x509.BasicConstraints; @@ -77,6 +78,18 @@ KeyStore trustStore() throws Exception { return store; } + /** */ + static KeyStore merge(KeyStore... stores) throws Exception { + KeyStore merged = emptyStore(); + + for (KeyStore store : stores) { + for (String alias : Collections.list(store.aliases())) + merged.setKeyEntry(alias, store.getKey(alias, PASSWORD), PASSWORD, store.getCertificateChain(alias)); + } + + return merged; + } + /** */ static void save(KeyStore store, Path path) throws Exception { try (OutputStream out = Files.newOutputStream(path)) { From 3edbc43b0f387ca5bcdfd64ac8bbc62db5f254be Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 13:41:08 +0300 Subject: [PATCH 241/242] IGNITE-29211 Pass the loaded key store explicitly SslContextFactory loads the key store once and passes it to the key managers and to the certificate checks, so no field carries it between methods. AbstractSslContextFactory builds the context from the given key managers, and init() checks the parameters before every load. Co-Authored-By: Claude Opus 5.5 --- .../ignite/ssl/AbstractSslContextFactory.java | 15 ++++++++--- .../apache/ignite/ssl/SslContextFactory.java | 25 ++++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 6babf0267f3d9..648ac39e7efa9 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -114,10 +114,17 @@ public void setProtocols(String... protocols) { * @throws SSLException If SSL context could not be created. */ SSLContext createSslContext() throws SSLException { - checkParameters(); - - KeyManager[] keyMgrs = createKeyManagers(); + return createSslContext(createKeyManagers()); + } + /** + * Creates SSL context with the given key managers and the trust managers of this factory. + * + * @param keyMgrs Key managers. + * @return Initialized SSL context. + * @throws SSLException If SSL context could not be created. + */ + final SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -184,6 +191,8 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ public final synchronized void init() { try { + checkParameters(); + sslCtx.set(createSslContext()); } catch (SSLException e) { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index 9f9d447fba0c2..d85ab502ed2e1 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -103,9 +103,6 @@ public class SslContextFactory extends AbstractSslContextFactory { /** */ private transient volatile long renewalTime; - /** Key store read by the load in progress. */ - private transient KeyStore loadedKeyStore; - /** * @return Whether automatic certificate renewal is enabled. Defaults to {@code false}. */ @@ -147,9 +144,11 @@ public void renew() { /** {@inheritDoc} */ @Override SSLContext createSslContext() throws SSLException { + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + SSLContext ctx = createSslContext(createKeyManagers(keyStore)); + try { - SSLContext ctx = super.createSslContext(); - Map chains = keyChains(loadedKeyStore); + Map chains = keyChains(keyStore); checkValidity(chains); @@ -160,9 +159,6 @@ public void renew() { catch (GeneralSecurityException e) { throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } - finally { - loadedKeyStore = null; - } } /** @@ -339,13 +335,18 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { + return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); + } + + /** + * @param keyStore Loaded key store. + * @return Key managers for the key store. + * @throws SSLException If key managers could not be created. + */ + private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - - loadedKeyStore = keyStore; - keyMgrFactory.init(keyStore, keyStorePwd); return keyMgrFactory.getKeyManagers(); From 55a8e04776b9e3da5a9c7fe8298c716805a55fca Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Fri, 13 Nov 2026 14:13:14 +0300 Subject: [PATCH 242/242] IGNITE-29211 Keep the key store chains in the key managers createKeyManagers() is again the only path that creates key managers. SslContextFactory wraps each X.509 key manager with the private-key chains of the key store it was created from. AbstractSslContextFactory passes the key managers of a built context to acceptKeyManagers(), where SslContextFactory checks the certificates and records the renewal time before the context replaces the current one. Co-Authored-By: Claude Opus 5.5 --- .../ignite/ssl/AbstractSslContextFactory.java | 30 ++--- .../apache/ignite/ssl/SslContextFactory.java | 104 +++++++++++++----- 2 files changed, 96 insertions(+), 38 deletions(-) diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java index 648ac39e7efa9..be1747d4949c3 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/AbstractSslContextFactory.java @@ -113,18 +113,11 @@ public void setProtocols(String... protocols) { * @return Initialized SSL context. * @throws SSLException If SSL context could not be created. */ - SSLContext createSslContext() throws SSLException { - return createSslContext(createKeyManagers()); - } + private SSLContext createSslContext() throws SSLException { + checkParameters(); + + KeyManager[] keyMgrs = createKeyManagers(); - /** - * Creates SSL context with the given key managers and the trust managers of this factory. - * - * @param keyMgrs Key managers. - * @return Initialized SSL context. - * @throws SSLException If SSL context could not be created. - */ - final SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -144,6 +137,8 @@ final SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { ctx.init(keyMgrs, trustMgrs, null); + acceptKeyManagers(keyMgrs); + return ctx; } catch (NoSuchAlgorithmException e) { @@ -154,6 +149,17 @@ final SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { } } + /** + * Accepts the key managers of a new SSL context before the context replaces the current one. + * Does nothing by default. + * + * @param keyMgrs Key managers created by {@link #createKeyManagers()} for the new context. + * @throws SSLException If the new context must not replace the current one. + */ + void acceptKeyManagers(KeyManager[] keyMgrs) throws SSLException { + // No-op. + } + /** * @param param Value. * @param name Name. @@ -191,8 +197,6 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ public final synchronized void init() { try { - checkParameters(); - sslCtx.set(createSslContext()); } catch (SSLException e) { diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index d85ab502ed2e1..ba981806d6096 100644 --- a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java @@ -21,9 +21,11 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.Socket; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; +import java.security.Principal; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; @@ -35,10 +37,12 @@ import java.util.Map; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509TrustManager; import org.apache.ignite.IgniteException; import org.apache.ignite.internal.systemview.SensitiveProperty; @@ -143,22 +147,16 @@ public void renew() { } /** {@inheritDoc} */ - @Override SSLContext createSslContext() throws SSLException { - KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); - SSLContext ctx = createSslContext(createKeyManagers(keyStore)); + @Override void acceptKeyManagers(KeyManager[] keyMgrs) throws SSLException { + Map chains = Arrays.stream(keyMgrs) + .filter(LoadedKeyManager.class::isInstance) + .map(mgr -> ((LoadedKeyManager)mgr).chains) + .findFirst() + .orElse(Map.of()); - try { - Map chains = keyChains(keyStore); - - checkValidity(chains); + checkValidity(chains); - renewalTime = renewalEnabled ? renewalTime(chains) : 0; - - return ctx; - } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to read TLS material [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); - } + renewalTime = renewalEnabled ? renewalTime(chains) : 0; } /** @@ -335,21 +333,18 @@ public static TrustManager getDisabledTrustManager() { /** {@inheritDoc} */ @Override protected final KeyManager[] createKeyManagers() throws SSLException { - return createKeyManagers(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd)); - } - - /** - * @param keyStore Loaded key store. - * @return Key managers for the key store. - * @throws SSLException If key managers could not be created. - */ - private KeyManager[] createKeyManagers(KeyStore keyStore) throws SSLException { try { KeyManagerFactory keyMgrFactory = KeyManagerFactory.getInstance(keyAlgorithm); + KeyStore keyStore = loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd); + keyMgrFactory.init(keyStore, keyStorePwd); - return keyMgrFactory.getKeyManagers(); + Map chains = keyChains(keyStore); + + return Arrays.stream(keyMgrFactory.getKeyManagers()) + .map(mgr -> mgr instanceof X509KeyManager x509Mgr ? new LoadedKeyManager(x509Mgr, chains) : mgr) + .toArray(KeyManager[]::new); } catch (NoSuchAlgorithmException e) { throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); @@ -587,4 +582,63 @@ private static class DisabledX509TrustManager implements X509TrustManager { return CERTS; } } + + /** X.509 key manager that keeps the private-key chains of the key store it was created from. */ + private static class LoadedKeyManager extends X509ExtendedKeyManager { + /** */ + private final X509KeyManager delegate; + + /** */ + private final Map chains; + + /** */ + private LoadedKeyManager(X509KeyManager delegate, Map chains) { + this.delegate = delegate; + this.chains = chains; + } + + /** {@inheritDoc} */ + @Override public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + /** {@inheritDoc} */ + @Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket sock) { + return delegate.chooseClientAlias(keyType, issuers, sock); + } + + /** {@inheritDoc} */ + @Override public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + /** {@inheritDoc} */ + @Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket sock) { + return delegate.chooseServerAlias(keyType, issuers, sock); + } + + /** {@inheritDoc} */ + @Override public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + /** {@inheritDoc} */ + @Override public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + + /** {@inheritDoc} */ + @Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return delegate instanceof X509ExtendedKeyManager extMgr + ? extMgr.chooseEngineClientAlias(keyType, issuers, engine) + : super.chooseEngineClientAlias(keyType, issuers, engine); + } + + /** {@inheritDoc} */ + @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return delegate instanceof X509ExtendedKeyManager extMgr + ? extMgr.chooseEngineServerAlias(keyType, issuers, engine) + : super.chooseEngineServerAlias(keyType, issuers, engine); + } + } }