From e3677f7ff6bfab4b216a140acb75ac1b42d200b1 Mon Sep 17 00:00:00 2001 From: Scott Frederick Date: Thu, 20 Apr 2023 21:48:23 -0700 Subject: [PATCH] Add SSL bundle support to spring-boot module Add classes to support SSL bundles which can be used to apply SSL settings in a centralized way. An `SslBundle` can be registered with an `SslBundleRegistry` and obtained from an `SslBundles` instance. The `DefaultSslBundleRegistry` provides a default in-memory implementation. Different client libraries often configure SSL in slightly different ways. To accommodate this, the `SslBundle` provides a layered approach of obtaining SSL information: - `getStores` provides access to the key store and trust stores as well as any required key store password. - `getManagers` provides access to the `KeyManagerFactory`, `TrustManagerFactory` as well as the `KeyManger` and `TrustManager` arrays that they create. - `createSslContext` provides a convenient way to obtain a new `SSLContext` instance. In addition, the `SslBundle` also provides details about the key being used, the protocol to use and any options that should be applied to the SSL engine. See gh-34814 --- .../boot/ssl/AliasKeyManagerFactory.java | 149 +++++++++++ .../boot/ssl/DefaultSslBundleRegistry.java | 59 +++++ .../boot/ssl/DefaultSslManagerBundle.java | 86 ++++++ .../boot/ssl/NoSuchSslBundleException.java | 58 +++++ .../springframework/boot/ssl/SslBundle.java | 166 ++++++++++++ .../boot/ssl/SslBundleKey.java | 100 +++++++ .../boot/ssl/SslBundleRegistry.java | 34 +++ .../springframework/boot/ssl/SslBundles.java | 35 +++ .../boot/ssl/SslManagerBundle.java | 121 +++++++++ .../springframework/boot/ssl/SslOptions.java | 93 +++++++ .../boot/ssl/SslStoreBundle.java | 81 ++++++ .../boot/ssl/jks/JksSslStoreBundle.java | 122 +++++++++ .../boot/ssl/jks/JksSslStoreDetails.java | 65 +++++ .../boot/ssl/jks/package-info.java | 20 ++ .../boot/ssl/package-info.java | 20 ++ .../boot/ssl/pem/PemCertificateParser.java | 95 +++++++ .../boot/ssl/pem/PemContent.java | 64 +++++ .../boot/ssl/pem/PemPrivateKeyParser.java | 245 ++++++++++++++++++ .../boot/ssl/pem/PemSslStoreBundle.java | 116 +++++++++ .../boot/ssl/pem/PemSslStoreDetails.java | 66 +++++ .../boot/ssl/pem/package-info.java | 20 ++ .../boot/ssl/AliasKeyManagerFactoryTests.java | 54 ++++ .../ssl/DefaultSslBundleRegistryTests.java | 92 +++++++ .../ssl/DefaultSslManagerBundleTests.java | 156 +++++++++++ .../ssl/NoSuchSslBundleExceptionTests.java | 38 +++ .../boot/ssl/SslBundleKeyTests.java | 75 ++++++ .../boot/ssl/SslBundleTests.java | 55 ++++ .../boot/ssl/SslManagerBundleTests.java | 88 +++++++ .../boot/ssl/SslOptionsTests.java | 75 ++++++ .../boot/ssl/SslStoreBundleTests.java | 52 ++++ .../boot/ssl/jks/JksSslStoreBundleTests.java | 137 ++++++++++ .../ssl/pem/PemCertificateParserTests.java | 57 ++++ .../boot/ssl/pem/PemContentTests.java | 77 ++++++ .../ssl/pem/PemPrivateKeyParserTests.java | 62 +++++ .../boot/ssl/pem/PemSslStoreBundleTests.java | 137 ++++++++++ 35 files changed, 2970 insertions(+) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/AliasKeyManagerFactory.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslManagerBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/NoSuchSslBundleException.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslManagerBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslOptions.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslStoreBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreDetails.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/package-info.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/package-info.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/package-info.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/AliasKeyManagerFactoryTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslManagerBundleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/NoSuchSslBundleExceptionTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslManagerBundleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslOptionsTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslStoreBundleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/AliasKeyManagerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/AliasKeyManagerFactory.java new file mode 100644 index 0000000000..41f431b6db --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/AliasKeyManagerFactory.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.net.Socket; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.KeyManagerFactorySpi; +import javax.net.ssl.ManagerFactoryParameters; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; + +/** + * {@link KeyManagerFactory} that allows a configurable key alias to be used. Due to the + * fact that the actual calls to retrieve the key by alias are done at request time the + * approach is to wrap the actual key managers with a {@link AliasX509ExtendedKeyManager}. + * The actual SPI has to be wrapped as well due to the fact that + * {@link KeyManagerFactory#getKeyManagers()} is final. + * + * @author Scott Frederick + */ +final class AliasKeyManagerFactory extends KeyManagerFactory { + + AliasKeyManagerFactory(KeyManagerFactory delegate, String alias, String algorithm) { + super(new AliasKeyManagerFactorySpi(delegate, alias), delegate.getProvider(), algorithm); + } + + /** + * {@link KeyManagerFactorySpi} that allows a configurable key alias to be used. + */ + private static final class AliasKeyManagerFactorySpi extends KeyManagerFactorySpi { + + private final KeyManagerFactory delegate; + + private final String alias; + + private AliasKeyManagerFactorySpi(KeyManagerFactory delegate, String alias) { + this.delegate = delegate; + this.alias = alias; + } + + @Override + protected void engineInit(KeyStore keyStore, char[] chars) + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + this.delegate.init(keyStore, chars); + } + + @Override + protected void engineInit(ManagerFactoryParameters managerFactoryParameters) + throws InvalidAlgorithmParameterException { + throw new InvalidAlgorithmParameterException("Unsupported ManagerFactoryParameters"); + } + + @Override + protected KeyManager[] engineGetKeyManagers() { + return Arrays.stream(this.delegate.getKeyManagers()) + .filter(X509ExtendedKeyManager.class::isInstance) + .map(X509ExtendedKeyManager.class::cast) + .map(this::wrap) + .toArray(KeyManager[]::new); + } + + private AliasKeyManagerFactory.AliasX509ExtendedKeyManager wrap(X509ExtendedKeyManager keyManager) { + return new AliasX509ExtendedKeyManager(keyManager, this.alias); + } + + } + + /** + * {@link X509ExtendedKeyManager} that allows a configurable key alias to be used. + */ + static final class AliasX509ExtendedKeyManager extends X509ExtendedKeyManager { + + private final X509ExtendedKeyManager delegate; + + private final String alias; + + private AliasX509ExtendedKeyManager(X509ExtendedKeyManager keyManager, String alias) { + this.delegate = keyManager; + this.alias = alias; + } + + @Override + public String chooseEngineClientAlias(String[] strings, Principal[] principals, SSLEngine sslEngine) { + return this.delegate.chooseEngineClientAlias(strings, principals, sslEngine); + } + + @Override + public String chooseEngineServerAlias(String s, Principal[] principals, SSLEngine sslEngine) { + return this.alias; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return this.delegate.chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return this.delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return this.delegate.getCertificateChain(alias); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return this.delegate.getClientAliases(keyType, issuers); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return this.delegate.getPrivateKey(alias); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return this.delegate.getServerAliases(keyType, issuers); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java new file mode 100644 index 0000000000..fa79265755 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslBundleRegistry.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.util.Assert; + +/** + * Default {@link SslBundleRegistry} implementation. + * + * @author Scott Frederick + * @since 3.1.0 + */ +public class DefaultSslBundleRegistry implements SslBundleRegistry, SslBundles { + + private final Map bundles = new ConcurrentHashMap<>(); + + public DefaultSslBundleRegistry() { + } + + public DefaultSslBundleRegistry(String name, SslBundle bundle) { + registerBundle(name, bundle); + } + + @Override + public void registerBundle(String name, SslBundle bundle) { + Assert.notNull(name, "Name must not be null"); + Assert.notNull(bundle, "Bundle must not be null"); + SslBundle previous = this.bundles.putIfAbsent(name, bundle); + Assert.state(previous == null, () -> "Cannot replace existing SSL bundle '%s'".formatted(name)); + } + + @Override + public SslBundle getBundle(String name) { + Assert.notNull(name, "Name must not be null"); + SslBundle bundle = this.bundles.get(name); + if (bundle == null) { + throw new NoSuchSslBundleException(name, "SSL bundle name '%s' cannot be found".formatted(name)); + } + return bundle; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslManagerBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslManagerBundle.java new file mode 100644 index 0000000000..e449ab657a --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/DefaultSslManagerBundle.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +/** + * Default implementation of {@link SslManagerBundle}. + * + * @author Scott Frederick + * @see SslManagerBundle#from(SslStoreBundle, SslBundleKey) + */ +class DefaultSslManagerBundle implements SslManagerBundle { + + private final SslStoreBundle storeBundle; + + private final SslBundleKey key; + + DefaultSslManagerBundle(SslStoreBundle storeBundle, SslBundleKey key) { + this.storeBundle = (storeBundle != null) ? storeBundle : SslStoreBundle.NONE; + this.key = (key != null) ? key : SslBundleKey.NONE; + } + + @Override + public KeyManagerFactory getKeyManagerFactory() { + try { + KeyStore store = this.storeBundle.getKeyStore(); + this.key.assertContainsAlias(store); + String alias = this.key.getAlias(); + String algorithm = KeyManagerFactory.getDefaultAlgorithm(); + KeyManagerFactory factory = getKeyManagerFactoryInstance(algorithm); + factory = (alias != null) ? new AliasKeyManagerFactory(factory, alias, algorithm) : factory; + String password = this.key.getPassword(); + password = (password != null) ? password : this.storeBundle.getKeyStorePassword(); + factory.init(store, (password != null) ? password.toCharArray() : null); + return factory; + } + catch (RuntimeException ex) { + throw ex; + } + catch (Exception ex) { + throw new IllegalStateException("Could not load key manager factory: " + ex.getMessage(), ex); + } + } + + @Override + public TrustManagerFactory getTrustManagerFactory() { + try { + KeyStore store = this.storeBundle.getTrustStore(); + String algorithm = TrustManagerFactory.getDefaultAlgorithm(); + TrustManagerFactory factory = getTrustManagerFactoryInstance(algorithm); + factory.init(store); + return factory; + } + catch (Exception ex) { + throw new IllegalStateException("Could not load trust manager factory: " + ex.getMessage(), ex); + } + } + + protected KeyManagerFactory getKeyManagerFactoryInstance(String algorithm) throws NoSuchAlgorithmException { + return KeyManagerFactory.getInstance(algorithm); + } + + protected TrustManagerFactory getTrustManagerFactoryInstance(String algorithm) throws NoSuchAlgorithmException { + return TrustManagerFactory.getInstance(algorithm); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/NoSuchSslBundleException.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/NoSuchSslBundleException.java new file mode 100644 index 0000000000..c4c66140f8 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/NoSuchSslBundleException.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +/** + * Exception indicating that an {@link SslBundle} was referenced with a name that does not + * match any registered bundle. + * + * @author Scott Frederick + * @since 3.1.0 + */ +public class NoSuchSslBundleException extends RuntimeException { + + private final String bundleName; + + /** + * Create a new {@code SslBundleNotFoundException} instance. + * @param bundleName the name of the bundle that could not be found + * @param message the exception message + */ + public NoSuchSslBundleException(String bundleName, String message) { + this(bundleName, message, null); + } + + /** + * Create a new {@code SslBundleNotFoundException} instance. + * @param bundleName the name of the bundle that could not be found + * @param message the exception message + * @param cause the exception cause + */ + public NoSuchSslBundleException(String bundleName, String message, Throwable cause) { + super(message, cause); + this.bundleName = bundleName; + } + + /** + * Return the name of the bundle that was not found. + * @return the bundle name + */ + public String getBundleName() { + return this.bundleName; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundle.java new file mode 100644 index 0000000000..790b225df7 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundle.java @@ -0,0 +1,166 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; + +import org.springframework.util.StringUtils; + +/** + * A bundle of trust material that can be used to establish an SSL connection. + * + * @author Scott Frederick + * @since 3.1.0 + */ +public interface SslBundle { + + /** + * The default protocol to use. + */ + String DEFAULT_PROTOCOL = "TLS"; + + /** + * Return the {@link SslStoreBundle} that can be used to access this bundle's key and + * trust stores. + * @return the {@code SslStoreBundle} instance for this bundle + */ + SslStoreBundle getStores(); + + /** + * Return a reference to the key that should be used for this bundle or + * {@link SslBundleKey#NONE}. + * @return a reference to the SSL key that should be used + */ + SslBundleKey getKey(); + + /** + * Return {@link SslOptions} that should be applied when establishing the SSL + * connection. + * @return the options that should be applied + */ + SslOptions getOptions(); + + /** + * Return the protocol to use when establishing the connection. Values should be + * supported by {@link SSLContext#getInstance(String)}. + * @return the SSL protocol + * @see SSLContext#getInstance(String) + */ + String getProtocol(); + + /** + * Return the {@link SslManagerBundle} that can be used to access this bundle's + * {@link KeyManager key} and {@link TrustManager trust} managers. + * @return the {@code SslManagerBundle} instance for this bundle + */ + SslManagerBundle getManagers(); + + /** + * Factory method to create a new {@link SSLContext} for this bundle. + * @return a new {@link SSLContext} instance + */ + default SSLContext createSslContext() { + return getManagers().createSslContext(getProtocol()); + } + + /** + * Factory method to create a new {@link SslBundle} instance. + * @param stores the stores or {@code null} + * @return a new {@link SslBundle} instance + */ + static SslBundle of(SslStoreBundle stores) { + return of(stores, null, null); + } + + /** + * Factory method to create a new {@link SslBundle} instance. + * @param stores the stores or {@code null} + * @param key the key or {@code null} + * @return a new {@link SslBundle} instance + */ + static SslBundle of(SslStoreBundle stores, SslBundleKey key) { + return of(stores, key, null); + } + + /** + * Factory method to create a new {@link SslBundle} instance. + * @param stores the stores or {@code null} + * @param key the key or {@code null} + * @param options the options or {@code null} + * @return a new {@link SslBundle} instance + */ + static SslBundle of(SslStoreBundle stores, SslBundleKey key, SslOptions options) { + return of(stores, key, options, null); + } + + /** + * Factory method to create a new {@link SslBundle} instance. + * @param stores the stores or {@code null} + * @param key the key or {@code null} + * @param options the options or {@code null} + * @param protocol the protocol or {@code null} + * @return a new {@link SslBundle} instance + */ + static SslBundle of(SslStoreBundle stores, SslBundleKey key, SslOptions options, String protocol) { + return of(stores, key, options, protocol, null); + } + + /** + * Factory method to create a new {@link SslBundle} instance. + * @param stores the stores or {@code null} + * @param key the key or {@code null} + * @param options the options or {@code null} + * @param protocol the protocol or {@code null} + * @param managers the managers or {@code null} + * @return a new {@link SslBundle} instance + */ + static SslBundle of(SslStoreBundle stores, SslBundleKey key, SslOptions options, String protocol, + SslManagerBundle managers) { + SslManagerBundle managersToUse = (managers != null) ? managers : SslManagerBundle.from(stores, key); + return new SslBundle() { + + @Override + public SslStoreBundle getStores() { + return (stores != null) ? stores : SslStoreBundle.NONE; + } + + @Override + public SslBundleKey getKey() { + return (key != null) ? key : SslBundleKey.NONE; + } + + @Override + public SslOptions getOptions() { + return (options != null) ? options : SslOptions.NONE; + } + + @Override + public String getProtocol() { + return (!StringUtils.hasText(protocol)) ? DEFAULT_PROTOCOL : protocol; + } + + @Override + public SslManagerBundle getManagers() { + return managersToUse; + } + + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java new file mode 100644 index 0000000000..cf94130200 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; +import java.security.KeyStoreException; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A reference to a single key obtained via {@link SslBundle}. + * + * @author Phillip Webb + * @since 3.1.0 + */ +public interface SslBundleKey { + + /** + * {@link SslBundleKey} that returns no values. + */ + SslBundleKey NONE = of(null, null); + + /** + * Return the password that should be used to access the key or {@code null} if no + * password is required. + * @return the key password + */ + String getPassword(); + + /** + * Return the alias of the key or {@code null} if the key has no alias. + * @return the key alias + */ + String getAlias(); + + /** + * Assert that the alias is contained in the given keystore. + * @param keyStore the keystore to check + */ + default void assertContainsAlias(KeyStore keyStore) { + String alias = getAlias(); + if (StringUtils.hasLength(alias) && keyStore != null) { + try { + Assert.state(keyStore.containsAlias(alias), + () -> String.format("Keystore does not contain alias '%s'", alias)); + } + catch (KeyStoreException ex) { + throw new IllegalStateException( + String.format("Could not determine if keystore contains alias '%s'", alias), ex); + } + } + } + + /** + * Factory method to create a new {@link SslBundleKey} instance. + * @param password the password used to access the key + * @return a new {@link SslBundleKey} instance + */ + static SslBundleKey of(String password) { + return of(password, null); + } + + /** + * Factory method to create a new {@link SslBundleKey} instance. + * @param password the password used to access the key + * @param alias the alias of the key + * @return a new {@link SslBundleKey} instance + */ + static SslBundleKey of(String password, String alias) { + return new SslBundleKey() { + + @Override + public String getPassword() { + return password; + } + + @Override + public String getAlias() { + return alias; + } + + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java new file mode 100644 index 0000000000..990a481066 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleRegistry.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +/** + * Interface that can be used to register an {@link SslBundle} for a given name. + * + * @author Scott Frederick + * @since 3.1.0 + */ +public interface SslBundleRegistry { + + /** + * Register a named {@link SslBundle}. + * @param name the bundle name + * @param bundle the bundle + */ + void registerBundle(String name, SslBundle bundle); + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java new file mode 100644 index 0000000000..ed8a0ea9cd --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundles.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +/** + * A managed set of {@link SslBundle} instances that can be retrieved by name. + * + * @author Scott Frederick + * @since 3.1.0 + */ +public interface SslBundles { + + /** + * Return an {@link SslBundle} with the provided name. + * @param bundleName the bundle name + * @return the bundle + * @throws NoSuchSslBundleException if a bundle with the provided name does not exist + */ + SslBundle getBundle(String bundleName) throws NoSuchSslBundleException; + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslManagerBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslManagerBundle.java new file mode 100644 index 0000000000..87e82a5374 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslManagerBundle.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + +import org.springframework.util.Assert; + +/** + * A bundle of key and trust managers that can be used to establish an SSL connection. + * Instances are usually created {@link #from(SslStoreBundle, SslBundleKey) from} an + * {@link SslStoreBundle}. + * + * @author Scott Frederick + * @since 3.1.0 + * @see SslStoreBundle + * @see SslBundle#getManagers() + */ +public interface SslManagerBundle { + + /** + * Return the {@code KeyManager} instances used to establish identity. + * @return the key managers + */ + default KeyManager[] getKeyManagers() { + return getKeyManagerFactory().getKeyManagers(); + } + + /** + * Return the {@code KeyManagerFactory} used to establish identity. + * @return the key manager factory + */ + KeyManagerFactory getKeyManagerFactory(); + + /** + * Return the {@link TrustManager} instances used to establish trust. + * @return the trust managers + */ + default TrustManager[] getTrustManagers() { + return getTrustManagerFactory().getTrustManagers(); + } + + /** + * Return the {@link TrustManagerFactory} used to establish trust. + * @return the trust manager factory + */ + TrustManagerFactory getTrustManagerFactory(); + + /** + * Factory method to create a new {@link SSLContext} for the {@link #getKeyManagers() + * key managers} and {@link #getTrustManagers() trust managers} managed by this + * instance. + * @param protocol the standard name of the SSL protocol. See + * {@link SSLContext#getInstance(String)} + * @return a new {@link SSLContext} instance + */ + default SSLContext createSslContext(String protocol) { + try { + SSLContext sslContext = SSLContext.getInstance(protocol); + sslContext.init(getKeyManagers(), getTrustManagers(), null); + return sslContext; + } + catch (Exception ex) { + throw new IllegalStateException("Could not load SSL context: " + ex.getMessage(), ex); + } + } + + /** + * Factory method to create a new {@link SslManagerBundle} instance. + * @param keyManagerFactory the key manager factory + * @param trustManagerFactory the trust manager factory + * @return a new {@link SslManagerBundle} instance + */ + static SslManagerBundle of(KeyManagerFactory keyManagerFactory, TrustManagerFactory trustManagerFactory) { + Assert.notNull(keyManagerFactory, "KeyManagerFactory must not be null"); + Assert.notNull(trustManagerFactory, "TrustManagerFactory must not be null"); + return new SslManagerBundle() { + + @Override + public KeyManagerFactory getKeyManagerFactory() { + return keyManagerFactory; + } + + @Override + public TrustManagerFactory getTrustManagerFactory() { + return trustManagerFactory; + } + + }; + } + + /** + * Factory method to create a new {@link SslManagerBundle} backed by the given + * {@link SslBundle} and {@link SslBundleKey}. + * @param storeBundle the SSL store bundle + * @param key the key reference + * @return a new {@link SslManagerBundle} instance + */ + static SslManagerBundle from(SslStoreBundle storeBundle, SslBundleKey key) { + return new DefaultSslManagerBundle(storeBundle, key); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslOptions.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslOptions.java new file mode 100644 index 0000000000..9ebc13f8d0 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslOptions.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import javax.net.ssl.SSLEngine; + +/** + * Configuration options that should be applied when establishing an SSL connection. + * + * @author Scott Frederick + * @since 3.1.0 + * @see SslBundle#getOptions() + */ +public interface SslOptions { + + /** + * {@link SslOptions} that returns no values. + */ + SslOptions NONE = of(Collections.emptySet(), Collections.emptySet()); + + /** + * Return the ciphers that can be used or an empty set. The cipher names in this set + * should be compatible with those supported by + * {@link SSLEngine#getSupportedCipherSuites()}. + * @return the ciphers that can be used + */ + Set getCiphers(); + + /** + * Return the protocols that should be enabled or an empty set. The protocols names in + * this set should be compatible with those supported by + * {@link SSLEngine#getSupportedProtocols()}. + * @return the protocols to enable + */ + Set getEnabledProtocols(); + + /** + * Factory method to create a new {@link SslOptions} instance. + * @param ciphers the ciphers + * @param enabledProtocols the enabled protocols + * @return a new {@link SslOptions} instance + */ + static SslOptions of(String[] ciphers, String[] enabledProtocols) { + return of(asSet(ciphers), asSet(enabledProtocols)); + } + + /** + * Factory method to create a new {@link SslOptions} instance. + * @param ciphers the ciphers + * @param enabledProtocols the enabled protocols + * @return a new {@link SslOptions} instance + */ + static SslOptions of(Set ciphers, Set enabledProtocols) { + return new SslOptions() { + + @Override + public Set getCiphers() { + return (ciphers != null) ? ciphers : Collections.emptySet(); + } + + @Override + public Set getEnabledProtocols() { + return (enabledProtocols != null) ? enabledProtocols : Collections.emptySet(); + } + + }; + + } + + private static Set asSet(String[] array) { + return (array != null) ? Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(array))) : null; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslStoreBundle.java new file mode 100644 index 0000000000..a5f5a0e7d8 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/SslStoreBundle.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; + +/** + * A bundle of key and trust stores that can be used to establish an SSL connection. + * + * @author Scott Frederick + * @since 3.1.0 + * @see SslBundle#getStores() + */ +public interface SslStoreBundle { + + /** + * {@link SslStoreBundle} that returns {@code null} for each method. + */ + SslStoreBundle NONE = of(null, null, null); + + /** + * Return a key store generated from the trust material or {@code null}. + * @return the key store + */ + KeyStore getKeyStore(); + + /** + * Return the password for the key in the key store or {@code null}. + * @return the key password + */ + String getKeyStorePassword(); + + /** + * Return a trust store generated from the trust material or {@code null}. + * @return the trust store + */ + KeyStore getTrustStore(); + + /** + * Factory method to create a new {@link SslStoreBundle} instance. + * @param keyStore the key store or {@code null} + * @param keyStorePassword the key store password or {@code null} + * @param trustStore the trust store or {@code null} + * @return a new {@link SslStoreBundle} instance + */ + static SslStoreBundle of(KeyStore keyStore, String keyStorePassword, KeyStore trustStore) { + return new SslStoreBundle() { + + @Override + public KeyStore getKeyStore() { + return keyStore; + } + + @Override + public KeyStore getTrustStore() { + return trustStore; + } + + @Override + public String getKeyStorePassword() { + return keyStorePassword; + } + + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java new file mode 100644 index 0000000000..8deb079a5c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreBundle.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.jks; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.cert.CertificateException; + +import org.springframework.boot.ssl.SslStoreBundle; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; + +/** + * {@link SslStoreBundle} backed by a Java keystore. + * + * @author Scott Frederick + * @author Phillip Webb + * @since 3.1.0 + */ +public class JksSslStoreBundle implements SslStoreBundle { + + private final JksSslStoreDetails keyStoreDetails; + + private final JksSslStoreDetails trustStoreDetails; + + /** + * Create a new {@link JksSslStoreBundle} instance. + * @param keyStoreDetails the key store details + * @param trustStoreDetails the trust store details + */ + public JksSslStoreBundle(JksSslStoreDetails keyStoreDetails, JksSslStoreDetails trustStoreDetails) { + this.keyStoreDetails = keyStoreDetails; + this.trustStoreDetails = trustStoreDetails; + } + + @Override + public KeyStore getKeyStore() { + return createKeyStore("key", this.keyStoreDetails); + } + + @Override + public String getKeyStorePassword() { + return (this.keyStoreDetails != null) ? this.keyStoreDetails.password() : null; + } + + @Override + public KeyStore getTrustStore() { + return createKeyStore("trust", this.trustStoreDetails); + } + + private KeyStore createKeyStore(String name, JksSslStoreDetails details) { + if (details == null || details.isEmpty()) { + return null; + } + try { + String type = (!StringUtils.hasText(details.type())) ? KeyStore.getDefaultType() : details.type(); + char[] password = (details.password() != null) ? details.password().toCharArray() : null; + String location = details.location(); + KeyStore store = getKeyStoreInstance(type, details.provider()); + if (isHardwareKeystoreType(type)) { + loadHardwareKeyStore(store, location, password); + } + else { + loadKeyStore(store, location, password); + } + return store; + } + catch (Exception ex) { + throw new IllegalStateException("Unable to create %s store: %s".formatted(name, ex.getMessage()), ex); + } + } + + private KeyStore getKeyStoreInstance(String type, String provider) + throws KeyStoreException, NoSuchProviderException { + return (!StringUtils.hasText(provider)) ? KeyStore.getInstance(type) : KeyStore.getInstance(type, provider); + } + + private boolean isHardwareKeystoreType(String type) { + return type.equalsIgnoreCase("PKCS11"); + } + + private void loadHardwareKeyStore(KeyStore store, String location, char[] password) + throws IOException, NoSuchAlgorithmException, CertificateException { + Assert.state(!StringUtils.hasText(location), + () -> "Location is '%s', but must be empty or null for PKCS11 hardware key stores".formatted(location)); + store.load(null, password); + } + + private void loadKeyStore(KeyStore store, String location, char[] password) { + Assert.state(StringUtils.hasText(location), () -> "Location must not be empty or null"); + try { + URL url = ResourceUtils.getURL(location); + try (InputStream stream = url.openStream()) { + store.load(stream, password); + } + } + catch (Exception ex) { + throw new IllegalStateException("Could not load store from '" + location + "'", ex); + } + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreDetails.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreDetails.java new file mode 100644 index 0000000000..f2679e56ae --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/JksSslStoreDetails.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.jks; + +import java.security.KeyStore; + +import org.springframework.util.StringUtils; + +/** + * Details for an individual trust or key store in a {@link JksSslStoreBundle}. + * + * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A + * {@code null} value will use {@link KeyStore#getDefaultType()}). + * @param provider the name of the key store provider + * @param location the location of the key store file or {@code null} if using a + * {@code PKCS11} hardware store + * @param password the password used to unlock the store or {@code null} + * @author Scott Frederick + * @author Phillip Webb + * @since 3.1.0 + */ +public record JksSslStoreDetails(String type, String provider, String location, String password) { + + /** + * Return a new {@link JksSslStoreDetails} instance with a new password. + * @param password the new password + * @return a new {@link JksSslStoreDetails} instance + */ + public JksSslStoreDetails withPassword(String password) { + return new JksSslStoreDetails(this.type, this.provider, this.location, password); + } + + boolean isEmpty() { + return isEmpty(this.type) && isEmpty(this.provider) && isEmpty(this.location); + } + + private boolean isEmpty(String value) { + return !StringUtils.hasText(value); + } + + /** + * Factory method to create a new {@link JksSslStoreDetails} instance for the given + * location. + * @param location the location + * @return a new {@link JksSslStoreDetails} instance. + */ + public static JksSslStoreDetails forLocation(String location) { + return new JksSslStoreDetails(null, null, location, null); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/package-info.java new file mode 100644 index 0000000000..d50e7e85c0 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/jks/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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. + */ + +/** + * SSL trust material provider for Java KeyStores. + */ +package org.springframework.boot.ssl.jks; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/package-info.java new file mode 100644 index 0000000000..21eaefbfd2 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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. + */ + +/** + * Management of trust material that can be used to establish an SSL connection. + */ +package org.springframework.boot.ssl; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java new file mode 100644 index 0000000000..327dcc94a1 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for X.509 certificates in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemCertificateParser { + + private static final String HEADER = "-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + + private static final String FOOTER = "-+END\\s+.*CERTIFICATE[^-]*-+"; + + private static final Pattern PATTERN = Pattern.compile(HEADER + BASE64_TEXT + FOOTER, Pattern.CASE_INSENSITIVE); + + private PemCertificateParser() { + } + + /** + * Parse certificates from the specified string. + * @param certificates the certificates to parse + * @return the parsed certificates + */ + static X509Certificate[] parse(String certificates) { + if (certificates == null) { + return null; + } + CertificateFactory factory = getCertificateFactory(); + List certs = new ArrayList<>(); + readCertificates(certificates, factory, certs::add); + return (!certs.isEmpty()) ? certs.toArray(X509Certificate[]::new) : null; + } + + private static CertificateFactory getCertificateFactory() { + try { + return CertificateFactory.getInstance("X.509"); + } + catch (CertificateException ex) { + throw new IllegalStateException("Unable to get X.509 certificate factory", ex); + } + } + + private static void readCertificates(String text, CertificateFactory factory, Consumer consumer) { + try { + Matcher matcher = PATTERN.matcher(text); + while (matcher.find()) { + String encodedText = matcher.group(1); + byte[] decodedBytes = decodeBase64(encodedText); + ByteArrayInputStream inputStream = new ByteArrayInputStream(decodedBytes); + while (inputStream.available() > 0) { + consumer.accept((X509Certificate) factory.generateCertificate(inputStream)); + } + } + } + catch (CertificateException ex) { + throw new IllegalStateException("Error reading certificate: " + ex.getMessage(), ex); + } + } + + private static byte[] decodeBase64(String content) { + byte[] bytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64.getDecoder().decode(bytes); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java new file mode 100644 index 0000000000..3178285750 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Utility to load PEM content. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemContent { + + private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + + private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + + private PemContent() { + } + + static String load(String content) { + if (content == null || isPemContent(content)) { + return content; + } + try { + URL url = ResourceUtils.getURL(content); + try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } + } + catch (IOException ex) { + throw new IllegalStateException( + "Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex); + } + } + + private static boolean isPemContent(String content) { + return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java new file mode 100644 index 0000000000..b1129f06b3 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java @@ -0,0 +1,245 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for PKCS private key files in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemPrivateKeyParser { + + private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS8_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + + private static final List PEM_PARSERS; + static { + List parsers = new ArrayList<>(); + parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, "RSA", PemPrivateKeyParser::createKeySpecForPkcs1)); + parsers.add(new PemParser(EC_HEADER, EC_FOOTER, "EC", PemPrivateKeyParser::createKeySpecForEc)); + parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, "RSA", PKCS8EncodedKeySpec::new)); + PEM_PARSERS = Collections.unmodifiableList(parsers); + } + + /** + * ASN.1 encoded object identifier {@literal 1.2.840.113549.1.1.1}. + */ + private static final int[] RSA_ALGORITHM = { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01 }; + + /** + * ASN.1 encoded object identifier {@literal 1.2.840.10045.2.1}. + */ + private static final int[] EC_ALGORITHM = { 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01 }; + + /** + * ASN.1 encoded object identifier {@literal 1.3.132.0.34}. + */ + private static final int[] EC_PARAMETERS = { 0x2b, 0x81, 0x04, 0x00, 0x22 }; + + private PemPrivateKeyParser() { + } + + private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) { + return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null); + } + + private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) { + return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, EC_PARAMETERS); + } + + private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[] algorithm, int[] parameters) { + try { + DerEncoder encoder = new DerEncoder(); + encoder.integer(0x00); // Version 0 + DerEncoder algorithmIdentifier = new DerEncoder(); + algorithmIdentifier.objectIdentifier(algorithm); + algorithmIdentifier.objectIdentifier(parameters); + byte[] byteArray = algorithmIdentifier.toByteArray(); + encoder.sequence(byteArray); + encoder.octetString(bytes); + return new PKCS8EncodedKeySpec(encoder.toSequence()); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Parse a private key from the specified string. + * @param key the private key to parse + * @return the parsed private key + */ + static PrivateKey parse(String key) { + if (key == null) { + return null; + } + try { + for (PemParser pemParser : PEM_PARSERS) { + PrivateKey privateKey = pemParser.parse(key); + if (privateKey != null) { + return privateKey; + } + } + throw new IllegalStateException("Unrecognized private key format"); + } + catch (Exception ex) { + throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex); + } + } + + /** + * Parser for a specific PEM format. + */ + private static class PemParser { + + private final Pattern pattern; + + private final String algorithm; + + private final Function keySpecFactory; + + PemParser(String header, String footer, String algorithm, + Function keySpecFactory) { + this.pattern = Pattern.compile(header + BASE64_TEXT + footer, Pattern.CASE_INSENSITIVE); + this.algorithm = algorithm; + this.keySpecFactory = keySpecFactory; + } + + PrivateKey parse(String text) { + Matcher matcher = this.pattern.matcher(text); + return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(1))); + } + + private static byte[] decodeBase64(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64.getDecoder().decode(contentBytes); + } + + private PrivateKey parse(byte[] bytes) { + try { + PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes); + KeyFactory keyFactory = KeyFactory.getInstance(this.algorithm); + return keyFactory.generatePrivate(keySpec); + } + catch (GeneralSecurityException ex) { + throw new IllegalArgumentException("Unexpected key format", ex); + } + } + + } + + /** + * Simple ASN.1 DER encoder. + */ + static class DerEncoder { + + private final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + void objectIdentifier(int... encodedObjectIdentifier) throws IOException { + int code = (encodedObjectIdentifier != null) ? 0x06 : 0x05; + codeLengthBytes(code, bytes(encodedObjectIdentifier)); + } + + void integer(int... encodedInteger) throws IOException { + codeLengthBytes(0x02, bytes(encodedInteger)); + } + + void octetString(byte[] bytes) throws IOException { + codeLengthBytes(0x04, bytes); + } + + void sequence(int... elements) throws IOException { + sequence(bytes(elements)); + } + + void sequence(byte[] bytes) throws IOException { + codeLengthBytes(0x30, bytes); + } + + void codeLengthBytes(int code, byte[] bytes) throws IOException { + this.stream.write(code); + int length = (bytes != null) ? bytes.length : 0; + if (length <= 127) { + this.stream.write(length & 0xFF); + } + else { + ByteArrayOutputStream lengthStream = new ByteArrayOutputStream(); + while (length != 0) { + lengthStream.write(length & 0xFF); + length = length >> 8; + } + byte[] lengthBytes = lengthStream.toByteArray(); + this.stream.write(0x80 | lengthBytes.length); + for (int i = lengthBytes.length - 1; i >= 0; i--) { + this.stream.write(lengthBytes[i]); + } + } + if (bytes != null) { + this.stream.write(bytes); + } + } + + private static byte[] bytes(int... elements) { + if (elements == null) { + return null; + } + byte[] result = new byte[elements.length]; + for (int i = 0; i < elements.length; i++) { + result[i] = (byte) elements[i]; + } + return result; + } + + byte[] toSequence() throws IOException { + DerEncoder sequenceEncoder = new DerEncoder(); + sequenceEncoder.sequence(toByteArray()); + return sequenceEncoder.toByteArray(); + } + + byte[] toByteArray() { + return this.stream.toByteArray(); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java new file mode 100644 index 0000000000..dc857bf66f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +import org.springframework.boot.ssl.SslStoreBundle; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link SslStoreBundle} backed by PEM-encoded certificates and private keys. + * + * @author Scott Frederick + * @author Phillip Webb + * @since 3.1.0 + */ +public class PemSslStoreBundle implements SslStoreBundle { + + private static final String DEFAULT_KEY_ALIAS = "ssl"; + + private final PemSslStoreDetails keyStoreDetails; + + private final PemSslStoreDetails trustStoreDetails; + + private final String keyAlias; + + /** + * Create a new {@link PemSslStoreBundle} instance. + * @param keyStoreDetails the key store details + * @param trustStoreDetails the trust store details + */ + public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails) { + this(keyStoreDetails, trustStoreDetails, null); + } + + /** + * Create a new {@link PemSslStoreBundle} instance. + * @param keyStoreDetails the key store details + * @param trustStoreDetails the trust store details + * @param keyAlias the key alias to use or {@code null} to use a default alias + */ + public PemSslStoreBundle(PemSslStoreDetails keyStoreDetails, PemSslStoreDetails trustStoreDetails, + String keyAlias) { + this.keyAlias = keyAlias; + this.keyStoreDetails = keyStoreDetails; + this.trustStoreDetails = trustStoreDetails; + } + + @Override + public KeyStore getKeyStore() { + return createKeyStore("key", this.keyStoreDetails); + } + + @Override + public String getKeyStorePassword() { + return null; + } + + @Override + public KeyStore getTrustStore() { + return createKeyStore("trust", this.trustStoreDetails); + } + + private KeyStore createKeyStore(String name, PemSslStoreDetails details) { + if (details == null || details.isEmpty()) { + return null; + } + try { + Assert.notNull(details.certificate(), "CertificateContent must not be null"); + String type = (!StringUtils.hasText(details.type())) ? KeyStore.getDefaultType() : details.type(); + KeyStore store = KeyStore.getInstance(type); + store.load(null); + String certificateContent = PemContent.load(details.certificate()); + String privateKeyContent = PemContent.load(details.privateKey()); + X509Certificate[] certificates = PemCertificateParser.parse(certificateContent); + PrivateKey privateKey = PemPrivateKeyParser.parse(privateKeyContent); + addCertificates(store, certificates, privateKey); + return store; + } + catch (Exception ex) { + throw new IllegalStateException("Unable to create %s store: %s".formatted(name, ex.getMessage()), ex); + } + } + + private void addCertificates(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey) + throws KeyStoreException { + String alias = (this.keyAlias != null) ? this.keyAlias : DEFAULT_KEY_ALIAS; + if (privateKey != null) { + keyStore.setKeyEntry(alias, privateKey, null, certificates); + } + else { + for (int index = 0; index < certificates.length; index++) { + keyStore.setCertificateEntry(alias + "-" + index, certificates[index]); + } + } + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java new file mode 100644 index 0000000000..978d14a1b4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.security.KeyStore; + +import org.springframework.util.ResourceUtils; +import org.springframework.util.StringUtils; + +/** + * Details for an individual trust or key store in a {@link PemSslStoreBundle}. + * + * @param type the key store type, for example {@code JKS} or {@code PKCS11}. A + * {@code null} value will use {@link KeyStore#getDefaultType()}). + * @param certificate the certificate content (either the PEM content itself or something + * that can be loaded by {@link ResourceUtils#getURL}) + * @param privateKey the private key content (either the PEM content itself or something + * that can be loaded by {@link ResourceUtils#getURL}) + * @author Scott Frederick + * @author Phillip Webb + * @since 3.1.0 + */ +public record PemSslStoreDetails(String type, String certificate, String privateKey) { + + /** + * Return a new {@link PemSslStoreDetails} instance with a new private key. + * @param privateKey the new private key + * @return a new {@link PemSslStoreDetails} instance + */ + public PemSslStoreDetails withPrivateKey(String privateKey) { + return new PemSslStoreDetails(this.type, this.certificate, privateKey); + } + + boolean isEmpty() { + return isEmpty(this.type) && isEmpty(this.certificate) && isEmpty(this.privateKey); + } + + private boolean isEmpty(String value) { + return !StringUtils.hasText(value); + } + + /** + * Factory method to create a new {@link PemSslStoreDetails} instance for the given + * certificate. + * @param certificate the certificate + * @return a new {@link PemSslStoreDetails} instance. + */ + public static PemSslStoreDetails forCertificate(String certificate) { + return new PemSslStoreDetails(null, certificate, null); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/package-info.java new file mode 100644 index 0000000000..50e912f18a --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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. + */ + +/** + * SSL trust material provider for PEM-encoded certificates. + */ +package org.springframework.boot.ssl.pem; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/AliasKeyManagerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/AliasKeyManagerFactoryTests.java new file mode 100644 index 0000000000..f858ae3245 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/AliasKeyManagerFactoryTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.util.Arrays; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AliasKeyManagerFactory}. + * + * @author Phillip Webb + */ +class AliasKeyManagerFactoryTests { + + @Test + void chooseEngineServerAliasReturnsAlias() throws Exception { + KeyManagerFactory delegate = mock(KeyManagerFactory.class); + given(delegate.getKeyManagers()).willReturn(new KeyManager[] { mock(X509ExtendedKeyManager.class) }); + AliasKeyManagerFactory factory = new AliasKeyManagerFactory(delegate, "test-alias", + KeyManagerFactory.getDefaultAlgorithm()); + factory.init(null, null); + KeyManager[] keyManagers = factory.getKeyManagers(); + X509ExtendedKeyManager x509KeyManager = (X509ExtendedKeyManager) Arrays.stream(keyManagers) + .filter(X509ExtendedKeyManager.class::isInstance) + .findAny() + .get(); + String chosenAlias = x509KeyManager.chooseEngineServerAlias(null, null, null); + assertThat(chosenAlias).isEqualTo("test-alias"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java new file mode 100644 index 0000000000..d8cf034eef --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslBundleRegistryTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DefaultSslBundleRegistry}. + * + * @author Phillip Webb + */ +class DefaultSslBundleRegistryTests { + + private SslBundle bundle1 = mock(SslBundle.class); + + private SslBundle bundle2 = mock(SslBundle.class); + + private DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry(); + + @Test + void createWithNameAndBundleRegistersBundle() { + DefaultSslBundleRegistry registry = new DefaultSslBundleRegistry("test", this.bundle1); + assertThat(registry.getBundle("test")).isSameAs(this.bundle1); + } + + @Test + void registerBundleWhenNameIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.registry.registerBundle(null, this.bundle1)) + .withMessage("Name must not be null"); + } + + @Test + void registerBundleWhenBundleIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.registry.registerBundle("test", null)) + .withMessage("Bundle must not be null"); + } + + @Test + void registerBundleWhenNameIsTakenThrowsException() { + this.registry.registerBundle("test", this.bundle1); + assertThatIllegalStateException().isThrownBy(() -> this.registry.registerBundle("test", this.bundle2)) + .withMessage("Cannot replace existing SSL bundle 'test'"); + } + + @Test + void registerBundleRegistersBundle() { + this.registry.registerBundle("test", this.bundle1); + assertThat(this.registry.getBundle("test")).isSameAs(this.bundle1); + } + + @Test + void getBundleWhenNameIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.registry.getBundle(null)) + .withMessage("Name must not be null"); + } + + @Test + void getBundleWhenNoSuchBundleThrowsException() { + this.registry.registerBundle("test", this.bundle1); + assertThatExceptionOfType(NoSuchSslBundleException.class).isThrownBy(() -> this.registry.getBundle("missing")) + .satisfies((ex) -> assertThat(ex.getBundleName()).isEqualTo("missing")); + } + + @Test + void getBundleReturnsBundle() { + this.registry.registerBundle("test1", this.bundle1); + this.registry.registerBundle("test2", this.bundle2); + assertThat(this.registry.getBundle("test1")).isSameAs(this.bundle1); + assertThat(this.registry.getBundle("test2")).isSameAs(this.bundle2); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslManagerBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslManagerBundleTests.java new file mode 100644 index 0000000000..ffe6986b3b --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/DefaultSslManagerBundleTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DefaultSslManagerBundle}. + * + * @author Phillip Webb + */ +class DefaultSslManagerBundleTests { + + private KeyManagerFactory keyManagerFactory = mock(KeyManagerFactory.class); + + private TrustManagerFactory trustManagerFactory = mock(TrustManagerFactory.class); + + @Test + void getKeyManagerFactoryWhenStoreBundleIsNull() throws Exception { + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(null, SslBundleKey.NONE); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isNotNull(); + then(this.keyManagerFactory).should().init(null, null); + } + + @Test + void getKeyManagerFactoryWhenKeyIsNull() throws Exception { + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(SslStoreBundle.NONE, null); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isSameAs(this.keyManagerFactory); + then(this.keyManagerFactory).should().init(null, null); + } + + @Test + void getKeyManagerFactoryWhenHasKeyAliasReturnsWrapped() { + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(null, SslBundleKey.of("secret", "alias")); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isInstanceOf(AliasKeyManagerFactory.class); + } + + @Test + void getKeyManagerFactoryWhenHasKeyPassword() throws Exception { + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(null, SslBundleKey.of("secret")); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isSameAs(this.keyManagerFactory); + then(this.keyManagerFactory).should().init(null, "secret".toCharArray()); + } + + @Test + void getKeyManagerFactoryWhenHasKeyStorePassword() throws Exception { + SslStoreBundle storeBundle = SslStoreBundle.of(null, "secret", null); + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(storeBundle, null); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isSameAs(this.keyManagerFactory); + then(this.keyManagerFactory).should().init(null, "secret".toCharArray()); + } + + @Test + void getKeyManagerFactoryWhenHasAliasNotInStoreThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(false); + SslStoreBundle storeBundle = SslStoreBundle.of(keyStore, null, null); + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(storeBundle, + SslBundleKey.of("secret", "alias")); + assertThatIllegalStateException().isThrownBy(() -> bundle.getKeyManagerFactory()) + .withMessage("Keystore does not contain alias 'alias'"); + } + + @Test + void getKeyManagerFactoryWhenHasAliasNotDeterminedInStoreThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willThrow(KeyStoreException.class); + SslStoreBundle storeBundle = SslStoreBundle.of(keyStore, null, null); + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(storeBundle, + SslBundleKey.of("secret", "alias")); + assertThatIllegalStateException().isThrownBy(() -> bundle.getKeyManagerFactory()) + .withMessage("Could not determine if keystore contains alias 'alias'"); + } + + @Test + void getKeyManagerFactoryWhenHasStore() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + SslStoreBundle storeBundle = SslStoreBundle.of(keyStore, null, null); + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(storeBundle, null); + KeyManagerFactory result = bundle.getKeyManagerFactory(); + assertThat(result).isSameAs(this.keyManagerFactory); + then(this.keyManagerFactory).should().init(keyStore, null); + } + + @Test + void getTrustManagerFactoryWhenStoreBundleIsNull() throws Exception { + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(null, null); + TrustManagerFactory result = bundle.getTrustManagerFactory(); + assertThat(result).isSameAs(this.trustManagerFactory); + then(this.trustManagerFactory).should().init((KeyStore) null); + } + + @Test + void getTrustManagerFactoryWhenHasStore() throws Exception { + KeyStore trustStore = mock(KeyStore.class); + SslStoreBundle storeBundle = SslStoreBundle.of(null, null, trustStore); + DefaultSslManagerBundle bundle = new TestDefaultSslManagerBundle(storeBundle, null); + TrustManagerFactory result = bundle.getTrustManagerFactory(); + assertThat(result).isSameAs(this.trustManagerFactory); + then(this.trustManagerFactory).should().init(trustStore); + } + + /** + * Test version of {@link DefaultSslManagerBundle}. + */ + class TestDefaultSslManagerBundle extends DefaultSslManagerBundle { + + TestDefaultSslManagerBundle(SslStoreBundle storeBundle, SslBundleKey key) { + super(storeBundle, key); + } + + @Override + protected KeyManagerFactory getKeyManagerFactoryInstance(String algorithm) throws NoSuchAlgorithmException { + return DefaultSslManagerBundleTests.this.keyManagerFactory; + } + + @Override + protected TrustManagerFactory getTrustManagerFactoryInstance(String algorithm) throws NoSuchAlgorithmException { + return DefaultSslManagerBundleTests.this.trustManagerFactory; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/NoSuchSslBundleExceptionTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/NoSuchSslBundleExceptionTests.java new file mode 100644 index 0000000000..d8b5dbb2d5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/NoSuchSslBundleExceptionTests.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link NoSuchSslBundleException}. + * + * @author Phillip Webb + */ +class NoSuchSslBundleExceptionTests { + + @Test + void createCreatesException() { + Throwable cause = new RuntimeException(); + NoSuchSslBundleException exception = new NoSuchSslBundleException("name", "badness", cause); + assertThat(exception).hasMessage("badness").hasCause(cause); + assertThat(exception.getBundleName()).isEqualTo("name"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java new file mode 100644 index 0000000000..db3d3a42d5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; +import java.security.KeyStoreException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslBundleKey}. + * + * @author Phillip Webb + */ +class SslBundleKeyTests { + + @Test + void noneHasNoValues() { + SslBundleKey keyReference = SslBundleKey.NONE; + assertThat(keyReference.getPassword()).isNull(); + assertThat(keyReference.getAlias()).isNull(); + } + + @Test + void ofCreatesWithPasswordSslKeyReference() { + SslBundleKey keyReference = SslBundleKey.of("password"); + assertThat(keyReference.getPassword()).isEqualTo("password"); + assertThat(keyReference.getAlias()).isNull(); + } + + @Test + void ofCreatesWithPasswordAndAliasSslKeyReference() { + SslBundleKey keyReference = SslBundleKey.of("password", "alias"); + assertThat(keyReference.getPassword()).isEqualTo("password"); + assertThat(keyReference.getAlias()).isEqualTo("alias"); + } + + @Test + void getKeyManagerFactoryWhenHasAliasNotInStoreThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(false); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Keystore does not contain alias 'alias'"); + } + + @Test + void getKeyManagerFactoryWhenHasAliasNotDeterminedInStoreThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willThrow(KeyStoreException.class); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Could not determine if keystore contains alias 'alias'"); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleTests.java new file mode 100644 index 0000000000..090d0cc5bc --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslBundle}. + * + * @author Phillip Webb + */ +class SslBundleTests { + + @Test + void createSslContextDelegatesToManagers() { + SslManagerBundle managers = mock(SslManagerBundle.class); + SslBundle bundle = SslBundle.of(null, null, null, "testprotocol", managers); + bundle.createSslContext(); + then(managers).should().createSslContext("testprotocol"); + } + + @Test + void ofCreatesSslBundle() { + SslStoreBundle stores = mock(SslStoreBundle.class); + SslBundleKey key = mock(SslBundleKey.class); + SslOptions options = mock(SslOptions.class); + String protocol = "test"; + SslManagerBundle managers = mock(SslManagerBundle.class); + SslBundle bundle = SslBundle.of(stores, key, options, protocol, managers); + assertThat(bundle.getStores()).isSameAs(stores); + assertThat(bundle.getKey()).isSameAs(key); + assertThat(bundle.getOptions()).isSameAs(options); + assertThat(bundle.getProtocol()).isSameAs(protocol); + assertThat(bundle.getManagers()).isSameAs(managers); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslManagerBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslManagerBundleTests.java new file mode 100644 index 0000000000..b58eb2e64e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslManagerBundleTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslManagerBundle}. + * + * @author Phillip Webb + */ +class SslManagerBundleTests { + + private KeyManagerFactory keyManagerFactory = mock(KeyManagerFactory.class); + + private TrustManagerFactory trustManagerFactory = mock(TrustManagerFactory.class); + + @Test + void getKeyManagersDelegatesToFactory() { + SslManagerBundle bundle = SslManagerBundle.of(this.keyManagerFactory, this.trustManagerFactory); + bundle.getKeyManagers(); + then(this.keyManagerFactory).should().getKeyManagers(); + } + + @Test + void getTrustManagersDelegatesToFactory() { + SslManagerBundle bundle = SslManagerBundle.of(this.keyManagerFactory, this.trustManagerFactory); + bundle.getTrustManagers(); + then(this.trustManagerFactory).should().getTrustManagers(); + } + + @Test + void createSslContextCreatesInitializedSslContext() { + SslManagerBundle bundle = SslManagerBundle.of(this.keyManagerFactory, this.trustManagerFactory); + SSLContext sslContext = bundle.createSslContext("TLS"); + assertThat(sslContext).isNotNull(); + assertThat(sslContext.getProtocol()).isEqualTo("TLS"); + } + + @Test + void ofWhenKeyManagerFactoryIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> SslManagerBundle.of(null, this.trustManagerFactory)) + .withMessage("KeyManagerFactory must not be null"); + } + + @Test + void ofWhenTrustManagerFactoryIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> SslManagerBundle.of(this.keyManagerFactory, null)) + .withMessage("TrustManagerFactory must not be null"); + } + + @Test + void ofCreatesSslManagerBundle() { + SslManagerBundle bundle = SslManagerBundle.of(this.keyManagerFactory, this.trustManagerFactory); + assertThat(bundle.getKeyManagerFactory()).isSameAs(this.keyManagerFactory); + assertThat(bundle.getTrustManagerFactory()).isSameAs(this.trustManagerFactory); + } + + @Test + void fromCreatesDefaultSslManagerBundle() { + SslManagerBundle bundle = SslManagerBundle.from(SslStoreBundle.NONE, SslBundleKey.NONE); + assertThat(bundle).isInstanceOf(DefaultSslManagerBundle.class); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslOptionsTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslOptionsTests.java new file mode 100644 index 0000000000..018ab2ecc4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslOptionsTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SslOptions}. + * + * @author Phillip Webb + */ +class SslOptionsTests { + + @Test + void noneReturnsEmptyCollections() { + SslOptions options = SslOptions.NONE; + assertThat(options.getCiphers()).isEmpty(); + assertThat(options.getEnabledProtocols()).isEmpty(); + } + + @Test + void ofWithArrayCreatesSslOptions() { + String[] ciphers = { "a", "b", "c" }; + String[] enabledProtocols = { "d", "e", "f" }; + SslOptions options = SslOptions.of(ciphers, enabledProtocols); + assertThat(options.getCiphers()).containsExactly(ciphers); + assertThat(options.getEnabledProtocols()).containsExactly(enabledProtocols); + } + + @Test + void ofWithNullArraysCreatesSslOptions() { + String[] ciphers = null; + String[] enabledProtocols = null; + SslOptions options = SslOptions.of(ciphers, enabledProtocols); + assertThat(options.getCiphers()).isEmpty(); + assertThat(options.getEnabledProtocols()).isEmpty(); + } + + @Test + void ofWithSetCreatesSslOptions() { + Set ciphers = Set.of("a", "b", "c"); + Set enabledProtocols = Set.of("d", "e", "f"); + SslOptions options = SslOptions.of(ciphers, enabledProtocols); + assertThat(options.getCiphers()).isEqualTo(ciphers); + assertThat(options.getEnabledProtocols()).isEqualTo(enabledProtocols); + } + + @Test + void ofWithNullSetCreatesSslOptions() { + Set ciphers = null; + Set enabledProtocols = null; + SslOptions options = SslOptions.of(ciphers, enabledProtocols); + assertThat(options.getCiphers()).isEmpty(); + assertThat(options.getEnabledProtocols()).isEmpty(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslStoreBundleTests.java new file mode 100644 index 0000000000..76b99cf73d --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/SslStoreBundleTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl; + +import java.security.KeyStore; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link SslStoreBundle} + * + * @author Phillip Webb + */ +class SslStoreBundleTests { + + @Test + void noneReturnsEmptySslStoreBundle() { + SslStoreBundle bundle = SslStoreBundle.NONE; + assertThat(bundle.getKeyStore()).isNull(); + assertThat(bundle.getKeyStorePassword()).isNull(); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void ofCreatesStoreBundle() { + KeyStore keyStore = mock(KeyStore.class); + String keyStorePassword = "secret"; + KeyStore trustStore = mock(KeyStore.class); + SslStoreBundle bundle = SslStoreBundle.of(keyStore, keyStorePassword, trustStore); + assertThat(bundle.getKeyStore()).isSameAs(keyStore); + assertThat(bundle.getKeyStorePassword()).isEqualTo(keyStorePassword); + assertThat(bundle.getTrustStore()).isSameAs(trustStore); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java new file mode 100644 index 0000000000..87b0f972e2 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/jks/JksSslStoreBundleTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.jks; + +import java.security.KeyStore; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.web.embedded.test.MockPkcs11Security; +import org.springframework.util.function.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link JksSslStoreBundle}. + * + * @author Scott Frederick + * @author Phillip Webb + */ +@MockPkcs11Security +class JksSslStoreBundleTests { + + @Test + void whenNullStores() { + JksSslStoreDetails keyStoreDetails = null; + JksSslStoreDetails trustStoreDetails = null; + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).isNull(); + assertThat(bundle.getKeyStorePassword()).isNull(); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void whenStoresHaveNoValues() { + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation(null); + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation(null); + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).isNull(); + assertThat(bundle.getKeyStorePassword()).isNull(); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void whenTypePKCS11AndLocationThrowsException() { + JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("PKCS11", null, "test.jks", null); + JksSslStoreDetails trustStoreDetails = null; + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThatIllegalStateException().isThrownBy(bundle::getKeyStore) + .withMessageContaining( + "Unable to create key store: Location is 'test.jks', but must be empty or null for PKCS11 hardware key stores"); + } + + @Test + void whenHasKeyStoreLocation() { + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation("classpath:test.jks") + .withPassword("secret"); + JksSslStoreDetails trustStoreDetails = null; + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("test-alias", "password")); + } + + @Test + void getTrustStoreWithLocations() { + JksSslStoreDetails keyStoreDetails = null; + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation("classpath:test.jks") + .withPassword("secret"); + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("test-alias", "password")); + } + + @Test + void whenHasKeyStoreType() { + JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("jks", null, "classpath:test.jks", "secret"); + JksSslStoreDetails trustStoreDetails = null; + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("jks", "test-alias", "password")); + } + + @Test + void whenHasTrustStoreType() { + JksSslStoreDetails keyStoreDetails = null; + JksSslStoreDetails trustStoreDetails = new JksSslStoreDetails("jks", null, "classpath:test.jks", "secret"); + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("jks", "test-alias", "password")); + } + + @Test + void whenHasKeyStoreProvider() { + JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider", + "classpath:test.jks", "secret"); + JksSslStoreDetails trustStoreDetails = null; + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThatIllegalStateException().isThrownBy(bundle::getKeyStore) + .withMessageContaining("com.example.KeyStoreProvider"); + } + + @Test + void whenHasTrustStoreProvider() { + JksSslStoreDetails keyStoreDetails = null; + JksSslStoreDetails trustStoreDetails = new JksSslStoreDetails(null, "com.example.KeyStoreProvider", + "classpath:test.jks", "secret"); + JksSslStoreBundle bundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThatIllegalStateException().isThrownBy(bundle::getTrustStore) + .withMessageContaining("com.example.KeyStoreProvider"); + } + + private Consumer storeContainingCertAndKey(String keyAlias, String keyPassword) { + return storeContainingCertAndKey(KeyStore.getDefaultType(), keyAlias, keyPassword); + } + + private Consumer storeContainingCertAndKey(String keyStoreType, String keyAlias, String keyPassword) { + return ThrowingConsumer.of((keyStore) -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo(keyStoreType); + assertThat(keyStore.containsAlias(keyAlias)).isTrue(); + assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); + assertThat(keyStore.getKey(keyAlias, keyPassword.toCharArray())).isNotNull(); + }); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java new file mode 100644 index 0000000000..20ceee1b9a --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemCertificateParserTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemCertificateParser}. + * + * @author Scott Frederick + */ +class PemCertificateParserTests { + + @Test + void parseCertificate() throws Exception { + X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert.pem")); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(1); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + } + + @Test + void parseCertificateChain() throws Exception { + X509Certificate[] certificates = PemCertificateParser.parse(read("test-cert-chain.pem")); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(2); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates[1].getType()).isEqualTo("X.509"); + } + + private String read(String path) throws IOException { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java new file mode 100644 index 0000000000..649d66f699 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemContent}. + * + * @author Phillip Webb + */ +class PemContentTests { + + @Test + void loadWhenContentIsNullReturnsNull() { + assertThat(PemContent.load(null)).isNull(); + } + + @Test + void loadWhenContentIsPemContentReturnsContent() { + String content = """ + -----BEGIN CERTIFICATE----- + MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls + b2NhbGhvc3QwHhcNMTQwOTEwMjE0MzA1WhcNMTQxMDEwMjE0MzA1WjAUMRIwEAYD + VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDR + 0KfxUw7MF/8RB5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQL + gqrRgAjl3VmCC9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJ + uEfnp07cTfYZFqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0Qa + zHQoM5s00Fer6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFX + yVuEF3HeyVPug8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0S + dJ1N7aJnXpeSQjAgf03jAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAE4yvwhbPldg + Bpl7sBw/m2B3bfiNeSqa4tII1PQ7ysgWVb9HbFNKkriScwDWlqo6ljZfJ+SDFCoj + bQz4fOFdMAOzRnpTrG2NAKMoJLY0/g/p7XO00PiC8T3h3BOJ5SHuW3gUyfGXmAYs + DnJxJOrwPzj57xvNXjNSbDOJ3DRfCbB0CWBexOeGDiUokoEq3Gnz04Q4ZfHyAcpZ + 3deMw8Od5p9WAoCh3oClpFyOSzXYKZd+3ppMMtfc4wnbfocnfSFxj0UCpOEJw4Ez + +lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO + 32C9XWHwRA4= + -----END CERTIFICATE-----"""; + assertThat(PemContent.load(content)).isEqualTo(content); + } + + @Test + void loadWhenClasspathLocationReturnsContent() throws IOException { + String actual = PemContent.load("classpath:test-cert.pem"); + String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); + assertThat(actual).isEqualTo(expected); + } + + @Test + void loadWhenFileLocationReturnsContent() throws IOException { + String actual = PemContent.load("src/test/resources/test-cert.pem"); + String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); + assertThat(actual).isEqualTo(expected); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java new file mode 100644 index 0000000000..e38c0b47f8 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemPrivateKeyParserTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.PrivateKey; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link PemPrivateKeyParser}. + * + * @author Scott Frederick + */ +class PemPrivateKeyParserTests { + + @Test + void parsePkcs8KeyFile() throws Exception { + PrivateKey privateKey = PemPrivateKeyParser.parse(read("test-key.pem")); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("RSA"); + } + + @Test + void parsePkcs8KeyFileWithEcdsa() throws Exception { + PrivateKey privateKey = PemPrivateKeyParser.parse(read("test-ec-key.pem")); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("EC"); + } + + @Test + void parseWithNonKeyTextWillThrowException() { + assertThatIllegalStateException().isThrownBy(() -> PemPrivateKeyParser.parse(read("test-banner.txt"))); + } + + private String read(String path) throws IOException { + return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java new file mode 100644 index 0000000000..fd092261b1 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemSslStoreBundleTests.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.ssl.pem; + +import java.security.KeyStore; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.util.function.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemSslStoreBundle}. + * + * @author Scott Frederick + * @author Phillip Webb + */ +class PemSslStoreBundleTests { + + @Test + void whenNullStores() { + PemSslStoreDetails keyStoreDetails = null; + PemSslStoreDetails trustStoreDetails = null; + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).isNull(); + assertThat(bundle.getKeyStorePassword()).isNull(); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void whenStoresHaveNoValues() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate(null); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate(null); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).isNull(); + assertThat(bundle.getKeyStorePassword()).isNull(); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void whenHasKeyStoreDetailsCertAndKey() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreDetails trustStoreDetails = null; + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).isNull(); + } + + @Test + void whenHasKeyStoreDetailsAndTrustStoreDetailsWithoutKey() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem"); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCert("ssl-0")); + } + + @Test + void whenHasKeyStoreDetailsAndTrustStoreDetails() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("ssl")); + } + + @Test + void whenHasKeyStoreDetailsAndTrustStoreDetailsAndAlias() { + PemSslStoreDetails keyStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreDetails trustStoreDetails = PemSslStoreDetails.forCertificate("classpath:test-cert.pem") + .withPrivateKey("classpath:test-key.pem"); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails, "test-alias"); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("test-alias")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("test-alias")); + } + + @Test + void whenHasStoreType() { + PemSslStoreDetails keyStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem", + "classpath:test-key.pem"); + PemSslStoreDetails trustStoreDetails = new PemSslStoreDetails("PKCS12", "classpath:test-cert.pem", + "classpath:test-key.pem"); + PemSslStoreBundle bundle = new PemSslStoreBundle(keyStoreDetails, trustStoreDetails); + assertThat(bundle.getKeyStore()).satisfies(storeContainingCertAndKey("PKCS12", "ssl")); + assertThat(bundle.getTrustStore()).satisfies(storeContainingCertAndKey("PKCS12", "ssl")); + } + + private Consumer storeContainingCert(String keyAlias) { + return storeContainingCert(KeyStore.getDefaultType(), keyAlias); + } + + private Consumer storeContainingCert(String keyStoreType, String keyAlias) { + return ThrowingConsumer.of((keyStore) -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo(keyStoreType); + assertThat(keyStore.containsAlias(keyAlias)).isTrue(); + assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); + assertThat(keyStore.getKey(keyAlias, new char[] {})).isNull(); + }); + } + + private Consumer storeContainingCertAndKey(String keyAlias) { + return storeContainingCertAndKey(KeyStore.getDefaultType(), keyAlias); + } + + private Consumer storeContainingCertAndKey(String keyStoreType, String keyAlias) { + return ThrowingConsumer.of((keyStore) -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo(keyStoreType); + assertThat(keyStore.containsAlias(keyAlias)).isTrue(); + assertThat(keyStore.getCertificate(keyAlias)).isNotNull(); + assertThat(keyStore.getKey(keyAlias, new char[] {})).isNotNull(); + }); + } + +}