diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 3e30e9cc37..6587e024ec 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -243,6 +243,22 @@ "name": "server.ssl.trust-store-type", "description": "Type of the trust store." }, + { + "name": "server.ssl.certificate", + "description": "Path to a PEM-encoded SSL certificate file." + }, + { + "name": "server.ssl.certificate-private-key", + "description": "Path to a PEM-encoded private key file for the SSL certificate." + }, + { + "name": "server.ssl.trust-certificate", + "description": "Path to a PEM-encoded SSL certificate authority file." + }, + { + "name": "server.ssl.trust-certificate-private-key", + "description": "Path to a PEM-encoded private key file for the SSL certificate authority." + }, { "name": "server.tomcat.max-http-post-size", "type": "org.springframework.util.unit.DataSize", diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc index 84d6c9a880..2b3649b0b9 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/howto/webserver.adoc @@ -183,7 +183,7 @@ You can configure this behavior by setting the configprop:server.compression.mim [[howto.webserver.configure-ssl]] === Configure SSL SSL can be configured declaratively by setting the various `+server.ssl.*+` properties, typically in `application.properties` or `application.yml`. -The following example shows setting SSL properties in `application.properties`: +The following example shows setting SSL properties using a Java KeyStore file: [source,yaml,indent=0,subs="verbatim",configprops,configblocks] ---- @@ -195,6 +195,19 @@ The following example shows setting SSL properties in `application.properties`: key-password: "another-secret" ---- +The following example shows setting SSL properties using PEM-encoded certificate and private key files: + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- + server: + port: 8443 + ssl: + certificate: "classpath:my-cert.crt" + certificate-private-key: "classpath:my-cert.key" + trust-certificate: "classpath:ca-cert.crt" + key-store-password: "secret" +---- + See {spring-boot-module-code}/web/server/Ssl.java[`Ssl`] for details of all of the supported properties. Using configuration such as the preceding example means the application no longer supports a plain HTTP connector at port 8080. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java index 1ea7e2b801..89f44959ed 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -39,6 +39,7 @@ import org.springframework.boot.rsocket.server.ConfigurableRSocketServerFactory; import org.springframework.boot.rsocket.server.RSocketServer; import org.springframework.boot.rsocket.server.RSocketServerCustomizer; import org.springframework.boot.rsocket.server.RSocketServerFactory; +import org.springframework.boot.web.server.CertificateFileSslStoreProvider; import org.springframework.boot.web.server.Ssl; import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.http.client.reactive.ReactorResourceFactory; @@ -51,6 +52,7 @@ import org.springframework.util.unit.DataSize; * * @author Brian Clozel * @author Chris Bono + * @author Scott Frederick * @since 2.2.0 */ public class NettyRSocketServerFactory implements RSocketServerFactory, ConfigurableRSocketServerFactory { @@ -179,7 +181,7 @@ public class NettyRSocketServerFactory implements RSocketServerFactory, Configur @SuppressWarnings("deprecation") private HttpServer customizeSslConfiguration(HttpServer httpServer) { org.springframework.boot.web.embedded.netty.SslServerCustomizer sslServerCustomizer = new org.springframework.boot.web.embedded.netty.SslServerCustomizer( - this.ssl, null, this.sslStoreProvider); + this.ssl, null, getOrCreateSslStoreProvider()); return sslServerCustomizer.apply(httpServer); } @@ -189,12 +191,20 @@ public class NettyRSocketServerFactory implements RSocketServerFactory, Configur tcpServer = tcpServer.runOn(this.resourceFactory.getLoopResources()); } if (this.ssl != null && this.ssl.isEnabled()) { - TcpSslServerCustomizer sslServerCustomizer = new TcpSslServerCustomizer(this.ssl, this.sslStoreProvider); + TcpSslServerCustomizer sslServerCustomizer = new TcpSslServerCustomizer(this.ssl, + getOrCreateSslStoreProvider()); tcpServer = sslServerCustomizer.apply(tcpServer); } return TcpServerTransport.create(tcpServer.bindAddress(this::getListenAddress)); } + private SslStoreProvider getOrCreateSslStoreProvider() { + if (this.sslStoreProvider != null) { + return this.sslStoreProvider; + } + return CertificateFileSslStoreProvider.from(this.ssl); + } + private InetSocketAddress getListenAddress() { if (this.address != null) { return new InetSocketAddress(this.address.getHostAddress(), this.port); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java index 531667de61..223058f720 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -236,7 +236,7 @@ public class JettyReactiveWebServerFactory extends AbstractReactiveWebServerFact } private void customizeSsl(Server server, InetSocketAddress address) { - new SslServerCustomizer(address, getSsl(), getSslStoreProvider(), getHttp2()).customize(server); + new SslServerCustomizer(address, getSsl(), getOrCreateSslStoreProvider(), getHttp2()).customize(server); } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java index 7591c4e7e5..f6d730a366 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/jetty/JettyServletWebServerFactory.java @@ -222,7 +222,7 @@ public class JettyServletWebServerFactory extends AbstractServletWebServerFactor } private void customizeSsl(Server server, InetSocketAddress address) { - new SslServerCustomizer(address, getSsl(), getSslStoreProvider(), getHttp2()).customize(server); + new SslServerCustomizer(address, getSsl(), getOrCreateSslStoreProvider(), getHttp2()).customize(server); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java index 78170a4ddf..ae14a41c33 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/netty/NettyReactiveWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -179,7 +179,8 @@ public class NettyReactiveWebServerFactory extends AbstractReactiveWebServerFact @SuppressWarnings("deprecation") private HttpServer customizeSslConfiguration(HttpServer httpServer) { - SslServerCustomizer sslServerCustomizer = new SslServerCustomizer(getSsl(), getHttp2(), getSslStoreProvider()); + SslServerCustomizer sslServerCustomizer = new SslServerCustomizer(getSsl(), getHttp2(), + getOrCreateSslStoreProvider()); return sslServerCustomizer.apply(httpServer); } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java index ca4a48a83c..6a76e0be7b 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatReactiveWebServerFactory.java @@ -221,7 +221,7 @@ public class TomcatReactiveWebServerFactory extends AbstractReactiveWebServerFac } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl(), getSslStoreProvider()).customize(connector); + new SslConnectorCustomizer(getSsl(), getOrCreateSslStoreProvider()).customize(connector); } @Override diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java index ef0351d2f2..e1a23f5935 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.java @@ -361,7 +361,7 @@ public class TomcatServletWebServerFactory extends AbstractServletWebServerFacto } private void customizeSsl(Connector connector) { - new SslConnectorCustomizer(getSsl(), getSslStoreProvider()).customize(connector); + new SslConnectorCustomizer(getSsl(), getOrCreateSslStoreProvider()).customize(connector); } /** diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServerFactoryDelegate.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServerFactoryDelegate.java index 0b17e7a908..dc7482d486 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServerFactoryDelegate.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/embedded/undertow/UndertowWebServerFactoryDelegate.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -163,7 +163,8 @@ class UndertowWebServerFactoryDelegate { builder.setServerOption(UndertowOptions.ENABLE_HTTP2, http2.isEnabled()); } if (ssl != null && ssl.isEnabled()) { - new SslBuilderCustomizer(factory.getPort(), address, ssl, factory.getSslStoreProvider()).customize(builder); + new SslBuilderCustomizer(factory.getPort(), address, ssl, factory.getOrCreateSslStoreProvider()) + .customize(builder); } else { builder.addHttpListener(port, (address != null) ? address.getHostAddress() : "0.0.0.0"); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java index fb29b31e09..1cde582ced 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/AbstractConfigurableWebServerFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2022 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. @@ -36,6 +36,7 @@ import org.springframework.util.Assert; * @author Ivan Sopov * @author EddĂș MelĂ©ndez * @author Brian Clozel + * @author Scott Frederick * @since 2.0.0 */ public abstract class AbstractConfigurableWebServerFactory implements ConfigurableWebServerFactory { @@ -179,6 +180,18 @@ public abstract class AbstractConfigurableWebServerFactory implements Configurab return this.shutdown; } + /** + * Return the provided {@link SslStoreProvider} or create one using {@link Ssl} + * properties. + * @return the {@code SslStoreProvider} + */ + public final SslStoreProvider getOrCreateSslStoreProvider() { + if (this.sslStoreProvider != null) { + return this.sslStoreProvider; + } + return CertificateFileSslStoreProvider.from(this.ssl); + } + /** * Return the absolute temp dir for given web server. * @param prefix server name diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java new file mode 100644 index 0000000000..56480fb8f3 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateFileSslStoreProvider.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +/** + * An {@link SslStoreProvider} that creates key and trust stores from certificate and + * private key PEM files. + * + * @author Scott Frederick + * @since 2.7.0 + */ +public final class CertificateFileSslStoreProvider implements SslStoreProvider { + + private static final char[] NO_PASSWORD = {}; + + private static final String DEFAULT_KEY_ALIAS = "spring-boot-web"; + + private final Ssl ssl; + + private CertificateFileSslStoreProvider(Ssl ssl) { + this.ssl = ssl; + } + + @Override + public KeyStore getKeyStore() throws Exception { + return createKeyStore(this.ssl.getCertificate(), this.ssl.getCertificatePrivateKey(), + this.ssl.getKeyStorePassword(), this.ssl.getKeyStoreType(), this.ssl.getKeyAlias()); + } + + @Override + public KeyStore getTrustStore() throws Exception { + if (this.ssl.getTrustCertificate() == null) { + return null; + } + return createKeyStore(this.ssl.getTrustCertificate(), this.ssl.getTrustCertificatePrivateKey(), + this.ssl.getTrustStorePassword(), this.ssl.getTrustStoreType(), this.ssl.getKeyAlias()); + } + + /** + * Create a new {@link KeyStore} populated with the certificate stored at the + * specified file path and an optional private key. + * @param certPath the path to the certificate authority file + * @param keyPath the path to the private file + * @param password the key store password + * @param storeType the {@code KeyStore} type to create + * @param keyAlias the alias to use when adding keys to the {@code KeyStore} + * @return the {@code KeyStore} + */ + private KeyStore createKeyStore(String certPath, String keyPath, String password, String storeType, + String keyAlias) { + try { + KeyStore keyStore = KeyStore.getInstance((storeType != null) ? storeType : KeyStore.getDefaultType()); + keyStore.load(null); + X509Certificate[] certificates = CertificateParser.parse(certPath); + PrivateKey privateKey = (keyPath != null) ? PrivateKeyParser.parse(keyPath) : null; + try { + addCertificates(keyStore, certificates, privateKey, password, keyAlias); + } + catch (KeyStoreException ex) { + throw new IllegalStateException("Error adding certificates to KeyStore: " + ex.getMessage(), ex); + } + return keyStore; + } + catch (GeneralSecurityException | IOException ex) { + throw new IllegalStateException("Error creating KeyStore: " + ex.getMessage(), ex); + } + } + + private void addCertificates(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey, + String password, String keyAlias) throws KeyStoreException { + String alias = (keyAlias != null) ? keyAlias : DEFAULT_KEY_ALIAS; + if (privateKey != null) { + keyStore.setKeyEntry(alias, privateKey, ((password != null) ? password.toCharArray() : NO_PASSWORD), + certificates); + } + else { + for (int index = 0; index < certificates.length; index++) { + keyStore.setCertificateEntry(alias + "-" + index, certificates[index]); + } + } + } + + /** + * Create a {@link SslStoreProvider} if the appropriate SSL properties are configured. + * @param ssl the SSL properties + * @return a {@code SslStoreProvider} or {@code null} + */ + public static SslStoreProvider from(Ssl ssl) { + if (ssl != null && ssl.isEnabled()) { + if (ssl.getCertificate() != null && ssl.getCertificatePrivateKey() != null) { + return new CertificateFileSslStoreProvider(ssl); + } + } + return null; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateParser.java new file mode 100644 index 0000000000..11867b095f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/CertificateParser.java @@ -0,0 +1,109 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Base64Utils; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Parser for X.509 certificates in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class CertificateParser { + + 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 CertificateParser() { + } + + /** + * Load certificates from the specified resource. + * @param path the certificate to parse + * @return the parsed certificates + */ + static X509Certificate[] parse(String path) { + CertificateFactory factory = getCertificateFactory(); + List certificates = new ArrayList<>(); + readCertificates(path, factory, certificates::add); + return certificates.toArray(new X509Certificate[0]); + } + + 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 resource, CertificateFactory factory, + Consumer consumer) { + try { + String text = readText(resource); + 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 | IOException ex) { + throw new IllegalStateException("Error reading certificate from '" + resource + "' : " + ex.getMessage(), + ex); + } + } + + private static String readText(String resource) throws IOException { + URL url = ResourceUtils.getURL(resource); + try (Reader reader = new InputStreamReader(url.openStream())) { + return FileCopyUtils.copyToString(reader); + } + } + + private static byte[] decodeBase64(String content) { + byte[] bytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64Utils.decode(bytes); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/PrivateKeyParser.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/PrivateKeyParser.java new file mode 100644 index 0000000000..924653d125 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/PrivateKeyParser.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Base64Utils; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Parser for PKCS private key files in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PrivateKeyParser { + + 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_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + + private static final Pattern PKCS1_PATTERN = Pattern.compile(PKCS1_HEADER + BASE64_TEXT + PKCS1_FOOTER, + Pattern.CASE_INSENSITIVE); + + private static final Pattern PKCS8_KEY_PATTERN = Pattern.compile(PKCS8_HEADER + BASE64_TEXT + PKCS8_FOOTER, + Pattern.CASE_INSENSITIVE); + + private PrivateKeyParser() { + } + + /** + * Load a private key from the specified resource. + * @param resource the private key to parse + * @return the parsed private key + */ + static PrivateKey parse(String resource) { + try { + String text = readText(resource); + Matcher matcher = PKCS1_PATTERN.matcher(text); + if (matcher.find()) { + return parsePkcs1(decodeBase64(matcher.group(1))); + } + matcher = PKCS8_KEY_PATTERN.matcher(text); + if (matcher.find()) { + return parsePkcs8(decodeBase64(matcher.group(1))); + } + throw new IllegalStateException("Unrecognized private key format in " + resource); + } + catch (GeneralSecurityException | IOException ex) { + throw new IllegalStateException("Error loading private key file " + resource, ex); + } + } + + private static PrivateKey parsePkcs1(byte[] privateKeyBytes) throws GeneralSecurityException { + byte[] pkcs8Bytes = convertPkcs1ToPkcs8(privateKeyBytes); + return parsePkcs8(pkcs8Bytes); + } + + private static byte[] convertPkcs1ToPkcs8(byte[] pkcs1) { + try { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + int pkcs1Length = pkcs1.length; + int totalLength = pkcs1Length + 22; + // Sequence + total length + result.write(bytes(0x30, 0x82)); + result.write((totalLength >> 8) & 0xff); + result.write(totalLength & 0xff); + // Integer (0) + result.write(bytes(0x02, 0x01, 0x00)); + // Sequence: 1.2.840.113549.1.1.1, NULL + result.write( + bytes(0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00)); + // Octet string + length + result.write(bytes(0x04, 0x82)); + result.write((pkcs1Length >> 8) & 0xff); + result.write(pkcs1Length & 0xff); + // PKCS1 + result.write(pkcs1); + return result.toByteArray(); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static byte[] bytes(int... elements) { + byte[] result = new byte[elements.length]; + for (int i = 0; i < elements.length; i++) { + result[i] = (byte) elements[i]; + } + return result; + } + + private static PrivateKey parsePkcs8(byte[] privateKeyBytes) throws GeneralSecurityException { + try { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } + catch (InvalidKeySpecException ex) { + throw new IllegalArgumentException("Unexpected key format", ex); + } + } + + private static String readText(String resource) throws IOException { + URL url = ResourceUtils.getURL(resource); + try (Reader reader = new InputStreamReader(url.openStream())) { + return FileCopyUtils.copyToString(reader); + } + } + + private static byte[] decodeBase64(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64Utils.decode(contentBytes); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Ssl.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Ssl.java index 8d1da04391..af4400717c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Ssl.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/Ssl.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2022 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. @@ -22,6 +22,7 @@ package org.springframework.boot.web.server; * @author Andy Wilkinson * @author Vladimir Tsanev * @author Stephane Nicoll + * @author Scott Frederick * @since 2.0.0 */ public class Ssl { @@ -54,6 +55,14 @@ public class Ssl { private String trustStoreProvider; + private String certificate; + + private String certificatePrivateKey; + + private String trustCertificate; + + private String trustCertificatePrivateKey; + private String protocol = "TLS"; /** @@ -226,6 +235,54 @@ public class Ssl { this.trustStoreProvider = trustStoreProvider; } + /** + * Return the location of the certificate in PEM format. + * @return the certificate location + */ + public String getCertificate() { + return this.certificate; + } + + public void setCertificate(String certificate) { + this.certificate = certificate; + } + + /** + * Return the location of the private key for the certificate in PEM format. + * @return the location of the certificate private key + */ + public String getCertificatePrivateKey() { + return this.certificatePrivateKey; + } + + public void setCertificatePrivateKey(String certificatePrivateKey) { + this.certificatePrivateKey = certificatePrivateKey; + } + + /** + * Return the location of the trust certificate authority chain in PEM format. + * @return the location of the trust certificate + */ + public String getTrustCertificate() { + return this.trustCertificate; + } + + public void setTrustCertificate(String trustCertificate) { + this.trustCertificate = trustCertificate; + } + + /** + * Return the location of the private key for the trust certificate in PEM format. + * @return the location of the trust certificate private key + */ + public String getTrustCertificatePrivateKey() { + return this.trustCertificatePrivateKey; + } + + public void setTrustCertificatePrivateKey(String trustCertificatePrivateKey) { + this.trustCertificatePrivateKey = trustCertificatePrivateKey; + } + /** * Return the SSL protocol to use. * @return the SSL protocol diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java index 0a79bea428..4c739922fd 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/rsocket/netty/NettyRSocketServerFactoryTests.java @@ -64,6 +64,7 @@ import static org.mockito.Mockito.mock; * @author Brian Clozel * @author Leo Li * @author Chris Bono + * @author Scott Frederick */ class NettyRSocketServerFactoryTests { @@ -166,6 +167,30 @@ class NettyRSocketServerFactoryTests { testBasicSslWithKeyStore("src/test/resources/test.jks", "password", Transport.WEBSOCKET); } + @Test + void tcpTransportBasicSslCertificateFromClassPath() { + testBasicSslWithPemCertificate("classpath:test-cert.pem", "classpath:test-key.pem", "classpath:test-cert.pem", + Transport.TCP); + } + + @Test + void tcpTransportBasicSslCertificateFromFileSystem() { + testBasicSslWithPemCertificate("src/test/resources/test-cert.pem", "src/test/resources/test-key.pem", + "src/test/resources/test-cert.pem", Transport.TCP); + } + + @Test + void websocketTransportBasicSslCertificateFromClassPath() { + testBasicSslWithPemCertificate("classpath:test-cert.pem", "classpath:test-key.pem", "classpath:test-cert.pem", + Transport.WEBSOCKET); + } + + @Test + void websocketTransportBasicSslCertificateFromFileSystem() { + testBasicSslWithPemCertificate("src/test/resources/test-cert.pem", "src/test/resources/test-key.pem", + "src/test/resources/test-cert.pem", Transport.WEBSOCKET); + } + private void checkEchoRequest() { String payload = "test payload"; Mono response = this.requester.route("test").data(payload).retrieveMono(String.class); @@ -186,6 +211,23 @@ class NettyRSocketServerFactoryTests { checkEchoRequest(); } + private void testBasicSslWithPemCertificate(String certificate, String certificatePrivateKey, + String trustCertificate, Transport transport) { + NettyRSocketServerFactory factory = getFactory(); + factory.setTransport(transport); + Ssl ssl = new Ssl(); + ssl.setCertificate(certificate); + ssl.setCertificatePrivateKey(certificatePrivateKey); + ssl.setTrustCertificate(trustCertificate); + ssl.setKeyStorePassword(""); + factory.setSsl(ssl); + this.server = factory.create(new EchoRequestResponseAcceptor()); + this.server.start(); + this.requester = (transport == Transport.TCP) ? createSecureRSocketTcpClient() + : createSecureRSocketWebSocketClient(); + checkEchoRequest(); + } + @Test void tcpTransportSslRejectsInsecureClient() { NettyRSocketServerFactory factory = getFactory(); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java index ad0b5f098d..0d190d973c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/reactive/server/AbstractReactiveWebServerFactoryTests.java @@ -86,6 +86,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; * Base for testing classes that extends {@link AbstractReactiveWebServerFactory}. * * @author Brian Clozel + * @author Scott Frederick */ public abstract class AbstractReactiveWebServerFactoryTests { @@ -215,7 +216,7 @@ public abstract class AbstractReactiveWebServerFactoryTests { ssl.setKeyPassword("password"); ssl.setKeyStorePassword("secret"); ssl.setTrustStore("classpath:test.jks"); - testClientAuthSuccess(ssl, buildTrustAllSslWithClientKeyConnector()); + testClientAuthSuccess(ssl, buildTrustAllSslWithClientKeyConnector("test.jks", "password")); } @Test @@ -229,14 +230,15 @@ public abstract class AbstractReactiveWebServerFactoryTests { testClientAuthSuccess(ssl, buildTrustAllSslConnector()); } - protected ReactorClientHttpConnector buildTrustAllSslWithClientKeyConnector() throws Exception { + protected ReactorClientHttpConnector buildTrustAllSslWithClientKeyConnector(String keyStoreFile, + String keyStorePassword) throws Exception { KeyStore clientKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - try (InputStream stream = new FileInputStream("src/test/resources/test.jks")) { + try (InputStream stream = new FileInputStream("src/test/resources/" + keyStoreFile)) { clientKeyStore.load(stream, "secret".toCharArray()); } KeyManagerFactory clientKeyManagerFactory = KeyManagerFactory .getInstance(KeyManagerFactory.getDefaultAlgorithm()); - clientKeyManagerFactory.init(clientKeyStore, "password".toCharArray()); + clientKeyManagerFactory.init(clientKeyStore, keyStorePassword.toCharArray()); Http11SslContextSpec sslContextSpec = Http11SslContextSpec.forClient() .configure((builder) -> builder.sslProvider(SslProvider.JDK) @@ -265,7 +267,7 @@ public abstract class AbstractReactiveWebServerFactoryTests { ssl.setKeyStorePassword("secret"); ssl.setKeyPassword("password"); ssl.setTrustStore("classpath:test.jks"); - testClientAuthSuccess(ssl, buildTrustAllSslWithClientKeyConnector()); + testClientAuthSuccess(ssl, buildTrustAllSslWithClientKeyConnector("test.jks", "password")); } @Test @@ -279,6 +281,17 @@ public abstract class AbstractReactiveWebServerFactoryTests { testClientAuthFailure(ssl, buildTrustAllSslConnector()); } + @Test + void sslWithPemCertificates() throws Exception { + Ssl ssl = new Ssl(); + ssl.setClientAuth(Ssl.ClientAuth.NEED); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + ssl.setTrustCertificate("classpath:test-cert.pem"); + ssl.setKeyStorePassword("secret"); + testClientAuthSuccess(ssl, buildTrustAllSslWithClientKeyConnector("test.p12", "secret")); + } + protected void testClientAuthFailure(Ssl sslConfiguration, ReactorClientHttpConnector clientConnector) { AbstractReactiveWebServerFactory factory = getFactory(); factory.setSsl(sslConfiguration); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateFileSslStoreProviderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateFileSslStoreProviderTests.java new file mode 100644 index 0000000000..8f0fcdd662 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateFileSslStoreProviderTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CertificateFileSslStoreProvider}. + * + * @author Scott Frederick + */ +class CertificateFileSslStoreProviderTests { + + @Test + void fromSslWhenNullReturnsNull() { + assertThat(CertificateFileSslStoreProvider.from(null)).isNull(); + } + + @Test + void fromSslWhenDisabledReturnsNull() { + assertThat(CertificateFileSslStoreProvider.from(new Ssl())).isNull(); + } + + @Test + void fromSslWithCertAndKeyReturnsStoreProvider() throws Exception { + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + SslStoreProvider storeProvider = CertificateFileSslStoreProvider.from(ssl); + assertThat(storeProvider).isNotNull(); + assertStoreContainsCertAndKey(storeProvider.getKeyStore(), KeyStore.getDefaultType(), "spring-boot-web"); + assertThat(storeProvider.getTrustStore()).isNull(); + } + + @Test + void fromSslWithCertAndKeyAndTrustCertReturnsStoreProvider() throws Exception { + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + ssl.setTrustCertificate("classpath:test-cert.pem"); + SslStoreProvider storeProvider = CertificateFileSslStoreProvider.from(ssl); + assertThat(storeProvider).isNotNull(); + assertStoreContainsCertAndKey(storeProvider.getKeyStore(), KeyStore.getDefaultType(), "spring-boot-web"); + assertStoreContainsCert(storeProvider.getTrustStore(), KeyStore.getDefaultType(), "spring-boot-web-0"); + } + + @Test + void fromSslWithCertAndKeyAndTrustCertAndTrustKeyReturnsStoreProvider() throws Exception { + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + ssl.setTrustCertificate("classpath:test-cert.pem"); + ssl.setTrustCertificatePrivateKey("classpath:test-key.pem"); + SslStoreProvider storeProvider = CertificateFileSslStoreProvider.from(ssl); + assertThat(storeProvider).isNotNull(); + assertStoreContainsCertAndKey(storeProvider.getKeyStore(), KeyStore.getDefaultType(), "spring-boot-web"); + assertStoreContainsCertAndKey(storeProvider.getTrustStore(), KeyStore.getDefaultType(), "spring-boot-web"); + } + + @Test + void fromSslWithKeyAliasReturnsStoreProvider() throws Exception { + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setKeyAlias("test-alias"); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + ssl.setTrustCertificate("classpath:test-cert.pem"); + ssl.setTrustCertificatePrivateKey("classpath:test-key.pem"); + SslStoreProvider storeProvider = CertificateFileSslStoreProvider.from(ssl); + assertThat(storeProvider).isNotNull(); + assertStoreContainsCertAndKey(storeProvider.getKeyStore(), KeyStore.getDefaultType(), "test-alias"); + assertStoreContainsCertAndKey(storeProvider.getTrustStore(), KeyStore.getDefaultType(), "test-alias"); + } + + @Test + void fromSslWithStoreTypeReturnsStoreProvider() throws Exception { + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setKeyStoreType("PKCS12"); + ssl.setTrustStoreType("PKCS12"); + ssl.setCertificate("classpath:test-cert.pem"); + ssl.setCertificatePrivateKey("classpath:test-key.pem"); + ssl.setTrustCertificate("classpath:test-cert.pem"); + ssl.setTrustCertificatePrivateKey("classpath:test-key.pem"); + SslStoreProvider storeProvider = CertificateFileSslStoreProvider.from(ssl); + assertThat(storeProvider).isNotNull(); + assertStoreContainsCertAndKey(storeProvider.getKeyStore(), "PKCS12", "spring-boot-web"); + assertStoreContainsCertAndKey(storeProvider.getTrustStore(), "PKCS12", "spring-boot-web"); + } + + private void assertStoreContainsCertAndKey(KeyStore keyStore, String keyStoreType, String keyAlias) + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + 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(); + } + + private void assertStoreContainsCert(KeyStore keyStore, String keyStoreType, String keyAlias) + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + 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(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateParserTests.java new file mode 100644 index 0000000000..7107c1ae7b --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/CertificateParserTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.security.cert.X509Certificate; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link CertificateParser}. + * + * @author Scott Frederick + */ +class CertificateParserTests { + + @Test + void parseCertificate() { + X509Certificate[] certificates = CertificateParser.parse("classpath:test-cert.pem"); + assertThat(certificates).isNotNull(); + assertThat(certificates.length).isEqualTo(1); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + } + + @Test + void parseCertificateChain() { + X509Certificate[] certificates = CertificateParser.parse("classpath:test-cert-chain.pem"); + assertThat(certificates).isNotNull(); + assertThat(certificates.length).isEqualTo(2); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates[1].getType()).isEqualTo("X.509"); + } + + @Test + void parseWithInvalidPathWillThrowException() { + String path = "file:///bad/path/cert.pem"; + assertThatIllegalStateException().isThrownBy(() -> CertificateParser.parse("file:///bad/path/cert.pem")) + .withMessageContaining(path); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/PrivateKeyParserTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/PrivateKeyParserTests.java new file mode 100644 index 0000000000..0a55581ea9 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/PrivateKeyParserTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2022 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.web.server; + +import java.security.PrivateKey; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link PrivateKeyParser}. + * + * @author Scott Frederick + */ +class PrivateKeyParserTests { + + @Test + void parsePkcs8KeyFile() { + PrivateKey privateKey = PrivateKeyParser.parse("classpath:test-key.pem"); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + } + + @Test + void parseWithNonKeyFileWillThrowException() { + String path = "classpath:test-banner.txt"; + assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse("file://" + path)) + .withMessageContaining(path); + } + + @Test + void parseWithInvalidPathWillThrowException() { + String path = "file:///bad/path/key.pem"; + assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse(path)).withMessageContaining(path); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java index 37b3ea74c4..2166da85ef 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/servlet/server/AbstractServletWebServerFactoryTests.java @@ -167,6 +167,7 @@ import static org.mockito.Mockito.mock; * @author Greg Turnquist * @author Andy Wilkinson * @author Raja Kolli + * @author Scott Frederick */ @ExtendWith(OutputCaptureExtension.class) public abstract class AbstractServletWebServerFactoryTests { @@ -559,6 +560,23 @@ public abstract class AbstractServletWebServerFactoryTests { assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); } + @Test + void pemKeyStoreAndTrustStore() throws Exception { + AbstractServletWebServerFactory factory = getFactory(); + addTestTxtFile(factory); + factory.setSsl(getSsl("classpath:test-cert.pem", "classpath:test-key.pem")); + this.webServer = factory.getWebServer(); + this.webServer.start(); + KeyStore keyStore = KeyStore.getInstance("pkcs12"); + loadStore(keyStore, new FileSystemResource("src/test/resources/test.p12")); + SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory( + new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()) + .loadKeyMaterial(keyStore, "secret".toCharArray()).build()); + HttpClient httpClient = this.httpClientBuilder.get().setSSLSocketFactory(socketFactory).build(); + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + assertThat(getResponse(getLocalUrl("https", "/test.txt"), requestFactory)).isEqualTo("test"); + } + @Test void sslNeedsClientAuthenticationSucceedsWithClientCertificate() throws Exception { AbstractServletWebServerFactory factory = getFactory(); @@ -710,6 +728,16 @@ public abstract class AbstractServletWebServerFactoryTests { return ssl; } + private Ssl getSsl(String cert, String privateKey) { + Ssl ssl = new Ssl(); + ssl.setClientAuth(ClientAuth.NEED); + ssl.setCertificate(cert); + ssl.setCertificatePrivateKey(privateKey); + ssl.setTrustCertificate(cert); + ssl.setKeyStorePassword("secret"); + return ssl; + } + protected void testRestrictedSSLProtocolsAndCipherSuites(String[] protocols, String[] ciphers) throws Exception { AbstractServletWebServerFactory factory = getFactory(); factory.setSsl(getSsl(null, "password", "src/test/resources/restricted.jks", null, protocols, ciphers)); diff --git a/spring-boot-project/spring-boot/src/test/resources/test-cert-chain.pem b/spring-boot-project/spring-boot/src/test/resources/test-cert-chain.pem new file mode 100644 index 0000000000..df103772cf --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/test-cert-chain.pem @@ -0,0 +1,32 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIClzCCAgACCQCPbjkRoMVEQDANBgkqhkiG9w0BAQUFADCBjzELMAkGA1UEBhMC +VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28x +DTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxFDASBgNVBAMMC2V4YW1wbGUu +Y29tMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1wbGUuY29tMB4XDTIwMDMyNzIx +NTgwNFoXDTIxMDMyNzIxNTgwNFowgY8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApD +YWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQKDARUZXN0 +MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0GCSqGSIb3 +DQEJARYQdGVzdEBleGFtcGxlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC +gYEA1YzixWEoyzrd20C2R1gjyPCoPfFLlG6UYTyT0tueNy6yjv6qbJ8lcZg7616O +3I9LuOHhZh9U+fCDCgPfiDdyJfDEW/P+dsOMFyMUXPrJPze2yPpOnvV8iJ5DM93u +fEVhCCyzLdYu0P2P3hU2W+T3/Im9DA7FOPA2vF1SrIJ2qtUCAwEAATANBgkqhkiG +9w0BAQUFAAOBgQBdShkwUv78vkn1jAdtfbB+7mpV9tufVdo29j7pmotTCz3ny5fc +zLEfeu6JPugAR71JYbc2CqGrMneSk1zT91EH6ohIz8OR5VNvzB7N7q65Ci7OFMPl +ly6k3rHpMCBtHoyNFhNVfPLxGJ9VlWFKLgIAbCmL4OIQm1l6Fr1MSM38Zw== +-----END TRUSTED CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICjzCCAfgCAQEwDQYJKoZIhvcNAQEFBQAwgY8xCzAJBgNVBAYTAlVTMRMwEQYD +VQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQK +DARUZXN0MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0G +CSqGSIb3DQEJARYQdGVzdEBleGFtcGxlLmNvbTAeFw0yMDAzMjcyMjAxNDZaFw0y +MTAzMjcyMjAxNDZaMIGPMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5p +YTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwEVGVzdDENMAsGA1UE +CwwEVGVzdDEUMBIGA1UEAwwLZXhhbXBsZS5jb20xHzAdBgkqhkiG9w0BCQEWEHRl +c3RAZXhhbXBsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAM7kd2cj +F49wm1+OQ7Q5GE96cXueWNPr/Nwei71tf6G4BmE0B+suXHEvnLpHTj9pdX/ZzBIK +8jIZ/x8RnSduK/Ky+zm1QMYUWZtWCAgCW8WzgB69Cn/hQG8KSX3S9bqODuQAvP54 +GQJD7+4kVuNBGjFb4DaD4nvMmPtALSZf8ZCZAgMBAAEwDQYJKoZIhvcNAQEFBQAD +gYEAOn6X8+0VVlDjF+TvTgI0KIasA6nDm+KXe7LVtfvqWqQZH4qyd2uiwcDM3Aux +a/OsPdOw0j+NqFDBd3mSMhSVgfvXdK6j9WaxY1VGXyaidLARgvn63wfzgr857sQW +c8eSxbwEQxwlMvVxW6Os4VhCfUQr8VrBrvPa2zs+6IlK+Ug= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/spring-boot-project/spring-boot/src/test/resources/test-cert.pem b/spring-boot-project/spring-boot/src/test/resources/test-cert.pem new file mode 100644 index 0000000000..1e912b9f63 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/test-cert.pem @@ -0,0 +1,17 @@ +-----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----- diff --git a/spring-boot-project/spring-boot/src/test/resources/test-key.pem b/spring-boot-project/spring-boot/src/test/resources/test-key.pem new file mode 100644 index 0000000000..00d439edc6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/resources/test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDR0KfxUw7MF/8R +B5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQLgqrRgAjl3VmC +C9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJuEfnp07cTfYZ +FqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0QazHQoM5s00Fer +6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFXyVuEF3HeyVPu +g8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0SdJ1N7aJnXpeS +QjAgf03jAgMBAAECggEBAIhQyzwj3WJGWOZkkLqOpufJotcmj/Wwf0VfOdkq9WMl +cB/bAlN/xWVxerPVgDCFch4EWBzi1WUaqbOvJZ2u7QNubmr56aiTmJCFTVI/GyZx +XqiTGN01N6lKtN7xo6LYTyAUhUsBTWAemrx0FSErvTVb9C/mUBj6hbEZ2XQ5kN5t +7qYX4Lu0zyn7s1kX5SLtm5I+YRq7HSwB6wLy+DSroO71izZ/VPwME3SwT5SN+c87 +3dkklR7fumNd9dOpSWKrLPnq4aMko00rvIGc63xD1HrEpXUkB5v24YEn7HwCLEH7 +b8jrp79j2nCvvR47inpf+BR8FIWAHEOUUqCEzjQkdiECgYEA6ifjMM0f02KPeIs7 +zXd1lI7CUmJmzkcklCIpEbKWf/t/PHv3QgqIkJzERzRaJ8b+GhQ4zrSwAhrGUmI8 +kDkXIqe2/2ONgIOX2UOHYHyTDQZHnlXyDecvHUTqs2JQZCGBZkXyZ9i0j3BnTymC +iZ8DvEa0nxsbP+U3rgzPQmXiQVMCgYEA5WN2Y/RndbriNsNrsHYRldbPO5nfV9rp +cDzcQU66HRdK5VIdbXT9tlMYCJIZsSqE0tkOwTgEB/sFvF/tIHSCY5iO6hpIyk6g +kkUzPcld4eM0dEPAge7SYUbakB9CMvA7MkDQSXQNFyZ0mH83+UikwT6uYHFh7+ox +N1P+psDhXzECgYEA1gXLVQnIcy/9LxMkgDMWV8j8uMyUZysDthpbK3/uq+A2dhRg +9g4msPd5OBQT65OpIjElk1n4HpRWfWqpLLHiAZ0GWPynk7W0D7P3gyuaRSdeQs0P +x8FtgPVDCN9t13gAjHiWjnC26Py2kNbCKAQeJ/MAmQTvrUFX2VCACJKTcV0CgYAj +xJWSUmrLfb+GQISLOG3Xim434e9keJsLyEGj4U29+YLRLTOvfJ2PD3fg5j8hU/rw +Ea5uTHi8cdTcIa0M8X3fX8txD3YoLYh2JlouGTcNYOst8d6TpBSj3HN6I5Wj8beZ +R2fy/CiKYpGtsbCdq0kdZNO18BgQW9kewncjs1GxEQKBgQCf8q34h6KuHpHSDh9h +YkDTypk0FReWBAVJCzDNDUMhVLFivjcwtaMd2LiC3FMKZYodr52iKg60cj43vbYI +frmFFxoL37rTmUocCTBKc0LhWj6MicI+rcvQYe1uwTrpWdFf1aZJMYRLRczeKtev +OWaE/9hVZ5+9pild1NukGpOydw== +-----END PRIVATE KEY-----