From 2b39ec6f602787d684343bc204e99255856f56a8 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Fri, 27 Oct 2023 17:09:30 -0700 Subject: [PATCH] Introduce a public `PemContent` class Update `PemContent` so that it now holds PEM data and is public. This update is required so that in the future we can make use of our PEM parsing code in spring-boot-autoconfigure. Closes gh-38174 --- .../boot/ssl/pem/PemCertificateParser.java | 4 + .../boot/ssl/pem/PemContent.java | 110 +++++++++++++++--- .../boot/ssl/pem/PemPrivateKeyParser.java | 2 +- .../boot/ssl/pem/PemSslStoreBundle.java | 6 +- .../boot/ssl/pem/PemContentTests.java | 99 +++++++++++++++- .../ssl/pem/PemPrivateKeyParserTests.java | 19 +-- 6 files changed, 199 insertions(+), 41 deletions(-) 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 index 58cf1ac444..8e07b0740b 100644 --- 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 @@ -27,6 +27,9 @@ import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + /** * Parser for X.509 certificates in PEM format. * @@ -58,6 +61,7 @@ final class PemCertificateParser { CertificateFactory factory = getCertificateFactory(); List certs = new ArrayList<>(); readCertificates(text, factory, certs::add); + Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format"); return List.copyOf(certs); } 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 index 9d70459836..280e7094df 100644 --- 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 @@ -17,26 +17,32 @@ package org.springframework.boot.ssl.pem; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.Reader; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.security.PrivateKey; import java.security.cert.X509Certificate; import java.util.List; import java.util.Objects; import java.util.regex.Pattern; -import org.springframework.util.FileCopyUtils; +import org.springframework.util.Assert; import org.springframework.util.ResourceUtils; +import org.springframework.util.StreamUtils; /** - * Utility to load PEM content. + * PEM encoded content that can provide {@link X509Certificate certificates} and + * {@link PrivateKey private keys}. * * @author Scott Frederick * @author Phillip Webb + * @since 3.2.0 */ -final class PemContent { +public final class PemContent { private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); @@ -48,11 +54,32 @@ final class PemContent { this.text = text; } - List getCertificates() { + /** + * Parse and return all {@link X509Certificate certificates} from the PEM content. + * Most PEM files either contain a single certificate or a certificate chain. + * @return the certificates + * @throws IllegalStateException if no certificates could be loaded + */ + public List getCertificates() { return PemCertificateParser.parse(this.text); } - PrivateKey getPrivateKeys(String password) { + /** + * Parse and return the {@link PrivateKey private keys} from the PEM content. + * @return the private keys + * @throws IllegalStateException if no private key could be loaded + */ + public PrivateKey getPrivateKey() { + return getPrivateKey(null); + } + + /** + * Parse and return the {@link PrivateKey private keys} from the PEM content or + * {@code null} if there is no private key. + * @param password the password to decrypt the private keys or {@code null} + * @return the private keys + */ + public PrivateKey getPrivateKey(String password) { return PemPrivateKeyParser.parse(this.text, password); } @@ -77,27 +104,74 @@ final class PemContent { return this.text; } - static PemContent load(String content) { + /** + * Load {@link PemContent} from the given content (either the PEM content itself or + * something that can be loaded by {@link ResourceUtils#getURL}). + * @param content the content to load + * @return a new {@link PemContent} instance + * @throws IOException on IO error + */ + static PemContent load(String content) throws IOException { if (content == null) { return null; } - if (isPemContent(content)) { + if (isPresentInText(content)) { return new PemContent(content); } try { - URL url = ResourceUtils.getURL(content); - try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { - return new PemContent(FileCopyUtils.copyToString(reader)); - } + return load(ResourceUtils.getURL(content)); } - catch (IOException ex) { - throw new IllegalStateException( - "Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex); + catch (IOException | UncheckedIOException ex) { + throw new IOException("Error reading certificate or key from file '%s'".formatted(content), ex); } } - private static boolean isPemContent(String content) { - return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); + /** + * Load {@link PemContent} from the given {@link URL}. + * @param url the URL to load content from + * @return the loaded PEM content + * @throws IOException on IO error + */ + public static PemContent load(URL url) throws IOException { + Assert.notNull(url, "Url must not be null"); + try (InputStream in = url.openStream()) { + return load(in); + } + } + + /** + * Load {@link PemContent} from the given {@link Path}. + * @param path a path to load the content from + * @return the loaded PEM content + * @throws IOException on IO error + */ + public static PemContent load(Path path) throws IOException { + Assert.notNull(path, "Path must not be null"); + try (InputStream in = Files.newInputStream(path, StandardOpenOption.READ)) { + return load(in); + } + } + + private static PemContent load(InputStream in) throws IOException { + return of(StreamUtils.copyToString(in, StandardCharsets.UTF_8)); + } + + /** + * Return a new {@link PemContent} instance containing the given text. + * @param text the text containing PEM encoded content + * @return a new {@link PemContent} instance + */ + public static PemContent of(String text) { + return (text != null) ? new PemContent(text) : null; + } + + /** + * Return if PEM content is present in the given text. + * @param text the text to check + * @return if the text includes PEM encoded content. + */ + public static boolean isPresentInText(String text) { + return text != null && PEM_HEADER.matcher(text).find() && PEM_FOOTER.matcher(text).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 index 068ae51f62..dbc5ca6972 100644 --- 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 @@ -194,11 +194,11 @@ final class PemPrivateKeyParser { return privateKey; } } - throw new IllegalStateException("Unrecognized private key format"); } catch (Exception ex) { throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex); } + throw new IllegalStateException("Missing private key or unrecognized format"); } /** 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 index 0346b5395b..ac565343b7 100644 --- 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 @@ -141,15 +141,15 @@ public class PemSslStoreBundle implements SslStoreBundle { throw new IllegalStateException("Private key matches none of the certificates"); } - private static PrivateKey loadPrivateKey(PemSslStoreDetails details) { + private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException { PemContent pemContent = PemContent.load(details.privateKey()); if (pemContent == null) { return null; } - return pemContent.getPrivateKeys(details.privateKeyPassword()); + return pemContent.getPrivateKey(details.privateKeyPassword()); } - private static X509Certificate[] loadCertificates(PemSslStoreDetails details) { + private static X509Certificate[] loadCertificates(PemSslStoreDetails details) throws IOException { PemContent pemContent = PemContent.load(details.certificate()); List certificates = pemContent.getCertificates(); Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty"); 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 index 6a8ddedb5d..e4318afe66 100644 --- 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 @@ -18,12 +18,17 @@ package org.springframework.boot.ssl.pem; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; 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 PemContent}. @@ -33,12 +38,61 @@ import static org.assertj.core.api.Assertions.assertThat; class PemContentTests { @Test - void loadWhenContentIsNullReturnsNull() { - assertThat(PemContent.load(null)).isNull(); + void getCertificateWhenNoCertificatesThrowsException() { + PemContent content = PemContent.of(""); + assertThatIllegalStateException().isThrownBy(content::getCertificates) + .withMessage("Missing certificates or unrecognized format"); } @Test - void loadWhenContentIsPemContentReturnsContent() { + void getCertificateReturnsCertificates() throws Exception { + PemContent content = PemContent.load(getClass().getResource("/test-cert-chain.pem")); + List certificates = content.getCertificates(); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(2); + assertThat(certificates.get(0).getType()).isEqualTo("X.509"); + assertThat(certificates.get(1).getType()).isEqualTo("X.509"); + } + + @Test + void getPrivateKeyWhenNoKeyThrowsException() { + PemContent content = PemContent.of(""); + assertThatIllegalStateException().isThrownBy(content::getPrivateKey) + .withMessage("Missing private key or unrecognized format"); + } + + @Test + void getPrivateKeyReturnsPrivateKey() throws Exception { + PemContent content = PemContent + .load(getClass().getResource("/org/springframework/boot/web/server/pkcs8/dsa.key")); + PrivateKey privateKey = content.getPrivateKey(); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("DSA"); + } + + @Test + void equalsAndHashCode() { + PemContent c1 = PemContent.of("aaa"); + PemContent c2 = PemContent.of("aaa"); + PemContent c3 = PemContent.of("bbb"); + assertThat(c1.hashCode()).isEqualTo(c2.hashCode()); + assertThat(c1).isEqualTo(c1).isEqualTo(c2).isNotEqualTo(c3); + } + + @Test + void toStringReturnsString() { + PemContent content = PemContent.of("test"); + assertThat(content).hasToString("test"); + } + + @Test + void loadWithStringWhenContentIsNullReturnsNull() throws Exception { + assertThat(PemContent.load((String) null)).isNull(); + } + + @Test + void loadWithStringWhenContentIsPemContentReturnsContent() throws Exception { String content = """ -----BEGIN CERTIFICATE----- MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls @@ -61,17 +115,52 @@ class PemContentTests { } @Test - void loadWhenClasspathLocationReturnsContent() throws IOException { + void loadWithStringWhenClasspathLocationReturnsContent() throws IOException { String actual = PemContent.load("classpath:test-cert.pem").toString(); String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); assertThat(actual).isEqualTo(expected); } @Test - void loadWhenFileLocationReturnsContent() throws IOException { + void loadWithStringWhenFileLocationReturnsContent() throws IOException { String actual = PemContent.load("src/test/resources/test-cert.pem").toString(); String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); assertThat(actual).isEqualTo(expected); } + @Test + void loadWithUrlReturnsContent() throws Exception { + ClassPathResource resource = new ClassPathResource("test-cert.pem"); + String expected = resource.getContentAsString(StandardCharsets.UTF_8); + String actual = PemContent.load(resource.getURL()).toString(); + assertThat(actual).isEqualTo(expected); + } + + @Test + void loadWithPathReturnsContent() throws IOException { + Path path = Path.of("src/test/resources/test-cert.pem"); + String actual = PemContent.load(path).toString(); + String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8); + assertThat(actual).isEqualTo(expected); + } + + @Test + void ofWhenNullReturnsNull() { + assertThat(PemContent.of(null)).isNull(); + } + + @Test + void ofReturnsContent() { + assertThat(PemContent.of("test")).hasToString("test"); + } + + @Test + void hashCodeAndEquals() { + PemContent a = PemContent.of("1"); + PemContent b = PemContent.of("1"); + PemContent c = PemContent.of("2"); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + assertThat(a).isEqualTo(a).isEqualTo(b).isNotEqualTo(c); + } + } 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 index e01584fb3c..22ceb5455b 100644 --- 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 @@ -77,10 +77,7 @@ class PemPrivateKeyParserTests { void shouldNotParseUnsupportedTraditionalPkcs1(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs1/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @ParameterizedTest @@ -120,10 +117,7 @@ class PemPrivateKeyParserTests { void shouldNotParseUnsupportedEcPkcs8(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/pkcs8/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @ParameterizedTest @@ -191,10 +185,7 @@ class PemPrivateKeyParserTests { void shouldNotParseUnsupportedEcSec1(String file) { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser.parse(read("org/springframework/boot/web/server/sec1/" + file))) - .withMessageContaining("Error loading private key file") - .withCauseInstanceOf(IllegalStateException.class) - .havingCause() - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @Test @@ -255,7 +246,7 @@ class PemPrivateKeyParserTests { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser .parse(read("org/springframework/boot/web/server/sec1/prime256v1-aes-128-cbc.key"), "test")) - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } @Test @@ -265,7 +256,7 @@ class PemPrivateKeyParserTests { assertThatIllegalStateException() .isThrownBy(() -> PemPrivateKeyParser .parse(read("org/springframework/boot/web/server/pkcs1/rsa-aes-256-cbc.key"), "test")) - .withMessageContaining("Unrecognized private key format"); + .withMessageContaining("Missing private key or unrecognized format"); } private String read(String path) throws IOException {