diff --git a/core/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java b/core/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java index 46da37d4e64..b3be703613d 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -104,9 +104,12 @@ public class SslInfo { private final List certificateChains; + private final List trustStoreCertificateChains; + private BundleInfo(String name, SslBundle sslBundle) { this.name = name; this.certificateChains = extractCertificateChains(sslBundle.getStores().getKeyStore()); + this.trustStoreCertificateChains = extractCertificateChains(sslBundle.getStores().getTrustStore()); } private List extractCertificateChains(@Nullable KeyStore keyStore) { @@ -132,6 +135,10 @@ public class SslInfo { return this.certificateChains; } + public List getTrustStoreCertificateChains() { + return this.trustStoreCertificateChains; + } + } /** @@ -150,15 +157,34 @@ public class SslInfo { private List extractCertificates(KeyStore keyStore, String alias) { try { - Certificate[] certificates = keyStore.getCertificateChain(alias); - return (!ObjectUtils.isEmpty(certificates)) - ? Arrays.stream(certificates).map(CertificateInfo::new).toList() : Collections.emptyList(); + List certificates = readCertificateChain(keyStore, alias); + if (certificates != null) { + return certificates; + } + List certificate = readCertificate(keyStore, alias); + if (certificate != null) { + return certificate; + } + return Collections.emptyList(); } catch (KeyStoreException ex) { return Collections.emptyList(); } } + private @Nullable List readCertificate(KeyStore keyStore, String alias) + throws KeyStoreException { + Certificate certificate = keyStore.getCertificate(alias); + return (certificate != null) ? List.of(new CertificateInfo(certificate)) : null; + } + + private @Nullable List readCertificateChain(KeyStore keyStore, String alias) + throws KeyStoreException { + Certificate[] certificates = keyStore.getCertificateChain(alias); + return ObjectUtils.isEmpty(certificates) ? null + : Arrays.stream(certificates).map(CertificateInfo::new).toList(); + } + public String getAlias() { return this.alias; } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java b/core/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java index d36f1f2ce27..57b9d3e38fb 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java @@ -60,9 +60,9 @@ class SslInfoTests { assertThat(bundle.getCertificateChains().get(1).getAlias()).isEqualTo("test-alias"); assertThat(bundle.getCertificateChains().get(1).getCertificates()).hasSize(1); assertThat(bundle.getCertificateChains().get(2).getAlias()).isEqualTo("spring-boot-cert"); - assertThat(bundle.getCertificateChains().get(2).getCertificates()).isEmpty(); + assertThat(bundle.getCertificateChains().get(2).getCertificates()).hasSize(1); assertThat(bundle.getCertificateChains().get(3).getAlias()).isEqualTo("test-alias-cert"); - assertThat(bundle.getCertificateChains().get(3).getCertificates()).isEmpty(); + assertThat(bundle.getCertificateChains().get(3).getCertificates()).hasSize(1); CertificateInfo cert1 = bundle.getCertificateChains().get(0).getCertificates().get(0); assertThat(cert1.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); assertThat(cert1.getIssuer()).isEqualTo(cert1.getSubject()); @@ -85,6 +85,7 @@ class SslInfoTests { assertThat(cert2.getValidity()).isNotNull(); assertThat(cert2.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert2.getValidity().getMessage()).isNull(); + assertThat(bundle.getTrustStoreCertificateChains()).isEmpty(); } @Test @@ -149,7 +150,7 @@ class SslInfoTests { .flatMap((bundle) -> bundle.getCertificateChains().stream()) .flatMap((certificateChain) -> certificateChain.getCertificates().stream()) .toList(); - assertThat(certs).hasSize(5); + assertThat(certs).hasSize(7); assertThat(certs).allSatisfy((cert) -> { assertThat(cert.getSubject()).isEqualTo("CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US"); assertThat(cert.getIssuer()).isEqualTo(cert.getSubject()); @@ -188,6 +189,68 @@ class SslInfoTests { SslInfo sslInfo = new SslInfo(sslBundleRegistry, CLOCK); assertThat(sslInfo.getBundles()).hasSize(1); assertThat(sslInfo.getBundles().get(0).getCertificateChains()).isEmpty(); + assertThat(sslInfo.getBundles().get(0).getTrustStoreCertificateChains()).isEmpty(); + } + + @Test + @WithPackageResources("test.p12") + void trustStoreCertificatesShouldProvideSslInfo() { + DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation("classpath:test.p12") + .withPassword("secret"); + SslStoreBundle sslStoreBundle = new JksSslStoreBundle(null, trustStoreDetails); + sslBundleRegistry.registerBundle("test-trust", SslBundle.of(sslStoreBundle)); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, CLOCK); + assertThat(sslInfo.getBundles()).hasSize(1); + BundleInfo bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-trust"); + assertThat(bundle.getCertificateChains()).isEmpty(); + assertThat(bundle.getTrustStoreCertificateChains()).hasSize(4); + assertThat(bundle.getTrustStoreCertificateChains().get(0).getAlias()).isEqualTo("spring-boot"); + assertThat(bundle.getTrustStoreCertificateChains().get(1).getAlias()).isEqualTo("test-alias"); + } + + @Test + @WithPackageResources("test.p12") + void bothKeyStoreAndTrustStoreCertificatesShouldProvideSslInfo() { + DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); + JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.p12").withPassword("secret"); + SslStoreBundle sslStoreBundle = new JksSslStoreBundle(storeDetails, storeDetails); + sslBundleRegistry.registerBundle("test-both", SslBundle.of(sslStoreBundle)); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, CLOCK); + assertThat(sslInfo.getBundles()).hasSize(1); + BundleInfo bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-both"); + assertThat(bundle.getCertificateChains()).hasSize(4); + assertThat(bundle.getTrustStoreCertificateChains()).hasSize(4); + } + + @Test + @WithPackageResources({ "keystore.jks", "truststore.jks" }) + void separateKeyStoreAndTrustStoreShouldProvideSslInfo() { + DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); + JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation("classpath:keystore.jks") + .withPassword("secret"); + JksSslStoreDetails trustStoreDetails = JksSslStoreDetails.forLocation("classpath:truststore.jks") + .withPassword("secret"); + SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, trustStoreDetails); + sslBundleRegistry.registerBundle("test-separate", SslBundle.of(sslStoreBundle)); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, CLOCK); + assertThat(sslInfo.getBundles()).hasSize(1); + BundleInfo bundle = sslInfo.getBundles().get(0); + assertThat(bundle.getName()).isEqualTo("test-separate"); + // Keystore has 2 PrivateKeyEntry entries + assertThat(bundle.getCertificateChains()).hasSize(2); + assertThat(bundle.getCertificateChains()).allSatisfy((chain) -> { + assertThat(chain.getCertificates()).hasSize(1); + assertThat(chain.getCertificates().get(0).getSubject()).startsWith("CN=localhost"); + }); + // Truststore has 3 trustedCertEntry entries + assertThat(bundle.getTrustStoreCertificateChains()).hasSize(3); + assertThat(bundle.getTrustStoreCertificateChains()).allSatisfy((chain) -> { + assertThat(chain.getCertificates()).hasSize(1); + assertThat(chain.getCertificates().get(0).getSubject()).startsWith("CN=localhost"); + }); } private SslInfo createSslInfo(String... locations) { diff --git a/core/spring-boot/src/test/resources/org/springframework/boot/info/keystore.jks b/core/spring-boot/src/test/resources/org/springframework/boot/info/keystore.jks new file mode 100644 index 00000000000..4e3e26f8195 Binary files /dev/null and b/core/spring-boot/src/test/resources/org/springframework/boot/info/keystore.jks differ diff --git a/core/spring-boot/src/test/resources/org/springframework/boot/info/truststore.jks b/core/spring-boot/src/test/resources/org/springframework/boot/info/truststore.jks new file mode 100644 index 00000000000..8e27d6e6790 Binary files /dev/null and b/core/spring-boot/src/test/resources/org/springframework/boot/info/truststore.jks differ diff --git a/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/info/InfoEndpointDocumentationTests.java b/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/info/InfoEndpointDocumentationTests.java index 0d11d51829b..41ee62ec7eb 100644 --- a/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/info/InfoEndpointDocumentationTests.java +++ b/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/info/InfoEndpointDocumentationTests.java @@ -208,6 +208,42 @@ class InfoEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { .description("Certificate validity status.") .type(JsonFieldType.STRING), fieldWithPath("bundles[].certificateChains[].certificates[].signatureAlgorithmName") + .description("Signature algorithm name.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains") + .description("Certificate chains in the trust store.") + .type(JsonFieldType.ARRAY), + fieldWithPath("bundles[].trustStoreCertificateChains[].alias") + .description("Alias of the certificate chain.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates") + .description("Certificates in the chain.") + .type(JsonFieldType.ARRAY), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].subject") + .description("Subject of the certificate.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].version") + .description("Version of the certificate.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].issuer") + .description("Issuer of the certificate.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].validityStarts") + .description("Certificate validity start date.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].serialNumber") + .description("Serial number of the certificate.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].validityEnds") + .description("Certificate validity end date.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].validity") + .description("Certificate validity information.") + .type(JsonFieldType.OBJECT), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].validity.status") + .description("Certificate validity status.") + .type(JsonFieldType.STRING), + fieldWithPath("bundles[].trustStoreCertificateChains[].certificates[].signatureAlgorithmName") .description("Signature algorithm name.") .type(JsonFieldType.STRING)); } @@ -259,9 +295,9 @@ class InfoEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { @Bean SslInfo sslInfo() { DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); - JksSslStoreDetails keyStoreDetails = JksSslStoreDetails.forLocation("classpath:test.p12") + JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.p12") .withPassword("secret"); - SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null); + SslStoreBundle sslStoreBundle = new JksSslStoreBundle(storeDetails, storeDetails); sslBundleRegistry.registerBundle("test-0", SslBundle.of(sslStoreBundle)); return new SslInfo(sslBundleRegistry); }