Skip to content

Commit 408fb8a

Browse files
committed
Add support for EdDSA, XDH and RSA-PSS key parsing
This works with Java 17 and up. Also refactor the test for more structure. Closes gh-37237
1 parent 16d1a31 commit 408fb8a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+536
-78
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
import java.nio.charset.StandardCharsets;
2323
import java.nio.file.Files;
2424
import java.nio.file.Path;
25-
import java.security.GeneralSecurityException;
2625
import java.security.KeyFactory;
26+
import java.security.NoSuchAlgorithmException;
2727
import java.security.PrivateKey;
2828
import java.security.spec.InvalidKeySpecException;
2929
import java.security.spec.PKCS8EncodedKeySpec;
@@ -48,26 +48,28 @@
4848
*/
4949
final class PrivateKeyParser {
5050

51-
private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
51+
private static final String PKCS1_RSA_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
5252

53-
private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
53+
private static final String PKCS1_RSA_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
5454

5555
private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
5656

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

59-
private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
59+
private static final String SEC1_EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
6060

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

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

6565
private static final List<PemParser> PEM_PARSERS;
6666
static {
6767
List<PemParser> parsers = new ArrayList<>();
68-
parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, PrivateKeyParser::createKeySpecForPkcs1, "RSA"));
69-
parsers.add(new PemParser(EC_HEADER, EC_FOOTER, PrivateKeyParser::createKeySpecForEc, "EC"));
70-
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "EC", "DSA"));
68+
parsers
69+
.add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PrivateKeyParser::createKeySpecForPkcs1Rsa, "RSA"));
70+
parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PrivateKeyParser::createKeySpecForSec1Ec, "EC"));
71+
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "RSASSA-PSS", "EC",
72+
"DSA", "EdDSA", "XDH"));
7173
PEM_PARSERS = Collections.unmodifiableList(parsers);
7274
}
7375

@@ -89,11 +91,11 @@ final class PrivateKeyParser {
8991
private PrivateKeyParser() {
9092
}
9193

92-
private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) {
94+
private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes) {
9395
return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null);
9496
}
9597

96-
private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) {
98+
private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes) {
9799
DerElement ecPrivateKey = DerElement.of(bytes);
98100
Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE),
99101
"Key spec should be an ASN.1 encoded sequence");
@@ -200,21 +202,16 @@ private static byte[] decodeBase64(String content) {
200202
}
201203

202204
private PrivateKey parse(byte[] bytes) {
203-
try {
204-
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
205-
for (String algorithm : this.algorithms) {
205+
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
206+
for (String algorithm : this.algorithms) {
207+
try {
206208
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
207-
try {
208-
return keyFactory.generatePrivate(keySpec);
209-
}
210-
catch (InvalidKeySpecException ex) {
211-
}
209+
return keyFactory.generatePrivate(keySpec);
210+
}
211+
catch (InvalidKeySpecException | NoSuchAlgorithmException ex) {
212212
}
213-
return null;
214-
}
215-
catch (GeneralSecurityException ex) {
216-
throw new IllegalArgumentException("Unexpected key format", ex);
217213
}
214+
return null;
218215
}
219216

220217
}
@@ -302,7 +299,7 @@ static final class DerElement {
302299

303300
private final long tagType;
304301

305-
private ByteBuffer contents;
302+
private final ByteBuffer contents;
306303

307304
private DerElement(ByteBuffer bytes) {
308305
byte b = bytes.get();

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/server/PrivateKeyParser.java

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
import java.io.Reader;
2323
import java.net.URL;
2424
import java.nio.ByteBuffer;
25-
import java.security.GeneralSecurityException;
2625
import java.security.KeyFactory;
26+
import java.security.NoSuchAlgorithmException;
2727
import java.security.PrivateKey;
2828
import java.security.spec.InvalidKeySpecException;
2929
import java.security.spec.PKCS8EncodedKeySpec;
@@ -50,26 +50,28 @@
5050
*/
5151
final class PrivateKeyParser {
5252

53-
private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
53+
private static final String PKCS1_RSA_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
5454

55-
private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
55+
private static final String PKCS1_RSA_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+";
5656

5757
private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
5858

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

61-
private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
61+
private static final String SEC1_EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+";
6262

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

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

6767
private static final List<PemParser> PEM_PARSERS;
6868
static {
6969
List<PemParser> parsers = new ArrayList<>();
70-
parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, PrivateKeyParser::createKeySpecForPkcs1, "RSA"));
71-
parsers.add(new PemParser(EC_HEADER, EC_FOOTER, PrivateKeyParser::createKeySpecForEc, "EC"));
72-
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "EC", "DSA"));
70+
parsers
71+
.add(new PemParser(PKCS1_RSA_HEADER, PKCS1_RSA_FOOTER, PrivateKeyParser::createKeySpecForPkcs1Rsa, "RSA"));
72+
parsers.add(new PemParser(SEC1_EC_HEADER, SEC1_EC_FOOTER, PrivateKeyParser::createKeySpecForSec1Ec, "EC"));
73+
parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, PKCS8EncodedKeySpec::new, "RSA", "RSASSA-PSS", "EC",
74+
"DSA", "EdDSA", "XDH"));
7375
PEM_PARSERS = Collections.unmodifiableList(parsers);
7476
}
7577

@@ -91,11 +93,11 @@ final class PrivateKeyParser {
9193
private PrivateKeyParser() {
9294
}
9395

94-
private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) {
96+
private static PKCS8EncodedKeySpec createKeySpecForPkcs1Rsa(byte[] bytes) {
9597
return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null);
9698
}
9799

98-
private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) {
100+
private static PKCS8EncodedKeySpec createKeySpecForSec1Ec(byte[] bytes) {
99101
DerElement ecPrivateKey = DerElement.of(bytes);
100102
Assert.state(ecPrivateKey.isType(ValueType.ENCODED, TagType.SEQUENCE),
101103
"Key spec should be an ASN.1 encoded sequence");
@@ -203,21 +205,16 @@ private static byte[] decodeBase64(String content) {
203205
}
204206

205207
private PrivateKey parse(byte[] bytes) {
206-
try {
207-
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
208-
for (String algorithm : this.algorithms) {
208+
PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes);
209+
for (String algorithm : this.algorithms) {
210+
try {
209211
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
210-
try {
211-
return keyFactory.generatePrivate(keySpec);
212-
}
213-
catch (InvalidKeySpecException ex) {
214-
}
212+
return keyFactory.generatePrivate(keySpec);
213+
}
214+
catch (InvalidKeySpecException | NoSuchAlgorithmException ex) {
215215
}
216-
return null;
217-
}
218-
catch (GeneralSecurityException ex) {
219-
throw new IllegalArgumentException("Unexpected key format", ex);
220216
}
217+
return null;
221218
}
222219

223220
}
@@ -305,7 +302,7 @@ static final class DerElement {
305302

306303
private final long tagType;
307304

308-
private ByteBuffer contents;
305+
private final ByteBuffer contents;
309306

310307
private DerElement(ByteBuffer bytes) {
311308
byte b = bytes.get();

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/web/server/PrivateKeyParserTests.java

Lines changed: 141 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@
2020
import java.security.interfaces.ECPrivateKey;
2121

2222
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.condition.EnabledForJreRange;
24+
import org.junit.jupiter.api.condition.JRE;
2325
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.CsvSource;
2427
import org.junit.jupiter.params.provider.ValueSource;
2528

2629
import static org.assertj.core.api.Assertions.assertThat;
2730
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
31+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2832

2933
/**
3034
* Tests for {@link PrivateKeyParser}.
@@ -33,50 +37,166 @@
3337
* @author Moritz Halbritter
3438
* @author Phillip Webb
3539
*/
40+
// https://docs.oracle.com/en/java/javase/17/security/oracle-providers.html#GUID-091BF58C-82AB-4C9C-850F-1660824D5254
3641
class PrivateKeyParserTests {
3742

38-
@Test
39-
void parsePkcs8RsaKeyFile() {
40-
PrivateKey privateKey = PrivateKeyParser.parse("classpath:ssl/pkcs8/key-rsa.pem");
43+
@ParameterizedTest
44+
// @formatter:off
45+
@CsvSource({
46+
"dsa.key, DSA",
47+
"rsa.key, RSA",
48+
"rsa-pss.key, RSASSA-PSS"
49+
})
50+
// @formatter:on
51+
void shouldParseTraditionalPkcs8(String file, String algorithm) {
52+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs8/" + file);
53+
assertThat(privateKey).isNotNull();
54+
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
55+
assertThat(privateKey.getAlgorithm()).isEqualTo(algorithm);
56+
}
57+
58+
@ParameterizedTest
59+
// @formatter:off
60+
@CsvSource({
61+
"rsa.key, RSA"
62+
})
63+
// @formatter:on
64+
void shouldParseTraditionalPkcs1(String file, String algorithm) {
65+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs1/" + file);
4166
assertThat(privateKey).isNotNull();
4267
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
43-
assertThat(privateKey.getAlgorithm()).isEqualTo("RSA");
68+
assertThat(privateKey.getAlgorithm()).isEqualTo(algorithm);
69+
}
70+
71+
@ParameterizedTest
72+
// @formatter:off
73+
@ValueSource(strings = {
74+
"dsa.key"
75+
})
76+
// @formatter:on
77+
void shouldNotParseUnsupportedTraditionalPkcs1(String file) {
78+
assertThatThrownBy(() -> PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs1/" + file))
79+
.isInstanceOf(IllegalStateException.class)
80+
.hasMessageContaining("Error loading private key file")
81+
.hasCauseInstanceOf(IllegalStateException.class)
82+
.getCause()
83+
.hasMessageContaining("Unrecognized private key format");
4484
}
4585

4686
@ParameterizedTest
47-
@ValueSource(strings = { "key-ec-nist-p256.pem", "key-ec-nist-p384.pem", "key-ec-prime256v1.pem",
48-
"key-ec-secp256r1.pem" })
49-
void parsePkcs8EcKeyFile(String fileName) {
50-
PrivateKey privateKey = PrivateKeyParser.parse("classpath:ssl/pkcs8/" + fileName);
87+
// @formatter:off
88+
@CsvSource({
89+
"brainpoolP256r1.key, brainpoolP256r1, 1.3.36.3.3.2.8.1.1.7",
90+
"brainpoolP320r1.key, brainpoolP320r1, 1.3.36.3.3.2.8.1.1.9",
91+
"brainpoolP384r1.key, brainpoolP384r1, 1.3.36.3.3.2.8.1.1.11",
92+
"brainpoolP512r1.key, brainpoolP512r1, 1.3.36.3.3.2.8.1.1.13",
93+
"prime256v1.key, secp256r1, 1.2.840.10045.3.1.7",
94+
"secp224r1.key, secp224r1, 1.3.132.0.33",
95+
"secp256k1.key, secp256k1, 1.3.132.0.10",
96+
"secp256r1.key, secp256r1, 1.2.840.10045.3.1.7",
97+
"secp384r1.key, secp384r1, 1.3.132.0.34",
98+
"secp521r1.key, secp521r1, 1.3.132.0.35"
99+
})
100+
// @formatter:on
101+
void shouldParseEcPkcs8(String file, String curveName, String oid) {
102+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs8/" + file);
51103
assertThat(privateKey).isNotNull();
52104
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
53105
assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
106+
assertThat(privateKey).isInstanceOf(ECPrivateKey.class);
107+
ECPrivateKey ecPrivateKey = (ECPrivateKey) privateKey;
108+
assertThat(ecPrivateKey.getParams().toString()).contains(curveName).contains(oid);
54109
}
55110

56-
@Test
57-
void parsePkcs8DsaKeyFile() {
58-
PrivateKey privateKey = PrivateKeyParser.parse("classpath:ssl/pkcs8/key-dsa.pem");
111+
@ParameterizedTest
112+
// @formatter:off
113+
@ValueSource(strings = {
114+
"brainpoolP256t1.key",
115+
"brainpoolP320t1.key",
116+
"brainpoolP384t1.key",
117+
"brainpoolP512t1.key"
118+
})
119+
// @formatter:on
120+
void shouldNotParseUnsupportedEcPkcs8(String file) {
121+
assertThatThrownBy(() -> PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs8/" + file))
122+
.isInstanceOf(IllegalStateException.class)
123+
.hasMessageContaining("Error loading private key file")
124+
.hasCauseInstanceOf(IllegalStateException.class)
125+
.getCause()
126+
.hasMessageContaining("Unrecognized private key format");
127+
}
128+
129+
@EnabledForJreRange(min = JRE.JAVA_17, disabledReason = "EdDSA is only supported since Java 17")
130+
@ParameterizedTest
131+
// @formatter:off
132+
@ValueSource(strings = {
133+
"ed448.key",
134+
"ed25519.key"
135+
})
136+
// @formatter:on
137+
void shouldParseEdDsaPkcs8(String file) {
138+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs8/" + file);
59139
assertThat(privateKey).isNotNull();
60140
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
61-
assertThat(privateKey.getAlgorithm()).isEqualTo("DSA");
141+
assertThat(privateKey.getAlgorithm()).isEqualTo("EdDSA");
62142
}
63143

64-
@Test
65-
void parsePemKeyFileWithEcdsa() {
66-
ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse("classpath:test-ec-key.pem");
144+
@EnabledForJreRange(min = JRE.JAVA_17, disabledReason = "XDH is only supported since Java 17")
145+
@ParameterizedTest
146+
// @formatter:off
147+
@ValueSource(strings = {
148+
"x448.key",
149+
"x25519.key"
150+
})
151+
// @formatter:on
152+
void shouldParseXdhPkcs8(String file) {
153+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/pkcs8/" + file);
67154
assertThat(privateKey).isNotNull();
68155
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
69-
assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
70-
assertThat(privateKey.getParams().toString()).contains("1.3.132.0.34").doesNotContain("prime256v1");
156+
assertThat(privateKey.getAlgorithm()).isEqualTo("XDH");
71157
}
72158

73-
@Test
74-
void parsePemKeyFileWithEcdsaPrime256v1() {
75-
ECPrivateKey privateKey = (ECPrivateKey) PrivateKeyParser.parse("classpath:test-ec-key-prime256v1.pem");
159+
@ParameterizedTest
160+
// @formatter:off
161+
@CsvSource({
162+
"brainpoolP256r1.key, brainpoolP256r1, 1.3.36.3.3.2.8.1.1.7",
163+
"brainpoolP320r1.key, brainpoolP320r1, 1.3.36.3.3.2.8.1.1.9",
164+
"brainpoolP384r1.key, brainpoolP384r1, 1.3.36.3.3.2.8.1.1.11",
165+
"brainpoolP512r1.key, brainpoolP512r1, 1.3.36.3.3.2.8.1.1.13",
166+
"prime256v1.key, secp256r1, 1.2.840.10045.3.1.7",
167+
"secp224r1.key, secp224r1, 1.3.132.0.33",
168+
"secp256k1.key, secp256k1, 1.3.132.0.10",
169+
"secp256r1.key, secp256r1, 1.2.840.10045.3.1.7",
170+
"secp384r1.key, secp384r1, 1.3.132.0.34",
171+
"secp521r1.key, secp521r1, 1.3.132.0.35"
172+
})
173+
// @formatter:on
174+
void shouldParseEcSec1(String file, String curveName, String oid) {
175+
PrivateKey privateKey = PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/sec1/" + file);
76176
assertThat(privateKey).isNotNull();
77177
assertThat(privateKey.getFormat()).isEqualTo("PKCS#8");
78178
assertThat(privateKey.getAlgorithm()).isEqualTo("EC");
79-
assertThat(privateKey.getParams().toString()).contains("prime256v1").doesNotContain("1.3.132.0.34");
179+
assertThat(privateKey).isInstanceOf(ECPrivateKey.class);
180+
ECPrivateKey ecPrivateKey = (ECPrivateKey) privateKey;
181+
assertThat(ecPrivateKey.getParams().toString()).contains(curveName).contains(oid);
182+
}
183+
184+
@ParameterizedTest
185+
// @formatter:off
186+
@ValueSource(strings = {
187+
"brainpoolP256t1.key",
188+
"brainpoolP320t1.key",
189+
"brainpoolP384t1.key",
190+
"brainpoolP512t1.key"
191+
})
192+
// @formatter:on
193+
void shouldNotParseUnsupportedEcSec1(String file) {
194+
assertThatThrownBy(() -> PrivateKeyParser.parse("classpath:org/springframework/boot/web/server/sec1/" + file))
195+
.isInstanceOf(IllegalStateException.class)
196+
.hasMessageContaining("Error loading private key file")
197+
.hasCauseInstanceOf(IllegalStateException.class)
198+
.getCause()
199+
.hasMessageContaining("Unrecognized private key format");
80200
}
81201

82202
@Test

0 commit comments

Comments
 (0)