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 e4fdc499a44..93013a690e4 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.Duration; import java.time.Instant; import java.util.Arrays; @@ -41,6 +40,7 @@ import org.springframework.util.ObjectUtils; * Information about the certificates that the application uses. * * @author Jonatan Ivanov + * @author Moritz Halbritter * @since 3.4.0 */ public class SslInfo { @@ -49,9 +49,16 @@ public class SslInfo { private final Duration certificateValidityWarningThreshold; + private final Clock clock; + public SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold) { + this(sslBundles, certificateValidityWarningThreshold, Clock.systemDefaultZone()); + } + + SslInfo(SslBundles sslBundles, Duration certificateValidityWarningThreshold, Clock clock) { this.sslBundles = sslBundles; this.certificateValidityWarningThreshold = certificateValidityWarningThreshold; + this.clock = clock; } public List getBundles() { @@ -179,25 +186,31 @@ public class SslInfo { Instant starts = getValidityStarts(); Instant ends = getValidityEnds(); Duration threshold = SslInfo.this.certificateValidityWarningThreshold; - try { - certificate.checkValidity(); - return (!isExpiringSoon(certificate, threshold)) ? CertificateValidityInfo.VALID - : new CertificateValidityInfo(Status.WILL_EXPIRE_SOON, - "Certificate will expire within threshold (%s) at %s", threshold, ends); - } - 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, threshold); + 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); + case WILL_EXPIRE_SOON -> new CertificateValidityInfo(Status.WILL_EXPIRE_SOON, + "Certificate will expire within threshold (%s) at %s", threshold, ends); + }; }); } - private boolean isExpiringSoon(X509Certificate certificate, Duration threshold) { - Instant shouldBeValidAt = Instant.now().plus(threshold); - Instant expiresAt = certificate.getNotAfter().toInstant(); - return shouldBeValidAt.isAfter(expiresAt); + private CertificateValidityInfo.Status checkValidity(Instant starts, Instant ends, Duration threshold) { + Instant now = SslInfo.this.clock.instant(); + if (now.isBefore(starts)) { + return CertificateValidityInfo.Status.NOT_YET_VALID; + } + if (now.isAfter(ends)) { + return CertificateValidityInfo.Status.EXPIRED; + } + Instant shouldBeValidAt = now.plus(threshold); + if (shouldBeValidAt.isAfter(ends)) { + return CertificateValidityInfo.Status.WILL_EXPIRE_SOON; + } + return CertificateValidityInfo.Status.VALID; } private R extract(Function valueExtractor, Function 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 0bd87c0ca47..a3c5dc2cb99 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,17 +16,13 @@ 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.Duration; +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; @@ -46,9 +42,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() { @@ -71,8 +70,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(); @@ -82,8 +81,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(); @@ -107,8 +106,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"); @@ -132,18 +131,17 @@ 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 - void soonToBeExpiredCertificateShouldProvideSslInfo(@TempDir Path tempDir) - throws IOException, InterruptedException { - Path keyStore = createKeyStore(tempDir); - SslInfo sslInfo = createSslInfo(keyStore.toString()); + @WithPackageResources({ "will-expire-soon.p12" }) + void soonToBeExpiredCertificateShouldProvideSslInfo() { + SslInfo sslInfo = createSslInfo("classpath:will-expire-soon.p12"); assertThat(sslInfo.getBundles()).hasSize(1); BundleInfo bundle = sslInfo.getBundles().get(0); assertThat(bundle.getName()).isEqualTo("test-0"); @@ -158,19 +156,18 @@ class SslInfoTests { assertThat(cert.getSerialNumber()).isNotEmpty(); assertThat(cert.getVersion()).isEqualTo("V3"); assertThat(cert.getSignatureAlgorithmName()).isNotEmpty(); - 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.WILL_EXPIRE_SOON); + assertThat(cert.getValidity().getStatus()).isEqualTo(Status.WILL_EXPIRE_SOON); assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); } @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() @@ -188,29 +185,29 @@ 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"); }); assertThat(certs).satisfiesOnlyOnce((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.WILL_EXPIRE_SOON); assertThat(cert.getValidity().getMessage()).startsWith("Certificate will expire within threshold"); @@ -221,7 +218,7 @@ class SslInfoTests { void nullKeyStore() { DefaultSslBundleRegistry sslBundleRegistry = new DefaultSslBundleRegistry(); sslBundleRegistry.registerBundle("test", SslBundle.of(SslStoreBundle.NONE, SslBundleKey.NONE)); - SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7)); + SslInfo sslInfo = new SslInfo(sslBundleRegistry, Duration.ofDays(7), CLOCK); assertThat(sslInfo.getBundles()).hasSize(1); assertThat(sslInfo.getBundles().get(0).getCertificateChains()).isEmpty(); } @@ -233,41 +230,7 @@ class SslInfoTests { SslStoreBundle sslStoreBundle = new JksSslStoreBundle(keyStoreDetails, null); sslBundleRegistry.registerBundle("test-%d".formatted(i), SslBundle.of(sslStoreBundle)); } - return new SslInfo(sslBundleRegistry, Duration.ofDays(7)); - } - - 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, Duration.ofDays(7), 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