Skip to content

Commit 767ec4e

Browse files
Support encrypted PKCS8 private keys in SSL bundles
Properties `ssl.bundle.pem.mybundle.keystore.private-key-password` and `ssl.bundle.pem.mybundle.truststore.private-key-password` have been added for configuring the password required to decrypt an encrypted private key. Only PKCS8 private keys with encryption are supported. PKCS1 and EC private keys with encryption are much more complex to decrypt, and are not supported. Fixes gh-35652
1 parent 7fcfcad commit 767ec4e

File tree

12 files changed

+355
-17
lines changed

12 files changed

+355
-17
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PemSslBundleProperties.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public static class Store {
6666
*/
6767
String privateKey;
6868

69+
/**
70+
* Password used to decrypt an encrypted private key.
71+
*/
72+
String privateKeyPassword;
73+
6974
public String getType() {
7075
return this.type;
7176
}
@@ -90,6 +95,14 @@ public void setPrivateKey(String privateKey) {
9095
this.privateKey = privateKey;
9196
}
9297

98+
public String getPrivateKeyPassword() {
99+
return this.privateKeyPassword;
100+
}
101+
102+
public void setPrivateKeyPassword(String privateKeyPassword) {
103+
this.privateKeyPassword = privateKeyPassword;
104+
}
105+
93106
}
94107

95108
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ private static SslStoreBundle asSslStoreBundle(PemSslBundleProperties properties
113113
}
114114

115115
private static PemSslStoreDetails asStoreDetails(PemSslBundleProperties.Store properties) {
116-
return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey());
116+
return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(),
117+
properties.getPrivateKeyPassword());
117118
}
118119

119120
private static SslStoreBundle asSslStoreBundle(JksSslBundleProperties properties) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2012-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.ssl;
18+
19+
import java.util.Set;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.boot.ssl.SslBundle;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
27+
/**
28+
* Tests for {@link PropertiesSslBundle}.
29+
*
30+
* @author Scott Frederick
31+
*/
32+
class PropertiesSslBundleTests {
33+
34+
@Test
35+
void pemPropertiesAreMappedToSslBundle() {
36+
PemSslBundleProperties properties = new PemSslBundleProperties();
37+
properties.getKey().setAlias("alias");
38+
properties.getKey().setPassword("secret");
39+
properties.getOptions().setCiphers(Set.of("cipher1", "cipher2", "cipher3"));
40+
properties.getOptions().setEnabledProtocols(Set.of("protocol1", "protocol2"));
41+
properties.getKeystore().setCertificate("cert1.pem");
42+
properties.getKeystore().setPrivateKey("key1.pem");
43+
properties.getKeystore().setPrivateKeyPassword("keysecret1");
44+
properties.getKeystore().setType("PKCS12");
45+
properties.getTruststore().setCertificate("cert2.pem");
46+
properties.getTruststore().setPrivateKey("key2.pem");
47+
properties.getTruststore().setPrivateKeyPassword("keysecret2");
48+
properties.getTruststore().setType("JKS");
49+
SslBundle sslBundle = PropertiesSslBundle.get(properties);
50+
assertThat(sslBundle.getKey().getAlias()).isEqualTo("alias");
51+
assertThat(sslBundle.getKey().getPassword()).isEqualTo("secret");
52+
assertThat(sslBundle.getOptions().getCiphers()).containsExactlyInAnyOrder("cipher1", "cipher2", "cipher3");
53+
assertThat(sslBundle.getOptions().getEnabledProtocols()).containsExactlyInAnyOrder("protocol1", "protocol2");
54+
assertThat(sslBundle.getStores()).isNotNull();
55+
assertThat(sslBundle.getStores()).extracting("keyStoreDetails")
56+
.extracting("certificate", "privateKey", "privateKeyPassword", "type")
57+
.containsExactly("cert1.pem", "key1.pem", "keysecret1", "PKCS12");
58+
assertThat(sslBundle.getStores()).extracting("trustStoreDetails")
59+
.extracting("certificate", "privateKey", "privateKeyPassword", "type")
60+
.containsExactly("cert2.pem", "key2.pem", "keysecret2", "JKS");
61+
}
62+
63+
@Test
64+
void jksPropertiesAreMappedToSslBundle() {
65+
JksSslBundleProperties properties = new JksSslBundleProperties();
66+
properties.getKey().setAlias("alias");
67+
properties.getKey().setPassword("secret");
68+
properties.getOptions().setCiphers(Set.of("cipher1", "cipher2", "cipher3"));
69+
properties.getOptions().setEnabledProtocols(Set.of("protocol1", "protocol2"));
70+
properties.getKeystore().setLocation("cert1.p12");
71+
properties.getKeystore().setPassword("secret1");
72+
properties.getKeystore().setProvider("provider1");
73+
properties.getKeystore().setType("JKS");
74+
properties.getTruststore().setLocation("cert2.jks");
75+
properties.getTruststore().setPassword("secret2");
76+
properties.getTruststore().setProvider("provider2");
77+
properties.getTruststore().setType("PKCS12");
78+
SslBundle sslBundle = PropertiesSslBundle.get(properties);
79+
assertThat(sslBundle.getKey().getAlias()).isEqualTo("alias");
80+
assertThat(sslBundle.getKey().getPassword()).isEqualTo("secret");
81+
assertThat(sslBundle.getOptions().getCiphers()).containsExactlyInAnyOrder("cipher1", "cipher2", "cipher3");
82+
assertThat(sslBundle.getOptions().getEnabledProtocols()).containsExactlyInAnyOrder("protocol1", "protocol2");
83+
assertThat(sslBundle.getStores()).isNotNull();
84+
assertThat(sslBundle.getStores()).extracting("keyStoreDetails")
85+
.extracting("location", "password", "provider", "type")
86+
.containsExactly("cert1.p12", "secret1", "provider1", "JKS");
87+
assertThat(sslBundle.getStores()).extracting("trustStoreDetails")
88+
.extracting("location", "password", "provider", "type")
89+
.containsExactly("cert2.jks", "secret2", "provider2", "PKCS12");
90+
}
91+
92+
}

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

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.ByteArrayOutputStream;
2020
import java.io.IOException;
21+
import java.security.AlgorithmParameters;
2122
import java.security.GeneralSecurityException;
2223
import java.security.KeyFactory;
2324
import java.security.PrivateKey;
@@ -27,10 +28,18 @@
2728
import java.util.Base64;
2829
import java.util.Collections;
2930
import java.util.List;
30-
import java.util.function.Function;
31+
import java.util.function.BiFunction;
3132
import java.util.regex.Matcher;
3233
import java.util.regex.Pattern;
3334

35+
import javax.crypto.Cipher;
36+
import javax.crypto.EncryptedPrivateKeyInfo;
37+
import javax.crypto.SecretKey;
38+
import javax.crypto.SecretKeyFactory;
39+
import javax.crypto.spec.PBEKeySpec;
40+
41+
import org.springframework.util.Assert;
42+
3443
/**
3544
* Parser for PKCS private key files in PEM format.
3645
*
@@ -48,18 +57,27 @@ final class PemPrivateKeyParser {
4857

4958
private static final String PKCS8_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+";
5059

60+
private static final String PKCS8_ENCRYPTED_HEADER = "-+BEGIN\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
61+
62+
private static final String PKCS8_ENCRYPTED_FOOTER = "-+END\\s+ENCRYPTED\\s+PRIVATE\\s+KEY[^-]*-+";
63+
5164
private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
5265

5366
private static final String EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+";
5467

5568
private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)";
5669

70+
public static final int BASE64_TEXT_GROUP = 1;
71+
5772
private static final List<PemParser> PEM_PARSERS;
5873
static {
5974
List<PemParser> parsers = new ArrayList<>();
6075
parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs1, "RSA"));
6176
parsers.add(new PemParser(EC_HEADER, EC_FOOTER, PemPrivateKeyParser::createKeySpecForEc, "EC"));
62-
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "EC", "DSA", "Ed25519"));
77+
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PemPrivateKeyParser::createKeySpecForPkcs8, "RSA", "EC",
78+
"DSA", "Ed25519"));
79+
parsers.add(new PemParser(PKCS8_ENCRYPTED_HEADER, PKCS8_ENCRYPTED_FOOTER,
80+
PemPrivateKeyParser::createKeySpecForPkcs8Encrypted, "RSA", "EC", "DSA", "Ed25519"));
6381
PEM_PARSERS = Collections.unmodifiableList(parsers);
6482
}
6583

@@ -81,11 +99,11 @@ final class PemPrivateKeyParser {
8199
private PemPrivateKeyParser() {
82100
}
83101

84-
private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) {
102+
private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes, String password) {
85103
return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null);
86104
}
87105

88-
private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) {
106+
private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes, String password) {
89107
return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, EC_PARAMETERS);
90108
}
91109

@@ -106,18 +124,37 @@ private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[]
106124
}
107125
}
108126

127+
private static PKCS8EncodedKeySpec createKeySpecForPkcs8(byte[] bytes, String password) {
128+
return new PKCS8EncodedKeySpec(bytes);
129+
}
130+
131+
private static PKCS8EncodedKeySpec createKeySpecForPkcs8Encrypted(byte[] bytes, String password) {
132+
return Pkcs8PrivateKeyDecryptor.decrypt(bytes, password);
133+
}
134+
109135
/**
110136
* Parse a private key from the specified string.
111137
* @param key the private key to parse
112138
* @return the parsed private key
113139
*/
114140
static PrivateKey parse(String key) {
141+
return parse(key, null);
142+
}
143+
144+
/**
145+
* Parse a private key from the specified string, using the provided password for
146+
* decryption if necessary.
147+
* @param key the private key to parse
148+
* @param password the password used to decrypt an encrypted private key
149+
* @return the parsed private key
150+
*/
151+
static PrivateKey parse(String key, String password) {
115152
if (key == null) {
116153
return null;
117154
}
118155
try {
119156
for (PemParser pemParser : PEM_PARSERS) {
120-
PrivateKey privateKey = pemParser.parse(key);
157+
PrivateKey privateKey = pemParser.parse(key, password);
121158
if (privateKey != null) {
122159
return privateKey;
123160
}
@@ -136,30 +173,30 @@ private static class PemParser {
136173

137174
private final Pattern pattern;
138175

139-
private final Function<byte[], PKCS8EncodedKeySpec> keySpecFactory;
176+
private final BiFunction<byte[], String, PKCS8EncodedKeySpec> keySpecFactory;
140177

141178
private final String[] algorithms;
142179

143-
PemParser(String header, String footer, Function<byte[], PKCS8EncodedKeySpec> keySpecFactory,
180+
PemParser(String header, String footer, BiFunction<byte[], String, PKCS8EncodedKeySpec> keySpecFactory,
144181
String... algorithms) {
145182
this.pattern = Pattern.compile(header + BASE64_TEXT + footer, Pattern.CASE_INSENSITIVE);
146-
this.algorithms = algorithms;
147183
this.keySpecFactory = keySpecFactory;
184+
this.algorithms = algorithms;
148185
}
149186

150-
PrivateKey parse(String text) {
187+
PrivateKey parse(String text, String password) {
151188
Matcher matcher = this.pattern.matcher(text);
152-
return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(1)));
189+
return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(BASE64_TEXT_GROUP)), password);
153190
}
154191

155192
private static byte[] decodeBase64(String content) {
156193
byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes();
157194
return Base64.getDecoder().decode(contentBytes);
158195
}
159196

160-
private PrivateKey parse(byte[] bytes) {
197+
private PrivateKey parse(byte[] bytes, String password) {
161198
try {
162-
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
199+
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes, password);
163200
for (String algorithm : this.algorithms) {
164201
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
165202
try {
@@ -251,4 +288,34 @@ byte[] toByteArray() {
251288

252289
}
253290

291+
static class Pkcs8PrivateKeyDecryptor {
292+
293+
public static final String PBES2_ALGORITHM = "PBES2";
294+
295+
static PKCS8EncodedKeySpec decrypt(byte[] bytes, String password) {
296+
Assert.notNull(password, "Password is required for an encrypted private key");
297+
try {
298+
EncryptedPrivateKeyInfo keyInfo = new EncryptedPrivateKeyInfo(bytes);
299+
AlgorithmParameters algorithmParameters = keyInfo.getAlgParameters();
300+
String encryptionAlgorithm = getEncryptionAlgorithm(algorithmParameters, keyInfo.getAlgName());
301+
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptionAlgorithm);
302+
SecretKey key = keyFactory.generateSecret(new PBEKeySpec(password.toCharArray()));
303+
Cipher cipher = Cipher.getInstance(encryptionAlgorithm);
304+
cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters);
305+
return keyInfo.getKeySpec(cipher);
306+
}
307+
catch (IOException | GeneralSecurityException ex) {
308+
throw new IllegalArgumentException("Error decrypting private key", ex);
309+
}
310+
}
311+
312+
private static String getEncryptionAlgorithm(AlgorithmParameters algParameters, String algName) {
313+
if (algParameters != null && PBES2_ALGORITHM.equals(algName)) {
314+
return algParameters.toString();
315+
}
316+
return algName;
317+
}
318+
319+
}
320+
254321
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,14 @@ private KeyStore createKeyStore(String name, PemSslStoreDetails details) {
8484
return null;
8585
}
8686
try {
87-
Assert.notNull(details.certificate(), "CertificateContent must not be null");
87+
Assert.notNull(details.certificate(), "Certificate content must not be null");
8888
String type = (!StringUtils.hasText(details.type())) ? KeyStore.getDefaultType() : details.type();
8989
KeyStore store = KeyStore.getInstance(type);
9090
store.load(null);
9191
String certificateContent = PemContent.load(details.certificate());
9292
String privateKeyContent = PemContent.load(details.privateKey());
9393
X509Certificate[] certificates = PemCertificateParser.parse(certificateContent);
94-
PrivateKey privateKey = PemPrivateKeyParser.parse(privateKeyContent);
94+
PrivateKey privateKey = PemPrivateKeyParser.parse(privateKeyContent, details.privateKeyPassword());
9595
addCertificates(store, certificates, privateKey);
9696
return store;
9797
}

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,33 @@
3030
* that can be loaded by {@link ResourceUtils#getURL})
3131
* @param privateKey the private key content (either the PEM content itself or something
3232
* that can be loaded by {@link ResourceUtils#getURL})
33+
* @param privateKeyPassword a password used to decrypt an encrypted private key
3334
* @author Scott Frederick
3435
* @author Phillip Webb
3536
* @since 3.1.0
3637
*/
37-
public record PemSslStoreDetails(String type, String certificate, String privateKey) {
38+
public record PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) {
39+
40+
public PemSslStoreDetails(String type, String certificate, String privateKey) {
41+
this(type, certificate, privateKey, null);
42+
}
3843

3944
/**
4045
* Return a new {@link PemSslStoreDetails} instance with a new private key.
4146
* @param privateKey the new private key
4247
* @return a new {@link PemSslStoreDetails} instance
4348
*/
4449
public PemSslStoreDetails withPrivateKey(String privateKey) {
45-
return new PemSslStoreDetails(this.type, this.certificate, privateKey);
50+
return new PemSslStoreDetails(this.type, this.certificate, privateKey, this.privateKeyPassword);
51+
}
52+
53+
/**
54+
* Return a new {@link PemSslStoreDetails} instance with a new private key password.
55+
* @param password the new private key password
56+
* @return a new {@link PemSslStoreDetails} instance
57+
*/
58+
public PemSslStoreDetails withPrivateKeyPassword(String password) {
59+
return new PemSslStoreDetails(this.type, this.certificate, this.privateKey, password);
4660
}
4761

4862
boolean isEmpty() {

0 commit comments

Comments
 (0)