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 77dedf84eba78..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 @@ -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; @@ -30,9 +29,8 @@ import org.apache.ignite.internal.util.typedef.internal.A; /** - * 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. + * 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,9 +48,6 @@ public abstract class AbstractSslContextFactory implements Factory { /** Enabled protocols. */ protected String[] protocols; - /** Cached instance of an {@link SSLContext}. */ - protected final AtomicReference sslCtx = new AtomicReference<>(); - /** * Gets protocol for secure transport. * @@ -110,16 +105,31 @@ public void setProtocols(String... protocols) { } /** - * Creates SSL context based on factory settings. + * 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 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(); - KeyManager[] keyMgrs = createKeyManagers(); + KeyMaterial material = loadKeyMaterial(); + return new SslBundle(createSslContext(material.keyManagers()), material.certificateChains()); + } + catch (SSLException e) { + throw new IgniteException(e.getMessage() + " [factory=" + this + ']', e); + } + } + + /** + * @param keyMgrs Key managers for the loaded material. + * @return Initialized context. + * @throws SSLException If trust managers or the context cannot be created. + */ + private SSLContext createSslContext(KeyManager[] keyMgrs) throws SSLException { TrustManager[] trustMgrs = createTrustManagers(); try { @@ -167,10 +177,13 @@ 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. + * 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, never {@code null}. + * @throws SSLException If the key material cannot be loaded. */ - protected abstract KeyManager[] createKeyManagers() throws SSLException; + protected abstract KeyMaterial loadKeyMaterial() throws SSLException; /** * @return Created Trust Managers. @@ -178,22 +191,13 @@ protected void checkNullParameter(Object param, String name) throws SSLException */ protected abstract TrustManager[] createTrustManagers() throws SSLException; - /** {@inheritDoc} */ - @Override public SSLContext create() { - SSLContext ctx = sslCtx.get(); - - if (ctx == null) { - try { - ctx = createSslContext(); - - if (!sslCtx.compareAndSet(null, ctx)) - ctx = sslCtx.get(); - } - catch (SSLException e) { - throw new IgniteException(e); - } - } - - return ctx; + /** + * 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 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 new file mode 100644 index 0000000000000..565997946062b --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -0,0 +1,135 @@ +/* + * 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. Do not modify the store while any context created from it is in use: key managers + * such as PKIX read it during handshakes. Load replacement material into a separate store. + */ +public final class KeyMaterial { + /** */ + private final KeyStore store; + + /** */ + private final char[] keyPwd; + + /** */ + private final String algorithm; + + /** + * @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. + */ + 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, 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 { + try { + Map> chains = new TreeMap<>(); + + for (Enumeration aliases = store.aliases(); aliases.hasMoreElements(); ) { + String alias = aliases.nextElement(); + + if (!store.isKeyEntry(alias)) + continue; + + List chain = certificateChain(store.getCertificateChain(alias)); + + if (!chain.isEmpty()) + chains.put(alias, chain); + } + + return Collections.unmodifiableMap(chains); + } + catch (KeyStoreException e) { + throw new SSLException("Failed to read certificate chains from key store", e); + } + } + + /** + * @param certs Chain from the store, if present. + * @return Immutable X.509 chain, or an empty list if it is absent or contains another certificate type. + */ + private static List certificateChain(@Nullable Certificate[] certs) { + if (certs == null) + return Collections.emptyList(); + + List chain = new ArrayList<>(certs.length); + + for (Certificate cert : certs) { + if (!(cert instanceof X509Certificate)) + return Collections.emptyList(); + + chain.add((X509Certificate)cert); + } + + return Collections.unmodifiableList(chain); + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java new file mode 100644 index 0000000000000..f15f4ccfb61ed --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.ssl; + +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.util.typedef.internal.A; +import org.jetbrains.annotations.Nullable; + +/** + * An initialized SSL context and a snapshot of the certificate chains loaded with it. The chains describe its key material, + * not the certificate selected for a particular connection. Do not reinitialize the context after creating the bundle. + */ +public final class SslBundle { + /** */ + private final SSLContext ctx; + + /** */ + private final Map> chains; + + /** + * @param ctx Initialized SSL context. + * @param chains Nonempty X.509 chains of key entries, by alias, 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"); + + this.ctx = ctx; + + if (chains == null) + this.chains = null; + else { + Map> copy = new TreeMap<>(); + + chains.forEach((alias, chain) -> { + A.ensure(!chain.isEmpty(), "Certificate chain must not be empty [alias=" + alias + ']'); + + copy.put(alias, List.copyOf(chain)); + }); + + this.chains = Collections.unmodifiableMap(copy); + } + } + + /** @return Initialized context. */ + public SSLContext context() { + return ctx; + } + + /** + * @return Immutable, nonempty chains ordered by alias, or {@code null} if certificate metadata is unavailable. + * An empty map means there are no chains. + */ + public @Nullable Map> certificateChains() { + return chains; + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index e060dcc241b32..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,9 +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.KeyManagerFactory; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; @@ -40,8 +37,8 @@ /** * 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,23 +259,8 @@ public static TrustManager getDisabledTrustManager() { } /** {@inheritDoc} */ - @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); - } + @Override protected KeyMaterial loadKeyMaterial() throws SSLException { + return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); } /** {@inheritDoc} */ @@ -359,7 +341,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 bbdf058dea191..f2b464d91bd6e 100644 --- a/modules/core/pom.xml +++ b/modules/core/pom.xml @@ -158,6 +158,13 @@ test + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + test + + org.eclipse.jetty.ee11 jetty-ee11-servlets diff --git a/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java b/modules/core/src/main/java/org/apache/ignite/internal/jdbc/thin/JdbcThinSSLUtil.java index 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/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/SslContextMetadataTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java new file mode 100644 index 0000000000000..f637b7fe1481a --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -0,0 +1,261 @@ +/* + * 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.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.crypto.KeyGenerator; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; +import org.apache.ignite.IgniteException; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.TestCertificateAuthority; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static org.apache.ignite.testframework.TestCertificateAuthority.PWD; +import static org.apache.ignite.testframework.TestCertificateAuthority.chain; +import static org.apache.ignite.testframework.TestCertificateAuthority.keyStoreWithoutCertificateChain; +import static org.mockito.Mockito.mock; + +/** + * Each factory call reads the key store once into a fresh context, throws when the read fails and reads again on the next call, + * and checks parameters before reading. {@link KeyMaterial} reports nonempty X.509 chains of key entries without recovering + * their keys, and keeps its own key password. {@link SslBundle} keeps a sorted immutable copy of the metadata, or {@code null}, + * and rejects empty per-alias chains. {@link SslContextFactory} names the store whose key it cannot open. + */ +public class SslContextMetadataTest extends GridCommonAbstractTest { + /** Authority that signs the test certificates. */ + private TestCertificateAuthority ca; + + /** {@inheritDoc} */ + @Override protected void beforeTest() throws Exception { + ca = new TestCertificateAuthority("metadata-ca"); + } + + /** Each bundle reads the key store once, builds a new context and takes its metadata from that read. */ + @Test + public void testEachBundleReadsKeyStoreOnce() throws Exception { + KeyStore first = ca.keyStoreWithPrivateKey("first"); + InMemoryStoreFactory factory = new InMemoryStoreFactory(first); + + SslBundle firstBundle = factory.createSslBundle(); + + assertEquals(1, factory.loads); + assertEquals(Map.of("first", chain(first)), firstBundle.certificateChains()); + + KeyStore second = ca.keyStoreWithPrivateKey("second"); + + factory.store = second; + + SslBundle secondBundle = factory.createSslBundle(); + + assertEquals(2, factory.loads); + assertEquals(Map.of("second", chain(second)), secondBundle.certificateChains()); + assertNotSame(firstBundle.context(), secondBundle.context()); + } + + /** A failed read is thrown instead of reusing an earlier context, and the next call reads the store again. */ + @Test + public void testFailedLoadCanBeRetried() throws Exception { + InMemoryStoreFactory factory = new InMemoryStoreFactory(ca.keyStoreWithPrivateKey()); + + factory.create(); + + factory.loadFailure = "Key store is unavailable"; + + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, "Key store is unavailable"); + + assertTrue(err.getCause() instanceof SSLException); + + factory.loadFailure = null; + + factory.create(); + } + + /** Missing parameters are reported before the key store is read. */ + @Test + public void testInvalidParametersDoNotLoadStore() throws Exception { + KeyStore empty = KeyStore.getInstance("JKS"); + + empty.load(null, null); + + InMemoryStoreFactory factory = new InMemoryStoreFactory(empty); + + factory.setTrustManagers((TrustManager[])null); + + GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "either trustStoreFilePath or trustManagers must be provided"); + assertEquals(0, factory.loads); + } + + /** Metadata includes only nonempty X.509 chains of key entries, without recovering the keys. */ + @Test + public void testOnlyKeyEntriesWithX509Chains() throws Exception { + String firstAlias = "first-key"; + String secondAlias = "second-key"; + KeyStore first = ca.keyStoreWithPrivateKey(firstAlias); + KeyStore second = ca.keyStoreWithPrivateKey(secondAlias); + KeyStore.PasswordProtection prot = new KeyStore.PasswordProtection(PWD); + + // JCEKS can hold both private keys and secret keys. + KeyStore store = keyStoreWithoutCertificateChain("JCEKS", "key-without-chain"); + + store.setEntry(firstAlias, first.getEntry(firstAlias, prot), prot); + store.setEntry(secondAlias, second.getEntry(secondAlias, prot), prot); + store.setCertificateEntry("trusted", first.getCertificate(firstAlias)); + store.setEntry("secret", new KeyStore.SecretKeyEntry(KeyGenerator.getInstance("AES").generateKey()), + 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(firstAlias, chain(first), secondAlias, chain(second)), material.certificateChains()); + } + + /** An empty key store yields a context without private keys and an empty map. */ + @Test + public void testEmptyStoreCreatesKeylessContext() throws Exception { + KeyStore empty = KeyStore.getInstance("JKS"); + + empty.load(null, null); + + SslBundle bundle = new InMemoryStoreFactory(empty).createSslBundle(); + + assertEquals(Map.of(), bundle.certificateChains()); + } + + /** Material keeps its own copy of the key password, so clearing the caller's array does not lock the keys. */ + @Test + public void testLoadedMaterialCopiesKeyPassword() throws Exception { + char[] pwd = PWD.clone(); + KeyMaterial material = new KeyMaterial(ca.keyStoreWithPrivateKey("node"), pwd, SslContextFactory.DFLT_KEY_ALGORITHM); + + Arrays.fill(pwd, '?'); + + X509KeyManager mgr = (X509KeyManager)material.keyManagers()[0]; + + assertNotNull(mgr.getPrivateKey("node")); + } + + /** A bundle orders aliases and copies the map and its lists, so later changes to the input do not reach it. */ + @Test + public void testBundleKeepsSortedImmutableCopy() throws Exception { + X509Certificate aCert = mock(X509Certificate.class); + X509Certificate bCert = mock(X509Certificate.class); + List aChain = new ArrayList<>(List.of(aCert)); + Map> input = new LinkedHashMap<>(); + + input.put("b", List.of(bCert)); + input.put("a", aChain); + + SslBundle bundle = new SslBundle(SSLContext.getDefault(), input); + + aChain.clear(); + input.clear(); + + Map> chains = bundle.certificateChains(); + + assertEquals(List.of("a", "b"), new ArrayList<>(chains.keySet())); + assertEquals(Map.of("a", List.of(aCert), "b", List.of(bCert)), chains); + GridTestUtils.assertThrows(log, () -> chains.put("c", List.of()), UnsupportedOperationException.class, null); + GridTestUtils.assertThrows(log, () -> chains.get("a").set(0, bCert), UnsupportedOperationException.class, null); + } + + /** Custom factories must omit entries without a chain instead of reporting an empty chain. */ + @Test + public void testBundleRejectsEmptyChain() throws Exception { + String alias = "missing-chain"; + + GridTestUtils.assertThrows(log, () -> new SslBundle(SSLContext.getDefault(), Map.of(alias, List.of())), + IllegalArgumentException.class, "Certificate chain must not be empty [alias=" + alias + ']'); + } + + /** 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()); + } + + /** A key the store password cannot open is reported with the store type and path and keeps the original cause. */ + @Test + public void testKeyPasswordFailureNamesStore() throws Exception { + KeyStore store = ca.keyStoreWithPrivateKey("private-key"); + + store.setKeyEntry("private-key", store.getKey("private-key", PWD), "other-key-password".toCharArray(), + store.getCertificateChain("private-key")); + + InMemoryStoreFactory factory = new InMemoryStoreFactory(store); + + String storePath = "unreadable-key.jks"; + + factory.setKeyStoreFilePath(storePath); + + String storeDetails = "keyStoreFile=" + storePath; + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, storeDetails); + + assertTrue(err.getCause() instanceof SSLException); + assertTrue(err.getMessage().contains("keyStoreType=" + store.getType())); + assertTrue(err.getCause().getCause() instanceof UnrecoverableKeyException); + } + + /** + * 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; + + /** Number of key store reads. */ + private int loads; + + /** Message of the read failure, or {@code null} while the store is readable. */ + private String loadFailure; + + /** @param store Store returned by reads. */ + InMemoryStoreFactory(KeyStore store) { + this.store = store; + + setKeyStoreType(store.getType()); + setKeyStoreFilePath("in-memory"); + setKeyStorePassword(PWD); + setTrustManagers(getDisabledTrustManager()); + } + + /** {@inheritDoc} */ + @Override protected KeyStore loadKeyStore(String type, String path, char[] pwd) throws SSLException { + loads++; + + if (loadFailure != null) + throw new SSLException(loadFailure); + + return store; + } + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java new file mode 100644 index 0000000000000..19792613b6650 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextReuseTest.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.ssl; + +import java.util.concurrent.atomic.AtomicInteger; +import javax.cache.configuration.Factory; +import javax.net.ssl.SSLContext; +import org.apache.ignite.internal.IgniteEx; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.spi.communication.tcp.internal.ConnectionClientPool; +import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +/** 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 { + stopAllGrids(); + + super.afterTest(); + } + + /** Reopening a communication connection does not call the SSL context factory on either node. */ + @Test + public void testCommunicationReusesContextOnReconnect() throws Exception { + Factory sslFactory = GridTestUtils.sslFactory(); + AtomicInteger created = new AtomicInteger(); + Factory counting = () -> { + created.incrementAndGet(); + + return sslFactory.create(); + }; + + IgniteEx first = startGrid(0, cfg -> { + cfg.setSslContextFactory(counting); + }); + IgniteEx second = startGrid(1, cfg -> { + cfg.setSslContextFactory(counting); + }); + int createdAtStart = created.get(); + + ConnectionClientPool pool = U.field(first.configuration().getCommunicationSpi(), "clientPool"); + + pool.forceCloseConnection(second.localNode().id()); + first.compute(first.cluster().forNodeId(second.localNode().id())).run(() -> {}); + + assertEquals(createdAtStart, created.get()); + } +} 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..7dd13e7a8fbe1 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.testframework; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreSpi; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * Creates test stores protected by {@link #PWD}. Private keys use EC P-256, 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. + */ +public class TestCertificateAuthority { + /** Password of every store made here. */ + public static final char[] PWD = "123456".toCharArray(); + + /** Name used when certificate identity is not part of the test. */ + private static final String DEFAULT_NAME = "test-certificate"; + + /** Certificate serial numbers must be positive and unique within this authority. */ + private final AtomicLong serial = new AtomicLong(); + + /** */ + private final X500Principal name; + + /** */ + private final KeyPair keys; + + /** */ + private final X509Certificate cert; + + /** + * Creates an authority valid from yesterday until a year from now. + * + * @param cn Common name of the authority. + */ + public TestCertificateAuthority(String cn) throws Exception { + long now = System.currentTimeMillis(); + + name = new X500Principal("CN=" + cn); + keys = keyPair(); + + cert = sign(name, keys, now - TimeUnit.DAYS.toMillis(1), now + TimeUnit.DAYS.toMillis(365), true); + } + + /** @return Store with one private key and a currently valid certificate whose name is irrelevant to the test. */ + public KeyStore keyStoreWithPrivateKey() throws Exception { + return keyStoreWithPrivateKey(DEFAULT_NAME); + } + + /** + * Issues a certificate valid from an hour ago until a day from now, for tests that do not check expiry. + * + * @param cn Common name, also used as the key alias. + * @return Key store with the private key and a chain containing the issued certificate and this authority's certificate. + */ + public KeyStore keyStoreWithPrivateKey(String cn) throws Exception { + long now = System.currentTimeMillis(); + KeyPair pair = keyPair(); + + X509Certificate leaf = sign(new X500Principal("CN=" + cn), pair, now - TimeUnit.HOURS.toMillis(1), + now + TimeUnit.DAYS.toMillis(1), false); + + KeyStore store = emptyStore(); + + store.setKeyEntry(cn, pair.getPrivate(), PWD, new X509Certificate[] {leaf, cert}); + + return store; + } + + /** + * @param type Store type. + * @param alias Private-key alias. + * @return Saved and reloaded store with a private key but no certificate chain, protected by {@link #PWD}. + */ + public static KeyStore keyStoreWithoutCertificateChain(String type, String alias) throws Exception { + KeyStore store = KeyStore.getInstance(type); + + // KeyStore rejects a missing chain on insertion; its provider can still store and load such a key. + KeyStoreSpi spi = (KeyStoreSpi)store.getProvider().getService("KeyStore", type).newInstance(null); + + spi.engineLoad(null, PWD); + spi.engineSetKeyEntry(alias, keyPair().getPrivate(), PWD, null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + spi.engineStore(out, PWD); + store.load(new ByteArrayInputStream(out.toByteArray()), PWD); + + return store; + } + + /** @return Trust store with this authority alone. */ + public KeyStore trustStore() throws Exception { + KeyStore store = emptyStore(); + + store.setCertificateEntry(name.getName(), cert); + + return store; + } + + /** + * @param keyStore Store with exactly one private key. + * @return Its certificate chain in the form certificate metadata uses. + */ + public static List chain(KeyStore keyStore) throws Exception { + List chain = null; + + for (String alias : Collections.list(keyStore.aliases())) { + if (!keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + continue; + + if (chain != null) + throw new IllegalArgumentException("The test key store must contain exactly one private key"); + + Certificate[] certs = keyStore.getCertificateChain(alias); + + chain = List.of(Arrays.copyOf(certs, certs.length, X509Certificate[].class)); + } + + if (chain == null) + throw new IllegalArgumentException("The test key store must contain exactly one private key"); + + return chain; + } + + /** + * @param trustStore Store with the authorities to trust. + * @return Trust managers that trust them. + */ + public static TrustManager[] trustManagers(KeyStore trustStore) throws Exception { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + tmf.init(trustStore); + + return tmf.getTrustManagers(); + } + + /** + * @param subj Subject. + * @param subjKeys Key pair of the subject. + * @param notBefore Time the certificate becomes valid. + * @param notAfter Time it expires. + * @param ca Whether the certificate is the authority's own. + * @return Certificate signed by this authority. + */ + private X509Certificate sign(X500Principal subj, KeyPair subjKeys, long notBefore, long notAfter, boolean ca) throws Exception { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, + BigInteger.valueOf(serial.incrementAndGet()), + new Date(notBefore), + new Date(notAfter), + subj, + subjKeys.getPublic()); + + if (ca) + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + else + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(new KeyPurposeId[] {KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").build(keys.getPrivate()); + + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + /** */ + private static KeyPair keyPair() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + + gen.initialize(new ECGenParameterSpec("secp256r1")); + + return gen.generateKeyPair(); + } + + /** */ + private static KeyStore emptyStore() throws Exception { + KeyStore store = KeyStore.getInstance("JKS"); + + store.load(null, PWD); + + return store; + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/SecurityTestSuite.java index 37fa57703b572..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 @@ -80,6 +80,8 @@ import org.apache.ignite.plugin.security.SecurityBasicPermissionSetSerializationTest; import org.apache.ignite.ssl.MultipleSSLContextsTest; import org.apache.ignite.ssl.SSLContextWrapperTest; +import org.apache.ignite.ssl.SslContextMetadataTest; +import org.apache.ignite.ssl.SslContextReuseTest; import org.apache.ignite.tools.junit.JUnitTeamcityReporter; import org.junit.BeforeClass; import org.junit.runner.RunWith; @@ -143,6 +145,8 @@ IgniteSecurityProcessorTest.class, MultipleSSLContextsTest.class, SSLContextWrapperTest.class, + SslContextMetadataTest.class, + SslContextReuseTest.class, MaintenanceModeNodeSecurityTest.class, ServiceAuthorizationTest.class, ServiceStaticConfigTest.class, diff --git a/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs b/modules/platforms/dotnet/Apache.Ignite.Core.Tests/Ssl/SslConfigurationTest.cs index 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); } 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