Skip to content

Commit 2b39ec6

Browse files
committed
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
1 parent 2c6fca8 commit 2b39ec6

File tree

6 files changed

+199
-41
lines changed

6 files changed

+199
-41
lines changed

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemCertificateParser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
import java.util.regex.Matcher;
2828
import java.util.regex.Pattern;
2929

30+
import org.springframework.util.Assert;
31+
import org.springframework.util.CollectionUtils;
32+
3033
/**
3134
* Parser for X.509 certificates in PEM format.
3235
*
@@ -58,6 +61,7 @@ static List<X509Certificate> parse(String text) {
5861
CertificateFactory factory = getCertificateFactory();
5962
List<X509Certificate> certs = new ArrayList<>();
6063
readCertificates(text, factory, certs::add);
64+
Assert.state(!CollectionUtils.isEmpty(certs), "Missing certificates or unrecognized format");
6165
return List.copyOf(certs);
6266
}
6367

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,32 @@
1717
package org.springframework.boot.ssl.pem;
1818

1919
import java.io.IOException;
20-
import java.io.InputStreamReader;
21-
import java.io.Reader;
20+
import java.io.InputStream;
21+
import java.io.UncheckedIOException;
2222
import java.net.URL;
2323
import java.nio.charset.StandardCharsets;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import java.nio.file.StandardOpenOption;
2427
import java.security.PrivateKey;
2528
import java.security.cert.X509Certificate;
2629
import java.util.List;
2730
import java.util.Objects;
2831
import java.util.regex.Pattern;
2932

30-
import org.springframework.util.FileCopyUtils;
33+
import org.springframework.util.Assert;
3134
import org.springframework.util.ResourceUtils;
35+
import org.springframework.util.StreamUtils;
3236

3337
/**
34-
* Utility to load PEM content.
38+
* PEM encoded content that can provide {@link X509Certificate certificates} and
39+
* {@link PrivateKey private keys}.
3540
*
3641
* @author Scott Frederick
3742
* @author Phillip Webb
43+
* @since 3.2.0
3844
*/
39-
final class PemContent {
45+
public final class PemContent {
4046

4147
private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE);
4248

@@ -48,11 +54,32 @@ private PemContent(String text) {
4854
this.text = text;
4955
}
5056

51-
List<X509Certificate> getCertificates() {
57+
/**
58+
* Parse and return all {@link X509Certificate certificates} from the PEM content.
59+
* Most PEM files either contain a single certificate or a certificate chain.
60+
* @return the certificates
61+
* @throws IllegalStateException if no certificates could be loaded
62+
*/
63+
public List<X509Certificate> getCertificates() {
5264
return PemCertificateParser.parse(this.text);
5365
}
5466

55-
PrivateKey getPrivateKeys(String password) {
67+
/**
68+
* Parse and return the {@link PrivateKey private keys} from the PEM content.
69+
* @return the private keys
70+
* @throws IllegalStateException if no private key could be loaded
71+
*/
72+
public PrivateKey getPrivateKey() {
73+
return getPrivateKey(null);
74+
}
75+
76+
/**
77+
* Parse and return the {@link PrivateKey private keys} from the PEM content or
78+
* {@code null} if there is no private key.
79+
* @param password the password to decrypt the private keys or {@code null}
80+
* @return the private keys
81+
*/
82+
public PrivateKey getPrivateKey(String password) {
5683
return PemPrivateKeyParser.parse(this.text, password);
5784
}
5885

@@ -77,27 +104,74 @@ public String toString() {
77104
return this.text;
78105
}
79106

80-
static PemContent load(String content) {
107+
/**
108+
* Load {@link PemContent} from the given content (either the PEM content itself or
109+
* something that can be loaded by {@link ResourceUtils#getURL}).
110+
* @param content the content to load
111+
* @return a new {@link PemContent} instance
112+
* @throws IOException on IO error
113+
*/
114+
static PemContent load(String content) throws IOException {
81115
if (content == null) {
82116
return null;
83117
}
84-
if (isPemContent(content)) {
118+
if (isPresentInText(content)) {
85119
return new PemContent(content);
86120
}
87121
try {
88-
URL url = ResourceUtils.getURL(content);
89-
try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) {
90-
return new PemContent(FileCopyUtils.copyToString(reader));
91-
}
122+
return load(ResourceUtils.getURL(content));
123+
}
124+
catch (IOException | UncheckedIOException ex) {
125+
throw new IOException("Error reading certificate or key from file '%s'".formatted(content), ex);
126+
}
127+
}
128+
129+
/**
130+
* Load {@link PemContent} from the given {@link URL}.
131+
* @param url the URL to load content from
132+
* @return the loaded PEM content
133+
* @throws IOException on IO error
134+
*/
135+
public static PemContent load(URL url) throws IOException {
136+
Assert.notNull(url, "Url must not be null");
137+
try (InputStream in = url.openStream()) {
138+
return load(in);
92139
}
93-
catch (IOException ex) {
94-
throw new IllegalStateException(
95-
"Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex);
140+
}
141+
142+
/**
143+
* Load {@link PemContent} from the given {@link Path}.
144+
* @param path a path to load the content from
145+
* @return the loaded PEM content
146+
* @throws IOException on IO error
147+
*/
148+
public static PemContent load(Path path) throws IOException {
149+
Assert.notNull(path, "Path must not be null");
150+
try (InputStream in = Files.newInputStream(path, StandardOpenOption.READ)) {
151+
return load(in);
96152
}
97153
}
98154

99-
private static boolean isPemContent(String content) {
100-
return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find();
155+
private static PemContent load(InputStream in) throws IOException {
156+
return of(StreamUtils.copyToString(in, StandardCharsets.UTF_8));
157+
}
158+
159+
/**
160+
* Return a new {@link PemContent} instance containing the given text.
161+
* @param text the text containing PEM encoded content
162+
* @return a new {@link PemContent} instance
163+
*/
164+
public static PemContent of(String text) {
165+
return (text != null) ? new PemContent(text) : null;
166+
}
167+
168+
/**
169+
* Return if PEM content is present in the given text.
170+
* @param text the text to check
171+
* @return if the text includes PEM encoded content.
172+
*/
173+
public static boolean isPresentInText(String text) {
174+
return text != null && PEM_HEADER.matcher(text).find() && PEM_FOOTER.matcher(text).find();
101175
}
102176

103177
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemPrivateKeyParser.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,11 @@ static PrivateKey parse(String text, String password) {
194194
return privateKey;
195195
}
196196
}
197-
throw new IllegalStateException("Unrecognized private key format");
198197
}
199198
catch (Exception ex) {
200199
throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex);
201200
}
201+
throw new IllegalStateException("Missing private key or unrecognized format");
202202
}
203203

204204
/**

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,15 @@ private static void verifyKeys(PrivateKey privateKey, X509Certificate[] certific
141141
throw new IllegalStateException("Private key matches none of the certificates");
142142
}
143143

144-
private static PrivateKey loadPrivateKey(PemSslStoreDetails details) {
144+
private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOException {
145145
PemContent pemContent = PemContent.load(details.privateKey());
146146
if (pemContent == null) {
147147
return null;
148148
}
149-
return pemContent.getPrivateKeys(details.privateKeyPassword());
149+
return pemContent.getPrivateKey(details.privateKeyPassword());
150150
}
151151

152-
private static X509Certificate[] loadCertificates(PemSslStoreDetails details) {
152+
private static X509Certificate[] loadCertificates(PemSslStoreDetails details) throws IOException {
153153
PemContent pemContent = PemContent.load(details.certificate());
154154
List<X509Certificate> certificates = pemContent.getCertificates();
155155
Assert.state(!CollectionUtils.isEmpty(certificates), "Loaded certificates are empty");

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/ssl/pem/PemContentTests.java

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818

1919
import java.io.IOException;
2020
import java.nio.charset.StandardCharsets;
21+
import java.nio.file.Path;
22+
import java.security.PrivateKey;
23+
import java.security.cert.X509Certificate;
24+
import java.util.List;
2125

2226
import org.junit.jupiter.api.Test;
2327

2428
import org.springframework.core.io.ClassPathResource;
2529

2630
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
2732

2833
/**
2934
* Tests for {@link PemContent}.
@@ -33,12 +38,61 @@
3338
class PemContentTests {
3439

3540
@Test
36-
void loadWhenContentIsNullReturnsNull() {
37-
assertThat(PemContent.load(null)).isNull();
41+
void getCertificateWhenNoCertificatesThrowsException() {
42+
PemContent content = PemContent.of("");
43+
assertThatIllegalStateException().isThrownBy(content::getCertificates)
44+
.withMessage("Missing certificates or unrecognized format");
3845
}
3946

4047
@Test
41-
void loadWhenContentIsPemContentReturnsContent() {
48+
void getCertificateReturnsCertificates() throws Exception {
49+
PemContent content = PemContent.load(getClass().getResource("/test-cert-chain.pem"));
50+
List<X509Certificate> certificates = content.getCertificates();
51+
assertThat(certificates).isNotNull();
52+
assertThat(certificates).hasSize(2);
53+
assertThat(certificates.get(0).getType()).isEqualTo("X.509");
54+
assertThat(certificates.get(1).getType()).isEqualTo("X.509");
55+
}
56+
57+
@Test
58+
void getPrivateKeyWhenNoKeyThrowsException() {
59+
PemContent content = PemContent.of("");
60+
assertThatIllegalStateException().isThrownBy(content::getPrivateKey)
61+
.withMessage("Missing private key or unrecognized format");
62+
}
63+
64+
@Test
65+
void getPrivateKeyReturnsPrivateKey() throws Exception {
66+
PemContent content = PemContent
67+
.load(getClass().getResource("/org/springframework/boot/web/server/pkcs8/dsa.key"));
68+
PrivateKey privateKey = content.getPrivateKey();
69+
assertThat(privateKey).isNotNull();
70+
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
71+
assertThat(privateKey.getAlgorithm()).isEqualTo("DSA");
72+
}
73+
74+
@Test
75+
void equalsAndHashCode() {
76+
PemContent c1 = PemContent.of("aaa");
77+
PemContent c2 = PemContent.of("aaa");
78+
PemContent c3 = PemContent.of("bbb");
79+
assertThat(c1.hashCode()).isEqualTo(c2.hashCode());
80+
assertThat(c1).isEqualTo(c1).isEqualTo(c2).isNotEqualTo(c3);
81+
}
82+
83+
@Test
84+
void toStringReturnsString() {
85+
PemContent content = PemContent.of("test");
86+
assertThat(content).hasToString("test");
87+
}
88+
89+
@Test
90+
void loadWithStringWhenContentIsNullReturnsNull() throws Exception {
91+
assertThat(PemContent.load((String) null)).isNull();
92+
}
93+
94+
@Test
95+
void loadWithStringWhenContentIsPemContentReturnsContent() throws Exception {
4296
String content = """
4397
-----BEGIN CERTIFICATE-----
4498
MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls
@@ -61,17 +115,52 @@ void loadWhenContentIsPemContentReturnsContent() {
61115
}
62116

63117
@Test
64-
void loadWhenClasspathLocationReturnsContent() throws IOException {
118+
void loadWithStringWhenClasspathLocationReturnsContent() throws IOException {
65119
String actual = PemContent.load("classpath:test-cert.pem").toString();
66120
String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8);
67121
assertThat(actual).isEqualTo(expected);
68122
}
69123

70124
@Test
71-
void loadWhenFileLocationReturnsContent() throws IOException {
125+
void loadWithStringWhenFileLocationReturnsContent() throws IOException {
72126
String actual = PemContent.load("src/test/resources/test-cert.pem").toString();
73127
String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8);
74128
assertThat(actual).isEqualTo(expected);
75129
}
76130

131+
@Test
132+
void loadWithUrlReturnsContent() throws Exception {
133+
ClassPathResource resource = new ClassPathResource("test-cert.pem");
134+
String expected = resource.getContentAsString(StandardCharsets.UTF_8);
135+
String actual = PemContent.load(resource.getURL()).toString();
136+
assertThat(actual).isEqualTo(expected);
137+
}
138+
139+
@Test
140+
void loadWithPathReturnsContent() throws IOException {
141+
Path path = Path.of("src/test/resources/test-cert.pem");
142+
String actual = PemContent.load(path).toString();
143+
String expected = new ClassPathResource("test-cert.pem").getContentAsString(StandardCharsets.UTF_8);
144+
assertThat(actual).isEqualTo(expected);
145+
}
146+
147+
@Test
148+
void ofWhenNullReturnsNull() {
149+
assertThat(PemContent.of(null)).isNull();
150+
}
151+
152+
@Test
153+
void ofReturnsContent() {
154+
assertThat(PemContent.of("test")).hasToString("test");
155+
}
156+
157+
@Test
158+
void hashCodeAndEquals() {
159+
PemContent a = PemContent.of("1");
160+
PemContent b = PemContent.of("1");
161+
PemContent c = PemContent.of("2");
162+
assertThat(a.hashCode()).isEqualTo(b.hashCode());
163+
assertThat(a).isEqualTo(a).isEqualTo(b).isNotEqualTo(c);
164+
}
165+
77166
}

0 commit comments

Comments
 (0)