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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user