Commit 4a5704abc6 for openssl.org

commit 4a5704abc6d261c66e7fcdb0fc072f4f83a44c65
Author: Daniel Kubec <kubec@openssl.foundation>
Date:   Mon Oct 5 17:00:57 2026 +0200

    apps/x509.c: fail -checkend on unparsable validity times

    When the notBefore or notAfter field of a certificate cannot be parsed,
    X509_check_certificate_times() fails with an error other than
    X509_V_ERR_CERT_HAS_EXPIRED. The -checkend handling only reacted
    to that one error, so a certificate with a corrupt validity period
    printed nothing and exited with status zero, i.e. was treated
    as not expiring.  The same silent success happened for a certificate
    that is not yet valid at the check time.

    Report an unparsable validity period as an error with a failing exit
    status. Report a certificate that only becomes valid after the check
    time as not expiring, which is what -checkend did before the switch
    to X509_check_certificate_times(), as it cannot expire
    before it is valid.

    Add regression tests with corrupted notBefore and notAfter fields,
    including with -multi, and with a not yet valid certificate.
    Document the behaviour.

    Resolves: https://github.com/openssl/openssl/issues/11772
    Assisted-by: Claude:claude-fable-5-1
    Reviewed-by: Dmitry Belyavskiy <beldmit@gmail.com>
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    Reviewed-by: Eugene Syromiatnikov <esyr@openssl.org>
    Merge-date: Tue Oct 27 16:19:04 2026
    Merged-from: https://github.com/openssl/openssl/pull/33105

diff --git a/apps/x509.c b/apps/x509.c
index c8cd9229f4..77ed03c548 100644
--- a/apps/x509.c
+++ b/apps/x509.c
@@ -1221,13 +1221,26 @@ cert_loop:
         X509_VERIFY_PARAM_set_flags(vpm, X509_V_FLAG_USE_CHECK_TIME);
         X509_VERIFY_PARAM_set_time(vpm, tcheck);

-        if (!X509_check_certificate_times(vpm, x, &error)) {
-            if (error == X509_V_ERR_CERT_HAS_EXPIRED) {
-                BIO_puts(out, "Certificate will expire\n");
-                expired = 1;
-            }
-        } else {
+        if (X509_check_certificate_times(vpm, x, &error)) {
+            BIO_puts(out, "Certificate will not expire\n");
+        } else if (error == X509_V_ERR_CERT_HAS_EXPIRED) {
+            BIO_puts(out, "Certificate will expire\n");
+            expired = 1;
+        } else if (error == X509_V_ERR_CERT_NOT_YET_VALID) {
+            /*
+             * The certificate only becomes valid after the check time,
+             * so it cannot expire before it.
+             */
             BIO_puts(out, "Certificate will not expire\n");
+        } else {
+            /*
+             * A notBefore or notAfter field that cannot be parsed must not
+             * be reported as "will not expire", see issue #11772.
+             */
+            BIO_printf(bio_err, "Cannot check certificate expiry: %s\n",
+                X509_verify_cert_error_string(error));
+            X509_VERIFY_PARAM_free(vpm);
+            goto err;
         }
         if (multi && k > 0)
             ret |= expired;
diff --git a/doc/man1/openssl-x509.pod.in b/doc/man1/openssl-x509.pod.in
index cbb2ab29e2..4589850379 100644
--- a/doc/man1/openssl-x509.pod.in
+++ b/doc/man1/openssl-x509.pod.in
@@ -367,6 +367,10 @@ With B<-multi> checks if any certificate in the input will expire
 within the next I<arg> seconds and exits nonzero if any will expire
 or zero if none will.

+A certificate that only becomes valid after the next I<arg> seconds is
+reported as not expiring.  If the validity period of a certificate cannot
+be parsed, an error is reported and the exit status is nonzero.
+
 =item B<-checkhost> I<host>

 Check that the certificate matches the specified host.
diff --git a/test/recipes/25-test_x509.t b/test/recipes/25-test_x509.t
index 9a75043a1b..6d8f2cacde 100644
--- a/test/recipes/25-test_x509.t
+++ b/test/recipes/25-test_x509.t
@@ -17,7 +17,7 @@ use File::Compare qw/compare_text/;

 setup("test_x509");

-plan tests => 157;
+plan tests => 158;

 # Prevent MSys2 filename munging for arguments that look like file paths but
 # aren't
@@ -839,6 +839,79 @@ ok(!run(app(["openssl", "x509", "-multi", "-checkend",
 ok(!run(app(["openssl", "x509", "-checkend", "60", "-in", $c_key])),
     "Bad parse with -checkend returns non-zero");

+# Regression test: -checkend must not report a certificate whose validity
+# times cannot be parsed as "will not expire" with a zero exit status.
+# A certificate that is not yet valid at the check time is not expiring.
+# See https://github.com/openssl/openssl/issues/11772
+subtest "x509 -checkend with invalid or future validity times" => sub {
+    plan tests => 7;
+
+    my $c_der = "c-early.der";
+    my $c_bad_after = "c-bad-notafter.pem";
+    my $c_bad_before = "c-bad-notbefore.pem";
+    my $c_future = "c-future.pem";
+    my $c_mixed = "c-mixed.pem";
+
+    # Overwrite the first digit of the notBefore ($which == 0) or notAfter
+    # ($which == 1) UTCTime of a DER certificate with a non-digit, which
+    # keeps the certificate decodable but makes that time unparsable.
+    sub corrupt_time {
+        my ($in, $out, $which) = @_;
+        my $n = 0;
+
+        open(my $fh, "<:raw", $in) or die;
+        local $/;
+        my $der = <$fh>;
+        close($fh);
+        $der =~ s/(\x17\x0d)(\d)(\d{11}Z)/$n++ == $which ? "$1X$3" : "$1$2$3"/ge
+            or die "no UTCTime found in $in";
+        die "only $n UTCTime(s) found in $in" if $n < 2;
+        open($fh, ">:raw", $out) or die;
+        print {$fh} $der;
+        close($fh);
+        return 1;
+    }
+
+    ok(run(app(["openssl", "x509", "-in", $c_early, "-outform", "DER",
+                "-out", $c_der]))
+       && corrupt_time($c_der, "c-bad-notafter.der", 1)
+       && corrupt_time($c_der, "c-bad-notbefore.der", 0)
+       && run(app(["openssl", "x509", "-inform", "DER",
+                   "-in", "c-bad-notafter.der", "-out", $c_bad_after]))
+       && run(app(["openssl", "x509", "-inform", "DER",
+                   "-in", "c-bad-notbefore.der", "-out", $c_bad_before])),
+       "Create certificates with unparsable validity times");
+
+    ok(!run(app(["openssl", "x509", "-checkend", "60", "-in", $c_bad_after])),
+       "Invalid notAfter with -checkend returns non-zero");
+    ok(!run(app(["openssl", "x509", "-checkend", "60", "-in", $c_bad_before])),
+       "Invalid notBefore with -checkend returns non-zero");
+
+    mkchain($c_late, $c_bad_after);
+    ok(!run(app(["openssl", "x509", "-multi", "-checkend", "60",
+                 "-in", $c_chain])),
+       "Multi cert + invalid notAfter in 2nd cert returns non-zero");
+
+    my $not_before = Time::Piece->gmtime(time() + 2 * 366 * 86400)
+                         ->strftime("%Y%m%d%H%M%SZ");
+    my $not_after = Time::Piece->gmtime(time() + 3 * 366 * 86400)
+                        ->strftime("%Y%m%d%H%M%SZ");
+    ok(run(app(["openssl", "x509", "-new", "-key", $c_key, "-subj",
+                "/CN=FUTURE", "-not_before", $not_before,
+                "-not_after", $not_after, "-out", $c_future])),
+       "Create not yet valid certificate");
+
+    my @out = run(app(["openssl", "x509", "-checkend", "60",
+                       "-in", $c_future]), capture => 1, statusvar => \my $ok);
+    ok($ok && grep(/^Certificate will not expire/, @out),
+       "Not yet valid cert with -checkend returns zero and is reported");
+
+    mkchain($c_future, $c_early);
+    ok(!run(app(["openssl", "x509", "-multi", "-checkend",
+                 $delta_early + 3600, "-in", $c_chain])),
+       "Multi cert + not yet valid 1st + expiring 2nd returns non-zero");
+};
+
 # Regression test: with -multi, a failure on a later certificate must set
 # a failing exit status even after an earlier certificate succeeded, i.e.
 # the per-certificate success status must not leak into the final result.