From af10058840e024e6cf2bbdd50cc1dacfe236e6fc Mon Sep 17 00:00:00 2001 From: Stefan Eissing Date: Tue, 17 Sep 2024 11:38:19 +0000 Subject: [PATCH] *) mod_md: update to version 2.4.28 - When the server starts, it looks for new, staged certificates to activate. If the staged set of files in 'md/staging/' is messed up, this could prevent further renewals to happen. Now, when the staging set is present, but could not be activated due to an error, purge the whole directory. [icing] - Fix certificate retrieval on ACME renewal to not require a 'Location:' header returned by the ACME CA. This was the way it was done in ACME before it became an IETF standard. Let's Encrypt still supports this, but other CAs do not. [icing] - Restore compatibility with OpenSSL < 1.1. [ylavic] git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1920747 13f79535-47bb-0310-9956-ffa450edef68 --- changes-entries/md_v2.4.28.txt | 11 ++++ modules/md/md_acme_drive.c | 10 +-- modules/md/md_reg.c | 6 +- modules/md/md_version.h | 4 +- test/modules/md/conftest.py | 8 +-- test/modules/md/md_cert_util.py | 70 ++++++-------------- test/modules/md/md_env.py | 27 ++++---- test/modules/md/test_502_acmev2_drive.py | 14 ++-- test/modules/md/test_702_auto.py | 54 ++++++++++++--- test/modules/md/test_730_static.py | 33 ++++++---- test/modules/md/test_741_setup_errors.py | 26 ++++++++ test/modules/md/test_801_stapling.py | 12 ++-- test/modules/md/test_901_message.py | 28 ++++---- test/modules/md/test_920_status.py | 15 +++-- test/pyhttpd/certs.py | 83 +++++++++++++++++------- 15 files changed, 254 insertions(+), 147 deletions(-) create mode 100644 changes-entries/md_v2.4.28.txt diff --git a/changes-entries/md_v2.4.28.txt b/changes-entries/md_v2.4.28.txt new file mode 100644 index 00000000000..3eb2bc49177 --- /dev/null +++ b/changes-entries/md_v2.4.28.txt @@ -0,0 +1,11 @@ + *) mod_md: update to version 2.4.28 + - When the server starts, it looks for new, staged certificates to + activate. If the staged set of files in 'md/staging/' is messed + up, this could prevent further renewals to happen. Now, when the staging + set is present, but could not be activated due to an error, purge the + whole directory. [icing] + - Fix certificate retrieval on ACME renewal to not require a 'Location:' + header returned by the ACME CA. This was the way it was done in ACME + before it became an IETF standard. Let's Encrypt still supports this, + but other CAs do not. [icing] + - Restore compatibility with OpenSSL < 1.1. [ylavic] diff --git a/modules/md/md_acme_drive.c b/modules/md/md_acme_drive.c index 4bb04f321c6..0ec409c8637 100644 --- a/modules/md/md_acme_drive.c +++ b/modules/md/md_acme_drive.c @@ -305,11 +305,11 @@ static apr_status_t csr_req(md_acme_t *acme, const md_http_response_t *res, void (void)acme; location = apr_table_get(res->headers, "location"); - if (!location) { - md_log_perror(MD_LOG_MARK, MD_LOG_ERR, APR_EINVAL, d->p, - "cert created without giving its location header"); - return APR_EINVAL; - } + if (!location) + return rv; + + md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, d->p, + "cert created with location header (old ACMEv1 style)"); ad->order->certificate = apr_pstrdup(d->p, location); if (APR_SUCCESS != (rv = md_acme_order_save(d->store, d->p, MD_SG_STAGING, d->md->name, ad->order, 0))) { diff --git a/modules/md/md_reg.c b/modules/md/md_reg.c index 6aa7d788769..dc49446ae45 100644 --- a/modules/md/md_reg.c +++ b/modules/md/md_reg.c @@ -1194,7 +1194,7 @@ static apr_status_t run_load_staging(void *baton, apr_pool_t *p, apr_pool_t *pte result = va_arg(ap, md_result_t*); if (APR_STATUS_IS_ENOENT(rv = md_load(reg->store, MD_SG_STAGING, md->name, NULL, ptemp))) { - md_log_perror(MD_LOG_MARK, MD_LOG_TRACE2, 0, ptemp, "%s: nothing staged", md->name); + md_log_perror(MD_LOG_MARK, MD_LOG_DEBUG, 0, ptemp, "%s: nothing staged", md->name); goto out; } @@ -1259,7 +1259,9 @@ apr_status_t md_reg_load_stagings(md_reg_t *reg, apr_array_header_t *mds, } else if (!APR_STATUS_IS_ENOENT(rv)) { md_log_perror(MD_LOG_MARK, MD_LOG_ERR, rv, p, APLOGNO(10069) - "%s: error loading staged set", md->name); + "%s: error loading staged set, purging it", md->name); + md_store_purge(reg->store, p, MD_SG_STAGING, md->name); + md_store_purge(reg->store, p, MD_SG_CHALLENGES, md->name); } } diff --git a/modules/md/md_version.h b/modules/md/md_version.h index cefbb8ded72..3e2914d6b6d 100644 --- a/modules/md/md_version.h +++ b/modules/md/md_version.h @@ -27,7 +27,7 @@ * @macro * Version number of the md module as c string */ -#define MOD_MD_VERSION "2.4.26" +#define MOD_MD_VERSION "2.4.28" /** * @macro @@ -35,7 +35,7 @@ * release. This is a 24 bit number with 8 bits for major number, 8 bits * for minor and 8 bits for patch. Version 1.2.3 becomes 0x010203. */ -#define MOD_MD_VERSION_NUM 0x02041a +#define MOD_MD_VERSION_NUM 0x02041c #define MD_ACME_DEF_URL "https://acme-v02.api.letsencrypt.org/directory" #define MD_TAILSCALE_DEF_URL "file://localhost/var/run/tailscale/tailscaled.sock" diff --git a/test/modules/md/conftest.py b/test/modules/md/conftest.py index a7b064b6a98..0118de5e133 100755 --- a/test/modules/md/conftest.py +++ b/test/modules/md/conftest.py @@ -39,9 +39,7 @@ def env(pytestconfig) -> MDTestEnv: @pytest.fixture(autouse=True, scope="package") def _md_package_scope(env): env.httpd_error_log.add_ignored_lognos([ - "AH10085", # There are no SSL certificates configured and no other module contributed any - "AH10045", # No VirtualHost matches Managed Domain - "AH10105", # MDomain does not match any VirtualHost with 'SSLEngine on' + "AH10085" # There are no SSL certificates configured and no other module contributed any ]) @@ -59,7 +57,3 @@ def acme(env): if acme_server is not None: acme_server.stop() -@pytest.fixture(autouse=True, scope="package") -def _stop_package_scope(env): - yield - assert env.apache_stop() == 0 diff --git a/test/modules/md/md_cert_util.py b/test/modules/md/md_cert_util.py index abcd36b938c..6cd034a02b5 100755 --- a/test/modules/md/md_cert_util.py +++ b/test/modules/md/md_cert_util.py @@ -1,6 +1,5 @@ import logging import re -import os import socket import OpenSSL import time @@ -12,6 +11,7 @@ from http.client import HTTPConnection from urllib.parse import urlparse +from cryptography import x509 SEC_PER_DAY = 24 * 60 * 60 @@ -23,45 +23,6 @@ class MDCertUtil(object): # Utility class for inspecting certificates in test cases # Uses PyOpenSSL: https://pyopenssl.org/en/stable/index.html - @classmethod - def create_self_signed_cert(cls, path, name_list, valid_days, serial=1000): - domain = name_list[0] - if not os.path.exists(path): - os.makedirs(path) - - cert_file = os.path.join(path, 'pubcert.pem') - pkey_file = os.path.join(path, 'privkey.pem') - # create a key pair - if os.path.exists(pkey_file): - key_buffer = open(pkey_file, 'rt').read() - k = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key_buffer) - else: - k = OpenSSL.crypto.PKey() - k.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) - - # create a self-signed cert - cert = OpenSSL.crypto.X509() - cert.get_subject().C = "DE" - cert.get_subject().ST = "NRW" - cert.get_subject().L = "Muenster" - cert.get_subject().O = "greenbytes GmbH" - cert.get_subject().CN = domain - cert.set_serial_number(serial) - cert.gmtime_adj_notBefore(valid_days["notBefore"] * SEC_PER_DAY) - cert.gmtime_adj_notAfter(valid_days["notAfter"] * SEC_PER_DAY) - cert.set_issuer(cert.get_subject()) - - cert.add_extensions([OpenSSL.crypto.X509Extension( - b"subjectAltName", False, b", ".join(map(lambda n: b"DNS:" + n.encode(), name_list)) - )]) - cert.set_pubkey(k) - cert.sign(k, 'sha1') - - open(cert_file, "wt").write( - OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert).decode('utf-8')) - open(pkey_file, "wt").write( - OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, k).decode('utf-8')) - @classmethod def load_server_cert(cls, host_ip, host_port, host_name, tls=None, ciphers=None): ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) @@ -138,17 +99,26 @@ def get_serial(self): # add leading 0s to align with word boundaries. return ("%lx" % (self.cert.get_serial_number())).upper() - def same_serial_as(self, other): - if isinstance(other, MDCertUtil): - return self.cert.get_serial_number() == other.cert.get_serial_number() - elif isinstance(other, OpenSSL.crypto.X509): - return self.cert.get_serial_number() == other.get_serial_number() - elif isinstance(other, str): + @staticmethod + def _get_serial(cert) -> int: + if isinstance(cert, x509.Certificate): + return cert.serial_number + if isinstance(cert, MDCertUtil): + return cert.get_serial_number() + elif isinstance(cert, OpenSSL.crypto.X509): + return cert.get_serial_number() + elif isinstance(cert, str): # assume a hex number - return self.cert.get_serial_number() == int(other, 16) - elif isinstance(other, int): - return self.cert.get_serial_number() == other - return False + return int(cert, 16) + elif isinstance(cert, int): + return cert + return 0 + + def get_serial_number(self): + return self._get_serial(self.cert) + + def same_serial_as(self, other): + return self._get_serial(self.cert) == self._get_serial(other) def get_not_before(self): tsp = self.cert.get_notBefore() diff --git a/test/modules/md/md_env.py b/test/modules/md/md_env.py index 360086f97b3..acc8417b149 100755 --- a/test/modules/md/md_env.py +++ b/test/modules/md/md_env.py @@ -12,9 +12,9 @@ import time from datetime import datetime, timedelta -from typing import Dict, Optional +from typing import Dict, Optional, Any -from pyhttpd.certs import CertificateSpec +from pyhttpd.certs import CertificateSpec, Credentials, HttpdTestCA from .md_cert_util import MDCertUtil from pyhttpd.env import HttpdTestSetup, HttpdTestEnv from pyhttpd.result import ExecResult @@ -73,10 +73,10 @@ def has_acme_server(cls): @classmethod def has_acme_eab(cls): - # Pebble v2.5.0 and v2.5.1 do not support HS256 for EAB, which - # is the only thing mod_md supports. - # Should work for pebble until v2.4.0 and v2.5.2+. - # Reference: https://github.com/letsencrypt/pebble/issues/455 + # Pebble, in v2.5.0 no longer supported HS256 for EAB, which + # is the only thing mod_md supports. Issue opened at pebble: + # https://github.com/letsencrypt/pebble/issues/455 + # is fixed in v2.6.0 return cls.get_acme_server() == 'pebble' @classmethod @@ -611,8 +611,13 @@ def await_ocsp_status(self, domain, timeout=10, ca_file=None): time.sleep(0.1) raise TimeoutError(f"ocsp respopnse not available: {domain}") - def create_self_signed_cert(self, name_list, valid_days, serial=1000, path=None): - dirpath = path - if not path: - dirpath = os.path.join(self.store_domains(), name_list[0]) - return MDCertUtil.create_self_signed_cert(dirpath, name_list, valid_days, serial) + def create_self_signed_cert(self, spec: CertificateSpec, + valid_from: timedelta = timedelta(days=-1), + valid_to: timedelta = timedelta(days=89), + serial: Optional[int] = None) -> Credentials: + key_type = spec.key_type if spec.key_type else 'rsa4096' + return HttpdTestCA.create_credentials(spec=spec, issuer=None, + key_type=key_type, + valid_from=valid_from, + valid_to=valid_to, + serial=serial) diff --git a/test/modules/md/test_502_acmev2_drive.py b/test/modules/md/test_502_acmev2_drive.py index eb754f25eff..b064647450e 100644 --- a/test/modules/md/test_502_acmev2_drive.py +++ b/test/modules/md/test_502_acmev2_drive.py @@ -4,11 +4,12 @@ import json import os.path import re -import time +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec -from .md_conf import MDConf, MDConf +from .md_conf import MDConf from .md_cert_util import MDCertUtil from .md_env import MDTestEnv @@ -430,9 +431,12 @@ def test_md_502_201(self, env, renew_window, test_data_list): print("TRACE: start testing renew window: %s" % renew_window) for tc in test_data_list: print("TRACE: create self-signed cert: %s" % tc["valid"]) - env.create_self_signed_cert([name], tc["valid"]) - cert2 = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) - assert not cert2.same_serial_as(cert1) + creds = env.create_self_signed_cert(CertificateSpec(domains=[name]), + valid_from=timedelta(days=tc["valid"]["notBefore"]), + valid_to=timedelta(days=tc["valid"]["notAfter"])) + assert creds.certificate.serial_number != cert1.get_serial_number() + # copy it over, assess status again + creds.save_cert_pem(env.store_domain_file(name, 'pubcert.pem')) md = env.a2md(["list", name]).json['output'][0] assert md["renew"] == tc["renew"], \ "Expected renew == {} indicator in {}, test case {}".format(tc["renew"], md, tc) diff --git a/test/modules/md/test_702_auto.py b/test/modules/md/test_702_auto.py index 04a9c7561aa..90103e3aff7 100644 --- a/test/modules/md/test_702_auto.py +++ b/test/modules/md/test_702_auto.py @@ -1,9 +1,9 @@ import os -import time +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec -from pyhttpd.conf import HttpdConf from pyhttpd.env import HttpdTestEnv from .md_cert_util import MDCertUtil from .md_env import MDTestEnv @@ -320,18 +320,22 @@ def test_md_702_009(self, env): assert cert1.same_serial_as(stat['rsa']['serial']) # # create self-signed cert, with critical remaining valid duration -> drive again - env.create_self_signed_cert([domain], {"notBefore": -120, "notAfter": 2}, serial=7029) - cert3 = MDCertUtil(env.store_domain_file(domain, 'pubcert.pem')) - assert cert3.same_serial_as('1B75') + creds = env.create_self_signed_cert(CertificateSpec(domains=[domain]), + valid_from=timedelta(days=-120), + valid_to=timedelta(days=2), + serial=7029) + creds.save_cert_pem(env.store_domain_file(domain, 'pubcert.pem')) + creds.save_pkey_pem(env.store_domain_file(domain, 'privkey.pem')) + assert creds.certificate.serial_number == 7029 assert env.apache_restart() == 0 stat = env.get_certificate_status(domain) - assert cert3.same_serial_as(stat['rsa']['serial']) + assert creds.certificate.serial_number == int(stat['rsa']['serial'], 16) # # cert should renew and be different afterwards assert env.await_completion([domain], must_renew=True) stat = env.get_certificate_status(domain) - assert not cert3.same_serial_as(stat['rsa']['serial']) - + creds.certificate.serial_number != int(stat['rsa']['serial'], 16) + # test case: drive with an unsupported challenge due to port availability def test_md_702_010(self, env): domain = self.test_domain @@ -543,6 +547,40 @@ def test_md_702_032(self, env): assert name2 in cert1b.get_san_list() assert not cert1.same_serial_as(cert1b) + # test case: one MD on a vhost with ServerAlias. Renew. + # Exchange ServerName and ServerAlias. Is the rename detected? + # See: https://github.com/icing/mod_md/issues/338 + def test_md_702_033(self, env): + domain = self.test_domain + name_x = "test-x." + domain + name_a = "test-a." + domain + domains1 = [name_x, name_a] + # + # generate 1 MD and 2 vhosts + conf = MDConf(env, admin="admin@" + domain) + conf.add_md(domains=[name_x]) + conf.add_vhost(domains=domains1) + conf.install() + # + # restart (-> drive), check that MD was synched and completes + assert env.apache_restart() == 0 + env.check_md(domains1) + assert env.await_completion([name_x]) + env.check_md_complete(name_x) + cert_x = env.get_cert(name_x) + # + # reverse ServerName and ServerAlias + domains2 = [name_a, name_x] + conf = MDConf(env, admin="admin@" + domain) + conf.add_md(domains=[name_a]) + conf.add_vhost(domains=domains2) + conf.install() + # restart, check that host still works and kept the cert + assert env.apache_restart() == 0 + status = env.get_certificate_status(name_a) + assert cert_x.same_serial_as(status['rsa']['serial']) + + # test case: test "tls-alpn-01" challenge handling def test_md_702_040(self, env): domain = self.test_domain diff --git a/test/modules/md/test_730_static.py b/test/modules/md/test_730_static.py index 891ae620bb8..209d33a1aa5 100644 --- a/test/modules/md/test_730_static.py +++ b/test/modules/md/test_730_static.py @@ -1,6 +1,8 @@ import os +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec from .md_conf import MDConf from .md_env import MDTestEnv @@ -30,12 +32,14 @@ def test_md_730_001(self, env): domains = [domain, 'www.%s' % domain] testpath = os.path.join(env.gen_dir, 'test_920_001') # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10}, - serial=730001, path=testpath) + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-80), + valid_to=timedelta(days=10), + serial=730001) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.start_md(domains) conf.add(f"MDCertificateFile {cert_file}") @@ -60,12 +64,14 @@ def test_md_730_002(self, env): domains = [domain, 'www.%s' % domain] testpath = os.path.join(env.gen_dir, 'test_920_001') # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10}, - serial=730001, path=testpath) + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-80), + valid_to=timedelta(days=10), + serial=730001) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.start_md(domains) conf.add(f"MDPrivateKeys secp384r1 rsa3072") @@ -93,13 +99,14 @@ def test_md_730_003(self, env): domains = [domain, 'www.%s' % domain] testpath = os.path.join(env.gen_dir, 'test_920_001') # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -80, "notAfter": 10}, - serial=730001, path=testpath) + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-80), + valid_to=timedelta(days=10), + serial=730001) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) - + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.start_md(domains) conf.add(f"MDCertificateFile {cert_file}") diff --git a/test/modules/md/test_741_setup_errors.py b/test/modules/md/test_741_setup_errors.py index 9ad79f0b1e9..958f13f4d13 100644 --- a/test/modules/md/test_741_setup_errors.py +++ b/test/modules/md/test_741_setup_errors.py @@ -56,3 +56,29 @@ def test_md_741_001(self, env): r'.*CA considers answer to challenge invalid.*' ] ) + + # mess up the produced staging area before reload + def test_md_741_002(self, env): + domain = self.test_domain + domains = [domain] + conf = MDConf(env) + conf.add_md(domains) + conf.add_vhost(domains) + conf.install() + assert env.apache_restart() == 0 + env.check_md(domains) + assert env.await_completion([domain], restart=False) + staged_md_path = env.store_staged_file(domain, 'md.json') + with open(staged_md_path, 'w') as fd: + fd.write('garbage\n') + assert env.apache_restart() == 0 + assert env.await_completion([domain]) + env.check_md_complete(domain) + env.httpd_error_log.ignore_recent( + lognos = [ + "AH10069" # failed to load JSON file + ], + matches = [ + r'.*failed to load JSON file.*', + ] + ) diff --git a/test/modules/md/test_801_stapling.py b/test/modules/md/test_801_stapling.py index 5c0360251b5..1eacfabf885 100644 --- a/test/modules/md/test_801_stapling.py +++ b/test/modules/md/test_801_stapling.py @@ -2,7 +2,9 @@ import os import time +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec from .md_conf import MDConf from .md_env import MDTestEnv @@ -334,12 +336,14 @@ def test_md_801_009(self, env): domains = [md] testpath = os.path.join(env.gen_dir, 'test_801_009') # cert that is 30 more days valid - env.create_self_signed_cert(domains, {"notBefore": -60, "notAfter": 30}, - serial=801009, path=testpath) + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-60), + valid_to=timedelta(days=30), + serial=801009) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.start_md(domains) conf.add("MDCertificateFile %s" % cert_file) diff --git a/test/modules/md/test_901_message.py b/test/modules/md/test_901_message.py index b18cfd38d44..d5d66e6e1a1 100644 --- a/test/modules/md/test_901_message.py +++ b/test/modules/md/test_901_message.py @@ -3,9 +3,11 @@ import json import os import time +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec -from .md_conf import MDConf, MDConf +from .md_conf import MDConf from .md_env import MDTestEnv @@ -155,13 +157,15 @@ def test_md_901_010(self, env): domain = self.test_domain domains = [domain, 'www.%s' % domain] testpath = os.path.join(env.gen_dir, 'test_901_010') - # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -70, "notAfter": 20}, - serial=901010, path=testpath) + # cert that is only 20 more days valid + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-70), + valid_to=timedelta(days=20), + serial=901010) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}") conf.start_md(domains) @@ -178,13 +182,15 @@ def test_md_901_011(self, env): domain = self.test_domain domains = [domain, f'www.{domain}'] testpath = os.path.join(env.gen_dir, 'test_901_011') - # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -85, "notAfter": 5}, - serial=901011, path=testpath) + # cert that is only 5 more days valid + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-85), + valid_to=timedelta(days=5), + serial=901010) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env) conf.add(f"MDMessageCmd {self.mcmd} {self.mlog}") conf.start_md(domains) diff --git a/test/modules/md/test_920_status.py b/test/modules/md/test_920_status.py index 6ad708728c7..6a5b338559b 100644 --- a/test/modules/md/test_920_status.py +++ b/test/modules/md/test_920_status.py @@ -2,9 +2,10 @@ import os import re -import time +from datetime import timedelta import pytest +from pyhttpd.certs import CertificateSpec from .md_conf import MDConf from shutil import copyfile @@ -165,13 +166,15 @@ def test_md_920_011(self, env): domain = self.test_domain domains = [domain, 'www.%s' % domain] testpath = os.path.join(env.gen_dir, 'test_920_011') - # cert that is only 10 more days valid - env.create_self_signed_cert(domains, {"notBefore": -70, "notAfter": 20}, - serial=920011, path=testpath) + # cert that is only 20 more days valid + creds = env.create_self_signed_cert(CertificateSpec(domains=domains), + valid_from=timedelta(days=-70), + valid_to=timedelta(days=20), + serial=920011) cert_file = os.path.join(testpath, 'pubcert.pem') pkey_file = os.path.join(testpath, 'privkey.pem') - assert os.path.exists(cert_file) - assert os.path.exists(pkey_file) + creds.save_cert_pem(cert_file) + creds.save_pkey_pem(pkey_file) conf = MDConf(env, std_vhosts=False, std_ports=False, text=f""" MDBaseServer on MDPortMap http:- https:{env.https_port} diff --git a/test/pyhttpd/certs.py b/test/pyhttpd/certs.py index 5519f16188b..a08d5e64e4f 100644 --- a/test/pyhttpd/certs.py +++ b/test/pyhttpd/certs.py @@ -181,6 +181,14 @@ def issue_cert(self, spec: CertificateSpec, chain: List['Credentials'] = None) - creds.issue_certs(spec.sub_specs, chain=subchain) return creds + def save_cert_pem(self, fpath): + with open(fpath, "wb") as fd: + fd.write(self.cert_pem) + + def save_pkey_pem(self, fpath): + with open(fpath, "wb") as fd: + fd.write(self.pkey_pem) + class CertStore: @@ -282,6 +290,7 @@ def create_root(cls, name: str, store_dir: str, key_type: str = "rsa2048") -> Cr def create_credentials(spec: CertificateSpec, issuer: Credentials, key_type: Any, valid_from: timedelta = timedelta(days=-1), valid_to: timedelta = timedelta(days=89), + serial: Optional[int] = None, ) -> Credentials: """Create a certificate signed by this CA for the given domains. :returns: the certificate and private key PEM file paths @@ -289,15 +298,18 @@ def create_credentials(spec: CertificateSpec, issuer: Credentials, key_type: Any if spec.domains and len(spec.domains): creds = HttpdTestCA._make_server_credentials(name=spec.name, domains=spec.domains, issuer=issuer, valid_from=valid_from, - valid_to=valid_to, key_type=key_type) + valid_to=valid_to, key_type=key_type, + serial=serial) elif spec.client: creds = HttpdTestCA._make_client_credentials(name=spec.name, issuer=issuer, email=spec.email, valid_from=valid_from, - valid_to=valid_to, key_type=key_type) + valid_to=valid_to, key_type=key_type, + serial=serial) elif spec.name: creds = HttpdTestCA._make_ca_credentials(name=spec.name, issuer=issuer, valid_from=valid_from, valid_to=valid_to, - key_type=key_type) + key_type=key_type, + serial=serial) else: raise Exception(f"unrecognized certificate specification: {spec}") return creds @@ -320,7 +332,8 @@ def _make_csr( pkey: Any, issuer_subject: Optional[Credentials], valid_from_delta: timedelta = None, - valid_until_delta: timedelta = None + valid_until_delta: timedelta = None, + serial: Optional[int] = None ): pubkey = pkey.public_key() issuer_subject = issuer_subject if issuer_subject is not None else subject @@ -331,7 +344,8 @@ def _make_csr( valid_until = datetime.now() if valid_until_delta is not None: valid_until += valid_until_delta - + if serial is None: + serial = x509.random_serial_number() return ( x509.CertificateBuilder() .subject_name(subject) @@ -339,7 +353,7 @@ def _make_csr( .public_key(pubkey) .not_valid_before(valid_from) .not_valid_after(valid_until) - .serial_number(x509.random_serial_number()) + .serial_number(serial) .add_extension( x509.SubjectKeyIdentifier.from_public_key(pubkey), critical=False, @@ -374,23 +388,28 @@ def _add_ca_usages(csr: Any) -> Any: @staticmethod def _add_leaf_usages(csr: Any, domains: List[str], issuer: Credentials) -> Any: - return csr.add_extension( + csr = csr.add_extension( x509.BasicConstraints(ca=False, path_length=None), critical=True, - ).add_extension( - x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( - issuer.certificate.extensions.get_extension_for_class( - x509.SubjectKeyIdentifier).value), - critical=False - ).add_extension( + ) + if issuer is not None: + csr = csr.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( + issuer.certificate.extensions.get_extension_for_class( + x509.SubjectKeyIdentifier).value), + critical=False + ) + csr = csr.add_extension( x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains]), critical=True, - ).add_extension( + ) + csr = csr.add_extension( x509.ExtendedKeyUsage([ ExtendedKeyUsageOID.SERVER_AUTH, ]), critical=True ) + return csr @staticmethod def _add_client_usages(csr: Any, issuer: Credentials, rfc82name: str = None) -> Any: @@ -421,6 +440,7 @@ def _make_ca_credentials(name, key_type: Any, issuer: Credentials = None, valid_from: timedelta = timedelta(days=-1), valid_to: timedelta = timedelta(days=89), + serial: Optional[int] = None, ) -> Credentials: pkey = _private_key(key_type=key_type) if issuer is not None: @@ -432,7 +452,8 @@ def _make_ca_credentials(name, key_type: Any, subject = HttpdTestCA._make_x509_name(org_name=name, parent=issuer.subject if issuer else None) csr = HttpdTestCA._make_csr(subject=subject, issuer_subject=issuer_subject, pkey=pkey, - valid_from_delta=valid_from, valid_until_delta=valid_to) + valid_from_delta=valid_from, valid_until_delta=valid_to, + serial=serial) csr = HttpdTestCA._add_ca_usages(csr) cert = csr.sign(private_key=issuer_key, algorithm=hashes.SHA256(), @@ -444,15 +465,23 @@ def _make_server_credentials(name: str, domains: List[str], issuer: Credentials, key_type: Any, valid_from: timedelta = timedelta(days=-1), valid_to: timedelta = timedelta(days=89), + serial: Optional[int] = None, ) -> Credentials: name = name pkey = _private_key(key_type=key_type) - subject = HttpdTestCA._make_x509_name(common_name=name, parent=issuer.subject) + if issuer is not None: + issuer_subject = issuer.certificate.subject + issuer_key = issuer.private_key + else: + issuer_subject = None + issuer_key = pkey + subject = HttpdTestCA._make_x509_name(common_name=name, parent=issuer_subject) csr = HttpdTestCA._make_csr(subject=subject, - issuer_subject=issuer.certificate.subject, pkey=pkey, - valid_from_delta=valid_from, valid_until_delta=valid_to) + issuer_subject=issuer_subject, pkey=pkey, + valid_from_delta=valid_from, valid_until_delta=valid_to, + serial=serial) csr = HttpdTestCA._add_leaf_usages(csr, domains=domains, issuer=issuer) - cert = csr.sign(private_key=issuer.private_key, + cert = csr.sign(private_key=issuer_key, algorithm=hashes.SHA256(), backend=default_backend()) return Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer) @@ -463,14 +492,22 @@ def _make_client_credentials(name: str, key_type: Any, valid_from: timedelta = timedelta(days=-1), valid_to: timedelta = timedelta(days=89), + serial: Optional[int] = None, ) -> Credentials: pkey = _private_key(key_type=key_type) - subject = HttpdTestCA._make_x509_name(common_name=name, parent=issuer.subject) + if issuer is not None: + issuer_subject = issuer.certificate.subject + issuer_key = issuer.private_key + else: + issuer_subject = None + issuer_key = pkey + subject = HttpdTestCA._make_x509_name(common_name=name, parent=issuer_subject) csr = HttpdTestCA._make_csr(subject=subject, - issuer_subject=issuer.certificate.subject, pkey=pkey, - valid_from_delta=valid_from, valid_until_delta=valid_to) + issuer_subject=issuer_subject, pkey=pkey, + valid_from_delta=valid_from, valid_until_delta=valid_to, + serial=serial) csr = HttpdTestCA._add_client_usages(csr, issuer=issuer, rfc82name=email) - cert = csr.sign(private_key=issuer.private_key, + cert = csr.sign(private_key=issuer_key, algorithm=hashes.SHA256(), backend=default_backend()) return Credentials(name=name, cert=cert, pkey=pkey, issuer=issuer)