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
This commit is contained in:
Phillip Webb
2023-10-27 17:09:30 -07:00
parent 2c6fca8df7
commit 2b39ec6f60
6 changed files with 199 additions and 41 deletions

View File

@@ -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<X509Certificate> certs = new ArrayList<>();
readCertificates(text, factory, certs::add);
Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format");
return List.copyOf(certs);
}

View File

@@ -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<X509Certificate> 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<X509Certificate> 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();
}
}

View File

@@ -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");
}
/**

View File

@@ -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<X509Certificate> certificates = pemContent.getCertificates();
Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty");

View File

@@ -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<X509Certificate> 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);
}
}

View File

@@ -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 {