diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java new file mode 100644 index 00000000000..343305fd289 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcher.java @@ -0,0 +1,113 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.Certificate; +import java.util.List; +import java.util.Objects; + +/** + * Helper used to match certificates against a {@link PrivateKey}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class CertificateMatcher { + + private static final byte[] DATA = new byte[256]; + static { + for (int i = 0; i < DATA.length; i++) { + DATA[i] = (byte) i; + } + } + + private final PrivateKey privateKey; + + private final Signature signature; + + private final byte[] generatedSignature; + + CertificateMatcher(PrivateKey privateKey) { + this.privateKey = privateKey; + this.signature = createSignature(privateKey); + this.generatedSignature = sign(this.signature, privateKey); + } + + private Signature createSignature(PrivateKey privateKey) { + try { + String algorithm = getSignatureAlgorithm(this.privateKey); + return (algorithm != null) ? Signature.getInstance(algorithm) : null; + } + catch (NoSuchAlgorithmException ex) { + return null; + } + } + + private static String getSignatureAlgorithm(PrivateKey privateKey) { + // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#signature-algorithms + // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#keypairgenerator-algorithms + return switch (privateKey.getAlgorithm()) { + case "RSA" -> "SHA256withRSA"; + case "DSA" -> "SHA256withDSA"; + case "EC" -> "SHA256withECDSA"; + case "EdDSA" -> "EdDSA"; + default -> null; + }; + } + + boolean matchesAny(List certificates) { + return (this.generatedSignature != null) && certificates.stream().anyMatch(this::matches); + } + + boolean matches(Certificate certificate) { + return matches(certificate.getPublicKey()); + } + + private boolean matches(PublicKey publicKey) { + return (this.generatedSignature != null) + && Objects.equals(this.privateKey.getAlgorithm(), publicKey.getAlgorithm()) && verify(publicKey); + } + + private boolean verify(PublicKey publicKey) { + try { + this.signature.initVerify(publicKey); + this.signature.update(DATA); + return this.signature.verify(this.generatedSignature); + } + catch (InvalidKeyException | SignatureException ex) { + return false; + } + } + + private static byte[] sign(Signature signature, PrivateKey privateKey) { + try { + signature.initSign(privateKey); + signature.update(DATA); + return signature.sign(); + } + catch (InvalidKeyException | SignatureException ex) { + return null; + } + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/KeyVerifier.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/KeyVerifier.java deleted file mode 100644 index 65d68134e06..00000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/KeyVerifier.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.ssl; - -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.Signature; -import java.security.SignatureException; - -/** - * Performs checks on keys, e.g., if a public key and a private key belong together. - * - * @author Moritz Halbritter - */ -class KeyVerifier { - - private static final byte[] DATA = "Just some piece of data which gets signed".getBytes(StandardCharsets.UTF_8); - - /** - * Checks if the given private key belongs to the given public key. - * @param privateKey the private key - * @param publicKey the public key - * @return whether the keys belong together - */ - Result matches(PrivateKey privateKey, PublicKey publicKey) { - try { - if (!privateKey.getAlgorithm().equals(publicKey.getAlgorithm())) { - // Keys are of different type - return Result.NO; - } - String algorithm = getSignatureAlgorithm(privateKey.getAlgorithm()); - if (algorithm == null) { - return Result.UNKNOWN; - } - byte[] signature = createSignature(privateKey, algorithm); - return verifySignature(publicKey, algorithm, signature); - } - catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException ex) { - return Result.UNKNOWN; - } - } - - private static byte[] createSignature(PrivateKey privateKey, String algorithm) - throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { - Signature signer = Signature.getInstance(algorithm); - signer.initSign(privateKey); - signer.update(DATA); - return signer.sign(); - } - - private static Result verifySignature(PublicKey publicKey, String algorithm, byte[] signature) - throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { - Signature verifier = Signature.getInstance(algorithm); - verifier.initVerify(publicKey); - verifier.update(DATA); - try { - if (verifier.verify(signature)) { - return Result.YES; - } - else { - return Result.NO; - } - } - catch (SignatureException ex) { - return Result.NO; - } - } - - private static String getSignatureAlgorithm(String keyAlgorithm) { - // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#signature-algorithms - // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#keypairgenerator-algorithms - return switch (keyAlgorithm) { - case "RSA" -> "SHA256withRSA"; - case "DSA" -> "SHA256withDSA"; - case "EC" -> "SHA256withECDSA"; - case "EdDSA" -> "EdDSA"; - default -> null; - }; - } - - enum Result { - - YES, NO, UNKNOWN - - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java index 12a1847b24c..184c31b1719 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java @@ -18,7 +18,6 @@ package org.springframework.boot.autoconfigure.ssl; import java.io.IOException; import java.io.UncheckedIOException; -import java.security.cert.X509Certificate; import org.springframework.boot.autoconfigure.ssl.SslBundleProperties.Key; import org.springframework.boot.ssl.SslBundle; @@ -31,6 +30,7 @@ import org.springframework.boot.ssl.jks.JksSslStoreDetails; import org.springframework.boot.ssl.pem.PemSslStore; import org.springframework.boot.ssl.pem.PemSslStoreBundle; import org.springframework.boot.ssl.pem.PemSslStoreDetails; +import org.springframework.util.Assert; /** * {@link SslBundle} backed by {@link JksSslBundleProperties} or @@ -122,7 +122,9 @@ public final class PropertiesSslBundle implements SslBundle { PemSslStoreDetails details = asStoreDetails(properties, alias); PemSslStore pemSslStore = PemSslStore.load(details); if (properties.isVerifyKeys()) { - verifyPemSslStoreKeys(pemSslStore); + CertificateMatcher certificateMatcher = new CertificateMatcher(pemSslStore.privateKey()); + Assert.state(certificateMatcher.matchesAny(pemSslStore.certificates()), + "Private key matches none of the certificates in the chain"); } return pemSslStore; } @@ -131,17 +133,6 @@ public final class PropertiesSslBundle implements SslBundle { } } - private static void verifyPemSslStoreKeys(PemSslStore pemSslStore) { - KeyVerifier keyVerifier = new KeyVerifier(); - for (X509Certificate certificate : pemSslStore.certificates()) { - KeyVerifier.Result result = keyVerifier.matches(pemSslStore.privateKey(), certificate.getPublicKey()); - if (result == KeyVerifier.Result.YES) { - return; - } - } - throw new IllegalStateException("Private key matches none of the certificates in the chain"); - } - private static PemSslStoreDetails asStoreDetails(PemSslBundleProperties.Store properties, String alias) { return new PemSslStoreDetails(properties.getType(), alias, null, properties.getCertificate(), properties.getPrivateKey(), properties.getPrivateKeyPassword()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java new file mode 100644 index 00000000000..c1516bdf635 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatcherTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CertificateMatcher}. + * + * @author Moritz Halbritter + * @author Phillip Webb + */ +class CertificateMatcherTests { + + @CertificateMatchingTest + void matchesWhenMatchReturnsTrue(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + assertThat(matcher.matches(source.matchingCertificate())).isTrue(); + } + + @CertificateMatchingTest + void matchesWhenNoMatchReturnsFalse(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + for (Certificate nonMatchingCertificate : source.nonMatchingCertificates()) { + assertThat(matcher.matches(nonMatchingCertificate)).isFalse(); + } + } + + @CertificateMatchingTest + void matchesAnyWhenNoneMatchReturnsFalse(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + assertThat(matcher.matchesAny(source.nonMatchingCertificates())).isFalse(); + } + + @CertificateMatchingTest + void matchesAnyWhenOneMatchesReturnsTrue(CertificateMatchingTestSource source) { + CertificateMatcher matcher = new CertificateMatcher(source.privateKey()); + List certificates = new ArrayList<>(source.nonMatchingCertificates()); + certificates.add(source.matchingCertificate()); + assertThat(matcher.matchesAny(certificates)).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java new file mode 100644 index 00000000000..fcf5e39d653 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Annotation for a {@code ParameterizedTest @ParameterizedTest} with a + * {@link CertificateMatchingTestSource} parameter. + * + * @author Phillip Webb + */ +@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ParameterizedTest(name = "{0}") +@MethodSource("org.springframework.boot.autoconfigure.ssl.CertificateMatchingTestSource#create") +public @interface CertificateMatchingTest { + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java new file mode 100644 index 00000000000..e04f5651fa0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/CertificateMatchingTestSource.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.ssl; + +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.NamedParameterSpec; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Source used with {@link CertificateMatchingTest @CertificateMatchingTest} annotated + * tests that provides access to useful test material. + * + * @param algorithm the algorithm + * @param privateKey the private key to use for matching + * @param matchingCertificate a certificate that matches the private key + * @param nonMatchingCertificates a list of certificate that do not match the private key + * @param nonMatchingPrivateKeys a list of private keys that do not match the certificate + * @author Moritz Halbritter + * @author Phillip Webb + */ +record CertificateMatchingTestSource(CertificateMatchingTestSource.Algorithm algorithm, PrivateKey privateKey, + X509Certificate matchingCertificate, List nonMatchingCertificates, + List nonMatchingPrivateKeys) { + + private static final List ALGORITHMS; + static { + List algorithms = new ArrayList<>(); + Stream.of("RSA", "DSA", "ed25519", "ed448").map(Algorithm::of).forEach(algorithms::add); + Stream.of("secp256r1", "secp521r1").map(Algorithm::ec).forEach(algorithms::add); + ALGORITHMS = List.copyOf(algorithms); + } + + CertificateMatchingTestSource(Algorithm algorithm, KeyPair matchingKeyPair, List nonMatchingKeyPairs) { + this(algorithm, matchingKeyPair.getPrivate(), asCertificate(matchingKeyPair), + nonMatchingKeyPairs.stream().map(CertificateMatchingTestSource::asCertificate).toList(), + nonMatchingKeyPairs.stream().map(KeyPair::getPrivate).toList()); + } + + private static X509Certificate asCertificate(KeyPair keyPair) { + X509Certificate certificate = mock(X509Certificate.class); + given(certificate.getPublicKey()).willReturn(keyPair.getPublic()); + return certificate; + } + + @Override + public String toString() { + return this.algorithm.toString(); + } + + static List create() + throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + Map keyPairs = new LinkedHashMap<>(); + for (Algorithm algorithm : ALGORITHMS) { + keyPairs.put(algorithm, algorithm.generateKeyPair()); + } + List parameters = new ArrayList<>(); + keyPairs.forEach((algorith, matchingKeyPair) -> { + List nonMatchingKeyPairs = new ArrayList<>(keyPairs.values()); + nonMatchingKeyPairs.remove(matchingKeyPair); + parameters.add(new CertificateMatchingTestSource(algorith, matchingKeyPair, nonMatchingKeyPairs)); + }); + return List.copyOf(parameters); + } + + /** + * An individual algorithm. + * + * @param name the algorithm name + * @param spec the algorithm spec or {@code null} + */ + record Algorithm(String name, AlgorithmParameterSpec spec) { + + KeyPair generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator generator = KeyPairGenerator.getInstance(this.name); + if (this.spec != null) { + generator.initialize(this.spec); + } + return generator.generateKeyPair(); + } + + @Override + public String toString() { + String spec = (this.spec instanceof NamedParameterSpec namedSpec) ? namedSpec.getName() : ""; + return this.name + ((!spec.isEmpty()) ? ":" + spec : ""); + } + + static Algorithm of(String name) { + return new Algorithm(name, null); + } + + static Algorithm ec(String curve) { + return new Algorithm("EC", new ECGenParameterSpec(curve)); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/KeyVerifierTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/KeyVerifierTests.java deleted file mode 100644 index 269b824a678..00000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/KeyVerifierTests.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.ssl; - -import java.security.InvalidAlgorithmParameterException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.AlgorithmParameterSpec; -import java.security.spec.ECGenParameterSpec; -import java.util.LinkedList; -import java.util.List; -import java.util.stream.Stream; - -import org.junit.jupiter.api.Named; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import org.springframework.boot.autoconfigure.ssl.KeyVerifier.Result; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link KeyVerifier}. - * - * @author Moritz Halbritter - */ -class KeyVerifierTests { - - private static final List ALGORITHMS = List.of(Algorithm.of("RSA"), Algorithm.of("DSA"), - Algorithm.of("ed25519"), Algorithm.of("ed448"), Algorithm.ec("secp256r1"), Algorithm.ec("secp521r1")); - - private final KeyVerifier keyVerifier = new KeyVerifier(); - - @ParameterizedTest(name = "{0}") - @MethodSource("arguments") - void test(PrivateKey privateKey, PublicKey publicKey, List invalidPublicKeys) { - assertThat(this.keyVerifier.matches(privateKey, publicKey)).isEqualTo(Result.YES); - for (PublicKey invalidPublicKey : invalidPublicKeys) { - assertThat(this.keyVerifier.matches(privateKey, invalidPublicKey)).isEqualTo(Result.NO); - } - } - - static Stream arguments() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { - List keyPairs = new LinkedList<>(); - for (Algorithm algorithm : ALGORITHMS) { - KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm.name()); - if (algorithm.spec() != null) { - generator.initialize(algorithm.spec()); - } - keyPairs.add(generator.generateKeyPair()); - keyPairs.add(generator.generateKeyPair()); - } - return keyPairs.stream() - .map((kp) -> Arguments.arguments(Named.named(kp.getPrivate().getAlgorithm(), kp.getPrivate()), - kp.getPublic(), without(keyPairs, kp).map(KeyPair::getPublic).toList())); - } - - private static Stream without(List keyPairs, KeyPair without) { - return keyPairs.stream().filter((kp) -> !kp.equals(without)); - } - - private record Algorithm(String name, AlgorithmParameterSpec spec) { - static Algorithm of(String name) { - return new Algorithm(name, null); - } - - static Algorithm ec(String curve) { - return new Algorithm("EC", new ECGenParameterSpec(curve)); - } - } - -} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index bc4b1cae64c..fdec73a1639 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -81,4 +81,5 @@ +