From d91e552491c9d32b36cd34de9f51292b39fdaa15 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Wed, 12 Mar 2025 10:35:59 -0700 Subject: [PATCH] renewal: by default, use a fraction of lifetime (#10207) Previously we defaulted to renewing at 30 days before expiry, and allowed users to customize the config file to set a different value. Instead, we should renew when 1/3 of the lifetime is left, or for shorter certificates (<10 days), when 1/2 of the lifetime is left. This still allows explicitly configured values to take precedence. --------- Co-authored-by: Will Greenberg --- certbot/CHANGELOG.md | 5 +- certbot/certbot/_internal/storage.py | 31 ++++++-- .../certbot/_internal/tests/storage_test.py | 74 +++++++++++++++++-- certbot/docs/using.rst | 18 ++--- 4 files changed, 103 insertions(+), 25 deletions(-) diff --git a/certbot/CHANGELOG.md b/certbot/CHANGELOG.md index f4663e3a0..8d7cb4e80 100644 --- a/certbot/CHANGELOG.md +++ b/certbot/CHANGELOG.md @@ -10,7 +10,10 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). ### Changed -* +* Certificates now renew with 1/3rd of lifetime left (or 1/2 of lifetime left, + if the lifetime is shorter than 10 days). This is a change from a hardcoded + renewal at 30 days before expiration. The config field renew_before_expiry + still overrides this default. ### Fixed diff --git a/certbot/certbot/_internal/storage.py b/certbot/certbot/_internal/storage.py index 291ecccf7..6b7444398 100644 --- a/certbot/certbot/_internal/storage.py +++ b/certbot/certbot/_internal/storage.py @@ -1001,17 +1001,32 @@ class RenewableCert(interfaces.RenewableCert): logger.debug("Should renew, certificate is revoked.") return True - # Renews some period before expiry time - default_interval = constants.RENEWER_DEFAULTS["renew_before_expiry"] - interval = self.configuration.get("renew_before_expiry", default_interval) - expiry = crypto_util.notAfter(self.version( - "cert", self.latest_common_version())) + cert = self.version("cert", self.latest_common_version()) + notBefore = crypto_util.notBefore(cert) + notAfter = crypto_util.notAfter(cert) + lifetime = notAfter - notBefore + + config_interval = self.configuration.get("renew_before_expiry") now = datetime.datetime.now(pytz.UTC) - if expiry < add_time_interval(now, interval): + if config_interval is not None and notAfter < add_time_interval(now, config_interval): logger.debug("Should renew, less than %s before certificate " - "expiry %s.", interval, - expiry.strftime("%Y-%m-%d %H:%M:%S %Z")) + "expiry %s.", config_interval, + notAfter.strftime("%Y-%m-%d %H:%M:%S %Z")) return True + + # No config for "renew_before_expiry", provide default behavior. + # For most certs, renew with 1/3 of certificate lifetime remaining. + # For short lived certificates, renew at 1/2 of certificate lifetime. + default_interval = lifetime / 3 + if lifetime.total_seconds() < 10 * 86400: + default_interval = lifetime / 2 + remaining_time = notAfter - now + if remaining_time < default_interval: + logger.debug("Should renew, less than %ss before certificate " + "expiry %s.", default_interval, + notAfter.strftime("%Y-%m-%d %H:%M:%S %Z")) + return True + return False @classmethod diff --git a/certbot/certbot/_internal/tests/storage_test.py b/certbot/certbot/_internal/tests/storage_test.py index 35ccd864f..31b5ae1d2 100644 --- a/certbot/certbot/_internal/tests/storage_test.py +++ b/certbot/certbot/_internal/tests/storage_test.py @@ -19,8 +19,33 @@ from certbot.compat import filesystem from certbot.compat import os import certbot.tests.util as test_util -CERT = test_util.load_cert('cert_512.pem') +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization, hashes +from cryptography import x509 +from cryptography.x509 import Certificate +import datetime +from typing import Optional, Any + +def make_cert_with_lifetime(not_before: datetime.datetime, lifetime_days: int) -> bytes: + """Return PEM of a self-signed certificate with the given notBefore and lifetime.""" + key = ec.generate_private_key(ec.SECP256R1()) + not_after=not_before + datetime.timedelta(days=lifetime_days) + cert = x509.CertificateBuilder( + issuer_name=x509.Name([]), + subject_name=x509.Name([]), + public_key=key.public_key(), + serial_number=x509.random_serial_number(), + not_valid_before=not_before, + not_valid_after=not_after, + ).add_extension( + x509.SubjectAlternativeName([x509.DNSName("example.com")]), + critical=False, + ).sign( + private_key=key, + algorithm=hashes.SHA256(), + ) + return cert.public_bytes(serialization.Encoding.PEM) def unlink_all(rc_object): """Unlink all four items associated with this RenewableCert.""" @@ -445,32 +470,45 @@ class RenewableCertTests(BaseRenewableCertTest): @mock.patch("certbot._internal.storage.datetime") def test_time_interval_judgments(self, mock_datetime, mock_set_by_user): """Test should_autorenew() on the basis of expiry time windows.""" - test_cert = test_util.load_vector("cert_512.pem") + # Note: this certificate happens to have a lifetime of 7 days, + # and the tests below that use a "None" interval (i.e. choose a + # default) rely on that fact. + # + # Not Before: Dec 11 22:34:45 2014 GMT + # Not After : Dec 18 22:34:45 2014 GMT + not_before = datetime.datetime(2014, 12, 11, 22, 34, 45) + short_cert = make_cert_with_lifetime(not_before, 7) self._write_out_ex_kinds() self.test_rc.update_all_links_to(12) with open(self.test_rc.cert, "wb") as f: - f.write(test_cert) + f.write(short_cert) self.test_rc.update_all_links_to(11) with open(self.test_rc.cert, "wb") as f: - f.write(test_cert) + f.write(short_cert) mock_datetime.timedelta = datetime.timedelta mock_set_by_user.return_value = False self.test_rc.configuration["renewalparams"] = {} for (current_time, interval, result) in [ - # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + # 2014-12-13 12:00 (about 5 days prior to expiry) # Times that should result in autorenewal/autodeployment (1418472000, "2 months", True), (1418472000, "1 week", True), - # Times that should not + # With the "default" logic, this 7-day certificate should autorenew + # at 3.5 days prior to expiry. We haven't reached that yet, + # so don't renew. + (1418472000, None, False), + # 2014-12-16 03:20, a little less than 3.5 days to expiry. + (1418700000, None, True), + # Times that should not renew (1418472000, "4 days", False), (1418472000, "2 days", False), # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) # Times that should result in autorenewal/autodeployment (1241179200, "7 years", True), (1241179200, "11 years 2 months", True), - # Times that should not + # Times that should not renew (1241179200, "8 hours", False), (1241179200, "2 days", False), (1241179200, "40 days", False), (1241179200, "9 months", False), # 2015-01-01 (after expiry has already happened, so all @@ -480,6 +518,28 @@ class RenewableCertTests(BaseRenewableCertTest): (1420070400, "10 minutes", True), (1420070400, "10 weeks", True), (1420070400, "10 months", True), (1420070400, "10 years", True), (1420070400, "99 months", True), + (1420070400, None, True) + ]: + sometime = datetime.datetime.fromtimestamp(current_time, pytz.UTC) + mock_datetime.datetime.now.return_value = sometime + self.test_rc.configuration["renew_before_expiry"] = interval + assert self.test_rc.should_autorenew() == result + + # Lifetime: 31 years + # Default renewal: about 10 years from expiry + # Not Before: May 29 07:42:01 2017 GMT + # Not After : Mar 30 07:42:01 2048 GMT + not_before=datetime.datetime(2017, 5, 29, 7, 42, 1) + long_cert = make_cert_with_lifetime(not_before, 31 * 365) + self.test_rc.update_all_links_to(12) + with open(self.test_rc.cert, "wb") as f: + f.write(long_cert) + self.test_rc.update_all_links_to(11) + with open(self.test_rc.cert, "wb") as f: + f.write(long_cert) + for (current_time, result) in [ + (2114380800, False), # 2037-01-01 + (2148000000, True), # 2038-01-25 ]: sometime = datetime.datetime.fromtimestamp(current_time, pytz.UTC) mock_datetime.datetime.now.return_value = sometime diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index 8ac8060ad..b3ab3cd95 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -628,10 +628,6 @@ Follow these steps to safely delete a certificate: Renewing certificates --------------------- -.. note:: Let's Encrypt CA issues short-lived certificates (90 - days). Make sure you renew the certificates at least once in 3 - months. - .. seealso:: Most Certbot installations come with automatic renewal out of the box. See `Automated Renewals`_ for more details. @@ -639,14 +635,18 @@ Renewing certificates will not renew automatically, unless combined with authentication hook scripts. See `Renewal with the manual plugin <#manual-renewal>`_. -As of version 0.10.0, Certbot supports a ``renew`` action to check -all installed certificates for impending expiry and attempt to renew -them. The simplest form is simply +Certbot supports a ``renew`` action to check all installed certificates for +impending expiry and attempt to renew them. The simplest form is simply ``certbot renew`` -This command attempts to renew any previously-obtained certificates that -expire in less than 30 days. The same plugin and options that were used +This command attempts to renew any previously-obtained certificates which are ready +for renewal. As of Certbot 4.0.0, a certificate is considered ready for renewal +when less than 1/3rd of its lifetime remains. For certificates with a lifetime +of 10 days or less, that threshold is 1/2 of the lifetime. Prior to Certbot 4.0.0 +the threshold was a fixed 30 days. + +The same plugin and options that were used at the time the certificate was originally issued will be used for the renewal attempt, unless you specify other plugins or options. Unlike ``certonly``, ``renew`` acts on multiple certificates and always takes into account whether each one is near