diff --git a/core/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java b/core/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java index c423709d7cd3..eb812452f4bb 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/ssl/SslBundleKey.java @@ -18,6 +18,7 @@ import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.cert.Certificate; import org.jspecify.annotations.Nullable; @@ -52,7 +53,10 @@ public interface SslBundleKey { @Nullable String getAlias(); /** - * Assert that the alias is contained in the given keystore. + * Assert that the alias is contained in the given keystore and that it is a valid + * key entry with a certificate chain. Some JDK keystore implementations (notably + * passwordless PKCS12) may load a key entry but silently drop its certificate + * entries, causing cryptic handshake failures downstream. * @param keyStore the keystore to check */ default void assertContainsAlias(@Nullable KeyStore keyStore) { @@ -66,6 +70,17 @@ default void assertContainsAlias(@Nullable KeyStore keyStore) { throw new IllegalStateException( String.format("Could not determine if keystore contains alias '%s'", alias), ex); } + try { + Assert.state(keyStore.isKeyEntry(alias), + () -> String.format("Keystore alias '%s' is not a key entry", alias)); + Certificate[] chain = keyStore.getCertificateChain(alias); + Assert.state(chain != null && chain.length > 0, + () -> String.format("Keystore alias '%s' does not have a certificate chain", alias)); + } + catch (KeyStoreException ex) { + throw new IllegalStateException(String.format("Could not validate keystore alias '%s'", alias), + ex); + } } } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java b/core/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java index eb9605ce7ed5..021ed2e17ce0 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/ssl/SslBundleKeyTests.java @@ -18,11 +18,13 @@ import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.cert.Certificate; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -72,4 +74,66 @@ void getKeyManagerFactoryWhenHasAliasNotDeterminedInStoreThrowsException() throw .withMessage("Could not determine if keystore contains alias 'alias'"); } + @Test + void assertContainsAliasWhenAliasIsNotKeyEntryThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(true); + given(keyStore.isKeyEntry("alias")).willReturn(false); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Keystore alias 'alias' is not a key entry"); + } + + @Test + void assertContainsAliasWhenAliasIsTrustedCertificateEntryThrowsException() throws Exception { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null); + keyStore.setCertificateEntry("alias", mock(Certificate.class)); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Keystore alias 'alias' is not a key entry"); + } + + @Test + void assertContainsAliasWhenAliasHasNoCertificateChainThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(true); + given(keyStore.isKeyEntry("alias")).willReturn(true); + given(keyStore.getCertificateChain("alias")).willReturn(null); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Keystore alias 'alias' does not have a certificate chain"); + } + + @Test + void assertContainsAliasWhenAliasHasEmptyCertificateChainThrowsException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(true); + given(keyStore.isKeyEntry("alias")).willReturn(true); + given(keyStore.getCertificateChain("alias")).willReturn(new Certificate[0]); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Keystore alias 'alias' does not have a certificate chain"); + } + + @Test + void assertContainsAliasWhenAliasIsValidKeyEntryDoesNotThrow() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(true); + given(keyStore.isKeyEntry("alias")).willReturn(true); + given(keyStore.getCertificateChain("alias")).willReturn(new Certificate[] { mock(Certificate.class) }); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatNoException().isThrownBy(() -> key.assertContainsAlias(keyStore)); + } + + @Test + void assertContainsAliasWhenKeyEntryValidationFailsThrowsValidationException() throws Exception { + KeyStore keyStore = mock(KeyStore.class); + given(keyStore.containsAlias("alias")).willReturn(true); + given(keyStore.isKeyEntry("alias")).willThrow(KeyStoreException.class); + SslBundleKey key = SslBundleKey.of("secret", "alias"); + assertThatIllegalStateException().isThrownBy(() -> key.assertContainsAlias(keyStore)) + .withMessage("Could not validate keystore alias 'alias'"); + } + }