From cb49fc28b0e2fc2594e9857900feb181731fe678 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 17:34:25 +0300 Subject: [PATCH 01/35] 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 ae4204169a2ce22833d1856624423ef74c96d665 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 20:12:15 +0300 Subject: [PATCH 02/35] 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 0802865a3e198fd6e98ef188ed29665dcf1cd77a Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:20:01 +0300 Subject: [PATCH 03/35] 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 cf8143f403bcdc19e2d7451adfd6cdc8eb30e1e4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 21:53:44 +0300 Subject: [PATCH 04/35] 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 05/35] 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 fdb2a29f3bb41abc22809f96bac0924ffebff62b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Mon, 26 Oct 2026 22:50:15 +0300 Subject: [PATCH 06/35] 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 07/35] 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 08/35] 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 65c8098c9703b69cbcb8c04d4ac5167c71b74a3d Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 00:50:25 +0300 Subject: [PATCH 09/35] 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 10/35] 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 9716fd9fb24217feee1afea3f2046cf457343d46 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 01:20:45 +0300 Subject: [PATCH 11/35] 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 893a520f8808f3e23d4cced42bd5d51519142311 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:04:23 +0300 Subject: [PATCH 12/35] 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 13/35] 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 fbf5c510da4facf05dfa75a63221ee63e2e0d0f5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:33:13 +0300 Subject: [PATCH 14/35] 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 cbef7aa5fe4aadf586a8808674cf2a64a9512ad4 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 02:52:24 +0300 Subject: [PATCH 15/35] 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 2c9b01f235a7bec5598b6d56e2bc077c2d8c9cb6 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:28:46 +0300 Subject: [PATCH 16/35] 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 17/35] 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 7ab76356a919e030dc97c45f3b1457e34df33950 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 15:45:19 +0300 Subject: [PATCH 18/35] 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 72a76a4c1db30c8b67b0ef1f2b7a4bda26d1e3af Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:10:03 +0300 Subject: [PATCH 19/35] 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 a360d09a188ebdddf4bc68d771bcfa2f16086d77 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 16:28:29 +0300 Subject: [PATCH 20/35] 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 21/35] 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 65a208e51413a3445152b0bbd00a54e131476ce9 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 17:40:47 +0300 Subject: [PATCH 22/35] 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 fecbd9e1e6533d56c45f353443af3c1d048c884b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 18:08:05 +0300 Subject: [PATCH 23/35] 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 24/35] 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 25/35] 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 26/35] 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 e0dd39eba6118fe37ffb6f3ff1088678ec2cc55e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 22:09:24 +0300 Subject: [PATCH 27/35] 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 5fa46cb047e54c8119a96516e5fae711e90c1130 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:27:06 +0300 Subject: [PATCH 28/35] 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 c7f4aaebcc3f2f19781e8bc721872199041bebf5 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Tue, 27 Oct 2026 23:52:23 +0300 Subject: [PATCH 29/35] 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 8a99c9926e2095c452461b35b48564ae862a474e Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 02:28:34 +0300 Subject: [PATCH 30/35] 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 a1bb15fb55fc444908c596438695bdf3503ebbd8 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 15:30:20 +0300 Subject: [PATCH 31/35] 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 5f0c03fe35ffb20d29f26203b56938a87fa6238b Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 16:37:36 +0300 Subject: [PATCH 32/35] 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 f7e62f67406fe7d95014ab2e7ee99f13996da3fc Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Wed, 28 Oct 2026 19:43:43 +0300 Subject: [PATCH 33/35] 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 89d0e7ee3d9c6918b31736e94146188bbc90eb33 Mon Sep 17 00:00:00 2001 From: Anton Vinogradov Date: Thu, 29 Oct 2026 13:44:58 +0300 Subject: [PATCH 34/35] 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 35/35] 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); }