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..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 @@ -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,15 +105,32 @@ 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(); + + KeyMaterial material = loadKeyMaterial(); + + return new SslBundle(createSslContext(createKeyManagers(material)), material.certificateChains()); + } + catch (SSLException e) { + throw new IgniteException(e); + } + } - KeyManager[] keyMgrs = createKeyManagers(); + /** + * @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 { + checkNullParameter(keyMgrs, "keyManagers"); TrustManager[] trustMgrs = createTrustManagers(); @@ -167,10 +179,24 @@ 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 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 abstract KeyManager[] createKeyManagers() throws SSLException; + protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { + return material.keyManagers(); + } /** * @return Created Trust Managers. @@ -178,22 +204,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..2cfc501341b88 --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/KeyMaterial.java @@ -0,0 +1,130 @@ +/* + * 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 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 { + 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/SslBundle.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java new file mode 100644 index 0000000000000..ee5206e5f0d6a --- /dev/null +++ b/modules/commons/src/main/java/org/apache/ignite/ssl/SslBundle.java @@ -0,0 +1,75 @@ +/* + * 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 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. + */ + 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 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/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java b/modules/commons/src/main/java/org/apache/ignite/ssl/SslContextFactory.java index e060dcc241b32..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 @@ -28,8 +28,6 @@ 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 +38,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,22 +260,17 @@ 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); + @Override protected KeyMaterial loadKeyMaterial() throws SSLException { + return new KeyMaterial(loadKeyStore(keyStoreType, keyStoreFilePath, keyStorePwd), keyStorePwd, keyAlgorithm); + } - return keyMgrFactory.getKeyManagers(); - } - catch (NoSuchAlgorithmException e) { - throw new SSLException("Unsupported key algorithm: " + keyAlgorithm, e); + /** {@inheritDoc} */ + @Override protected KeyManager[] createKeyManagers(KeyMaterial material) throws SSLException { + try { + return super.createKeyManagers(material); } - catch (GeneralSecurityException e) { - throw new SSLException("Failed to initialize Key Manager (security exception occurred) [type=" + - keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); + catch (SSLException e) { + throw new SSLException(e.getMessage() + " [type=" + keyStoreType + ", keyStorePath=" + keyStoreFilePath + ']', e); } } @@ -359,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 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/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..8a1b632a144ff --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/ssl/SslContextMetadataTest.java @@ -0,0 +1,329 @@ +/* + * 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.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 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.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 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 { + /** 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 lists every private key with its own chain and skips trusted certificates and secret keys. */ + @Test + 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, 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); + + KeyMaterial material = new KeyMaterial(store, PWD, SslContextFactory.DFLT_KEY_ALGORITHM); + + assertEquals(Map.of("first-key", chain(first), "second-key", 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()); + } + + /** 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()); + } + + /** 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); + } + + /** Unknown metadata stays {@code null}, so it is not mistaken for a store without private keys. */ + @Test + 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 { + 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); + + factory.setKeyStoreFilePath("unreadable-key.jks"); + + Throwable err = GridTestUtils.assertThrows(log, factory::create, IgniteException.class, + "[type=JKS, keyStorePath=unreadable-key.jks]"); + + assertTrue(X.hasCause(err, UnrecoverableKeyException.class)); + } + + /** + * File factory that reads its key store from memory and counts the reads. Trust is disabled, so the trust store is never + * read and every read is the key store's. + */ + 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/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 new file mode 100644 index 0000000000000..fe3c9f829ae69 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/testframework/TestCertificateAuthority.java @@ -0,0 +1,200 @@ +/* + * 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.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +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; + +/** + * 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. + */ +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; + } + + /** @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/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