diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java index e274c00dae1..3147235f66a 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/info/SslInfo.java @@ -19,9 +19,8 @@ package org.springframework.boot.info; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.cert.Certificate; -import java.security.cert.CertificateExpiredException; -import java.security.cert.CertificateNotYetValidException; import java.security.cert.X509Certificate; +import java.time.Clock; import java.time.Instant; import java.util.Arrays; import java.util.Collections; @@ -48,13 +47,26 @@ public class SslInfo { private final SslBundles sslBundles; + private final Clock clock; + /** * Creates a new instance. * @param sslBundles the {@link SslBundles} to extract the info from * @since 4.0.0 */ public SslInfo(SslBundles sslBundles) { + this(sslBundles, Clock.systemDefaultZone()); + } + + /** + * Creates a new instance. + * @param sslBundles the {@link SslBundles} to extract the info from + * @param clock the {@link Clock} to use + * @since 4.0.0 + */ + public SslInfo(SslBundles sslBundles, Clock clock) { this.sslBundles = sslBundles; + this.clock = clock; } /** @@ -197,19 +209,27 @@ public class SslInfo { return extract((certificate) -> { Instant starts = getValidityStarts(); Instant ends = getValidityEnds(); - try { - certificate.checkValidity(); - return CertificateValidityInfo.VALID; - } - catch (CertificateNotYetValidException ex) { - return new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts); - } - catch (CertificateExpiredException ex) { - return new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends); - } + CertificateValidityInfo.Status validity = checkValidity(starts, ends); + return switch (validity) { + case VALID -> CertificateValidityInfo.VALID; + case EXPIRED -> new CertificateValidityInfo(Status.EXPIRED, "Not valid after %s", ends); + case NOT_YET_VALID -> + new CertificateValidityInfo(Status.NOT_YET_VALID, "Not valid before %s", starts); + }; }); } + private CertificateValidityInfo.Status checkValidity(Instant starts, Instant ends) { + Instant now = SslInfo.this.clock.instant(); + if (now.isBefore(starts)) { + return CertificateValidityInfo.Status.NOT_YET_VALID; + } + if (now.isAfter(ends)) { + return CertificateValidityInfo.Status.EXPIRED; + } + return CertificateValidityInfo.Status.VALID; + } + private R extract(Function valueExtractor, Function resultExtractor) { return extract(valueExtractor.andThen(resultExtractor)); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java index 4f44f8cfe91..d36f1f2ce27 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/info/SslInfoTests.java @@ -16,16 +16,12 @@ package org.springframework.boot.info; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import java.util.List; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.springframework.boot.info.SslInfo.BundleInfo; import org.springframework.boot.info.SslInfo.CertificateChainInfo; @@ -45,9 +41,12 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for {@link SslInfo}. * * @author Jonatan Ivanov + * @author Moritz Halbritter */ class SslInfoTests { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2025-06-18T13:00:00Z"), ZoneId.of("UTC")); + @Test @WithPackageResources("test.p12") void validCertificatesShouldProvideSslInfo() { @@ -70,8 +69,8 @@ class SslInfoTests { assertThat(cert1.getSerialNumber()).isNotEmpty(); assertThat(cert1.getVersion()).isEqualTo("V3"); assertThat(cert1.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert1.getValidityStarts()).isInThePast(); - assertThat(cert1.getValidityEnds()).isInTheFuture(); + assertThat(cert1.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert1.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert1.getValidity()).isNotNull(); assertThat(cert1.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert1.getValidity().getMessage()).isNull(); @@ -81,8 +80,8 @@ class SslInfoTests { assertThat(cert2.getSerialNumber()).isNotEmpty(); assertThat(cert2.getVersion()).isEqualTo("V3"); assertThat(cert2.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert2.getValidityStarts()).isInThePast(); - assertThat(cert2.getValidityEnds()).isInTheFuture(); + assertThat(cert2.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert2.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert2.getValidity()).isNotNull(); assertThat(cert2.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert2.getValidity().getMessage()).isNull(); @@ -106,8 +105,8 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert.getValidityStarts()).isInTheFuture(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); @@ -131,19 +130,18 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isEqualTo("SHA256withRSA"); - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); } @Test - @WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12" }) - void multipleBundlesShouldProvideSslInfo(@TempDir Path tempDir) throws IOException, InterruptedException { - Path keyStore = createKeyStore(tempDir); + @WithPackageResources({ "test.p12", "test-not-yet-valid.p12", "test-expired.p12", "will-expire-soon.p12" }) + void multipleBundlesShouldProvideSslInfo() { SslInfo sslInfo = createSslInfo("classpath:test.p12", "classpath:test-not-yet-valid.p12", - "classpath:test-expired.p12", keyStore.toString()); + "classpath:test-expired.p12", "classpath:will-expire-soon.p12"); assertThat(sslInfo.getBundles()).hasSize(4); assertThat(sslInfo.getBundles()).allSatisfy((bundle) -> assertThat(bundle.getName()).startsWith("test-")); List certs = sslInfo.getBundles() @@ -161,22 +159,22 @@ class SslInfoTests { assertThat(cert.getValidity()).isNotNull(); }); assertThat(certs).anySatisfy((cert) -> { - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.VALID); assertThat(cert.getValidity().getMessage()).isNull(); }); assertThat(certs).satisfiesOnlyOnce((cert) -> { - assertThat(cert.getValidityStarts()).isInTheFuture(); - assertThat(cert.getValidityEnds()).isInTheFuture(); + assertThat(cert.getValidityStarts()).isAfter(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isAfter(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.NOT_YET_VALID); assertThat(cert.getValidity().getMessage()).startsWith("Not valid before"); }); assertThat(certs).satisfiesOnlyOnce((cert) -> { - assertThat(cert.getValidityStarts()).isInThePast(); - assertThat(cert.getValidityEnds()).isInThePast(); + assertThat(cert.getValidityStarts()).isBefore(CLOCK.instant()); + assertThat(cert.getValidityEnds()).isBefore(CLOCK.instant()); assertThat(cert.getValidity()).isNotNull(); assertThat(cert.getValidity().getStatus()).isSameAs(Status.EXPIRED); assertThat(cert.getValidity().getMessage()).startsWith("Not valid after"); @@ -187,7 +185,7 @@ class SslInfoTests { void nullKeyStore() { DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); sslBundleRegistry.registerBundle("test", SslBundle.of(SslStoreBundle.NONE, SslBundleKey.NONE)); - SslInfo sslInfo = new SslInfo(sslBundleRegistry); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, CLOCK); assertThat(sslInfo.getBundles()).hasSize(1); assertThat(sslInfo.getBundles().get(0).getCertificateChains()).isEmpty(); } @@ -199,41 +197,7 @@ class SslInfoTests { SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null); sslBundleRegistry.registerBundle("test-%d".formatted(i), SslBundle.of(sslStoreBundle)); } - return new SslInfo(sslBundleRegistry); - } - - private Path createKeyStore(Path directory) throws IOException, InterruptedException { - Path keyStore = directory.resolve("test.p12"); - Process process = createProcessBuilder(keyStore).start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String out = reader.lines().collect(Collectors.joining("\n")); - throw new RuntimeException("Unexpected exit code from keytool: %d\n%s".formatted(exitCode, out)); - } - } - return keyStore; - } - - private ProcessBuilder createProcessBuilder(Path keystore) { - // @formatter:off - ProcessBuilder processBuilder = new ProcessBuilder( - "keytool", - "-genkeypair", - "-storetype", "PKCS12", - "-alias", "spring-boot", - "-keyalg", "RSA", - "-storepass", "secret", - "-keypass", "secret", - "-keystore", keystore.toString(), - "-dname", "CN=localhost,OU=Spring,O=VMware,L=Palo Alto,ST=California,C=US", - "-validity", "1", - "-ext", "SAN=DNS:localhost,IP:::1,IP:127.0.0.1" - ); - // @formatter:on - processBuilder.redirectErrorStream(true); - return processBuilder; + return new SslInfo(sslBundleRegistry, CLOCK); } } diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 new file mode 100644 index 00000000000..bd6c72e01a3 Binary files /dev/null and b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/info/will-expire-soon.p12 differ