From 6beeb1b708550be0d4a53b272283e17e5e35fe17 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 17:01:30 +0200 Subject: Adding upstream version 2.4.57. Signed-off-by: Daniel Baumann --- test/modules/md/test_502_acmev2_drive.py | 549 +++++++++++++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 test/modules/md/test_502_acmev2_drive.py (limited to 'test/modules/md/test_502_acmev2_drive.py') diff --git a/test/modules/md/test_502_acmev2_drive.py b/test/modules/md/test_502_acmev2_drive.py new file mode 100644 index 0000000..eb754f2 --- /dev/null +++ b/test/modules/md/test_502_acmev2_drive.py @@ -0,0 +1,549 @@ +# test driving the ACMEv2 protocol + +import base64 +import json +import os.path +import re +import time + +import pytest + +from .md_conf import MDConf, MDConf +from .md_cert_util import MDCertUtil +from .md_env import MDTestEnv + + +@pytest.mark.skipif(condition=not MDTestEnv.has_a2md(), reason="no a2md available") +@pytest.mark.skipif(condition=not MDTestEnv.has_acme_server(), + reason="no ACME test server configured") +class TestDrivev2: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env, acme): + acme.start(config='default') + env.check_acme() + env.APACHE_CONF_SRC = "data/test_drive" + MDConf(env).install() + assert env.apache_restart() == 0 + + @pytest.fixture(autouse=True, scope='function') + def _method_scope(self, env, request): + env.clear_store() + MDConf(env).install() + self.test_domain = env.get_request_domain(request) + + # --------- invalid precondition --------- + + def test_md_502_000(self, env): + # test case: md without contact info + domain = self.test_domain + name = "www." + domain + assert env.a2md(["add", name]).exit_code == 0 + r = env.a2md(["drive", name]) + assert r.exit_code == 1 + assert re.search("No contact information", r.stderr) + + def test_md_502_001(self, env): + # test case: md with contact, but without TOS + domain = self.test_domain + name = "www." + domain + assert env.a2md(["add", name]).exit_code == 0 + assert env.a2md( + ["update", name, "contacts", "admin@test1.not-forbidden.org"] + ).exit_code == 0 + r = env.a2md(["drive", name]) + assert r.exit_code == 1 + assert re.search("the CA requires you to accept the terms-of-service as specified in ", r.stderr) + + # test_102 removed, was based on false assumption + def test_md_502_003(self, env): + # test case: md with unknown protocol FOO + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.a2md( + ["update", name, "ca", env.acme_url, "FOO"] + ).exit_code == 0 + r = env.a2md(["drive", name]) + assert r.exit_code == 1 + assert re.search("Unknown CA protocol", r.stderr) + + # --------- driving OK --------- + + def test_md_502_100(self, env): + # test case: md with one domain + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.apache_restart() == 0 + # drive + prev_md = env.a2md(["list", name]).json['output'][0] + r = env.a2md(["-vv", "drive", "-c", "http-01", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + env.check_md_credentials([name]) + self._check_account_key(env, name) + + # check archive content + store_md = json.loads(open(env.store_archived_file(name, 1, 'md.json')).read()) + for f in ['name', 'ca', 'domains', 'contacts', 'renew-mode', 'renew-window', 'must-staple']: + assert store_md[f] == prev_md[f] + + # check file system permissions: + env.check_file_permissions(name) + # check: challenges removed + env.check_dir_empty(env.store_challenges()) + # check how the challenge resources are answered in sevceral combinations + r = env.get_meta(domain, "/.well-known/acme-challenge", False) + assert r.exit_code == 0 + assert r.response['status'] == 404 + r = env.get_meta(domain, "/.well-known/acme-challenge/", False) + assert r.exit_code == 0 + assert r.response['status'] == 404 + r = env.get_meta(domain, "/.well-known/acme-challenge/123", False) + assert r.exit_code == 0 + assert r.response['status'] == 404 + assert r.exit_code == 0 + cdir = os.path.join(env.store_challenges(), domain) + os.makedirs(cdir) + open(os.path.join(cdir, 'acme-http-01.txt'), "w").write("content-of-123") + r = env.get_meta(domain, "/.well-known/acme-challenge/123", False) + assert r.exit_code == 0 + assert r.response['status'] == 200 + assert r.response['header']['content-length'] == '14' + + def test_md_502_101(self, env): + # test case: md with 2 domains + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name, "test." + domain]) + assert env.apache_restart() == 0 + # drive + r = env.a2md(["-vv", "drive", "-c", "http-01", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + env.check_md_credentials([name, "test." + domain]) + + # test_502_102 removed, as accounts without ToS are not allowed in ACMEv2 + + def test_md_502_103(self, env): + # test case: md with one domain, ACME account and TOS agreement on server + # setup: create md + domain = self.test_domain + name = "www." + domain + assert env.a2md(["add", name]).exit_code == 0 + assert env.a2md(["update", name, "contacts", "admin@" + domain]).exit_code == 0 + assert env.apache_restart() == 0 + # setup: create account on server + r = env.a2md(["-t", "accepted", "acme", "newreg", "admin@" + domain], raw=True) + assert r.exit_code == 0 + acct = re.match("registered: (.*)$", r.stdout).group(1) + # setup: link md to account + assert env.a2md(["update", name, "account", acct]).exit_code == 0 + # drive + r = env.a2md(["-vv", "drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + env.check_md_credentials([name]) + + # test_502_104 removed, order are created differently in ACMEv2 + + def test_md_502_105(self, env): + # test case: md with one domain, local TOS agreement and ACME account that is deleted (!) on server + # setup: create md + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.apache_restart() == 0 + # setup: create account on server + r = env.a2md(["-t", "accepted", "acme", "newreg", "test@" + domain], raw=True) + assert r.exit_code == 0 + acct = re.match("registered: (.*)$", r.stdout).group(1) + # setup: link md to account + assert env.a2md(["update", name, "account", acct]).exit_code == 0 + # setup: delete account on server + assert env.a2md(["acme", "delreg", acct]).exit_code == 0 + # drive + r = env.a2md(["drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + env.check_md_credentials([name]) + + def test_md_502_107(self, env): + # test case: drive again on COMPLETE md, then drive --force + # setup: prepare md in store + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.apache_restart() == 0 + # drive + r = env.a2md(["-vv", "drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + env.check_md_credentials([name]) + orig_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + + # drive again + assert env.a2md(["-vv", "drive", name]).exit_code == 0 + env.check_md_credentials([name]) + cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + # check: cert not changed + assert cert.same_serial_as(orig_cert) + + # drive --force + assert env.a2md(["-vv", "drive", "--force", name]).exit_code == 0 + env.check_md_credentials([name]) + cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + # check: cert not changed + assert not cert.same_serial_as(orig_cert) + # check: previous cert was archived + cert = MDCertUtil(env.store_archived_file(name, 2, 'pubcert.pem')) + assert cert.same_serial_as(orig_cert) + + def test_md_502_108(self, env): + # test case: drive via HTTP proxy + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + conf = MDConf(env, proxy=True) + conf.add('LogLevel proxy:trace8') + conf.install() + assert env.apache_restart() == 0 + + # drive it, with wrong proxy url -> FAIL + r = env.a2md(["-p", "http://localhost:1", "drive", name]) + assert r.exit_code == 1 + assert "Connection refused" in r.stderr + + # drive it, working proxy url -> SUCCESS + proxy_url = f"http://localhost:{env.proxy_port}" + r = env.a2md(["-vv", "-p", proxy_url, "drive", name]) + assert 0 == r.exit_code, "a2md failed: {0}".format(r.stderr) + env.check_md_credentials([name]) + + def test_md_502_109(self, env): + # test case: redirect on SSL-only domain + # setup: prepare config + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add_md([name]) + conf.add_vhost(name, port=env.http_port, doc_root="htdocs/test") + conf.add_vhost(name, doc_root="htdocs/test") + conf.install() + # setup: create resource files + self._write_res_file(os.path.join(env.server_docs_dir, "test"), "name.txt", name) + self._write_res_file(os.path.join(env.server_docs_dir), "name.txt", "not-forbidden.org") + assert env.apache_restart() == 0 + + # drive it + assert env.a2md(["drive", name]).exit_code == 0 + assert env.apache_restart() == 0 + # test HTTP access - no redirect + jdata = env.get_json_content(f"test1.{env.http_tld}", "/alive.json", use_https=False) + assert jdata['host']== "test1" + assert env.get_content(name, "/name.txt", use_https=False) == name + r = env.get_meta(name, "/name.txt", use_https=False) + assert int(r.response['header']['content-length']) == len(name) + assert "Location" not in r.response['header'] + # test HTTPS access + assert env.get_content(name, "/name.txt", use_https=True) == name + + # test HTTP access again -> redirect to default HTTPS port + conf.add("MDRequireHttps temporary") + conf.install() + assert env.apache_restart() == 0 + r = env.get_meta(name, "/name.txt", use_https=False) + assert r.response['status'] == 302 + exp_location = "https://%s/name.txt" % name + assert r.response['header']['location'] == exp_location + # should not see this + assert 'strict-transport-security' not in r.response['header'] + # test default HTTP vhost -> still no redirect + jdata = env.get_json_content(f"test1.{env.http_tld}", "/alive.json", use_https=False) + assert jdata['host']== "test1" + r = env.get_meta(name, "/name.txt", use_https=True) + # also not for this + assert 'strict-transport-security' not in r.response['header'] + + # test HTTP access again -> redirect permanent + conf.add("MDRequireHttps permanent") + conf.install() + assert env.apache_restart() == 0 + r = env.get_meta(name, "/name.txt", use_https=False) + assert r.response['status'] == 301 + exp_location = "https://%s/name.txt" % name + assert r.response['header']['location'] == exp_location + assert 'strict-transport-security' not in r.response['header'] + # should see this + r = env.get_meta(name, "/name.txt", use_https=True) + assert r.response['header']['strict-transport-security'] == 'max-age=15768000' + + def test_md_502_110(self, env): + # test case: SSL-only domain, override headers generated by mod_md + # setup: prepare config + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add("MDRequireHttps permanent") + conf.add_md([name]) + conf.add_vhost(name, port=env.http_port) + conf.add_vhost(name) + conf.install() + assert env.apache_restart() == 0 + # drive it + assert env.a2md(["drive", name]).exit_code == 0 + assert env.apache_restart() == 0 + + # test override HSTS header + conf.add('Header set Strict-Transport-Security "max-age=10886400; includeSubDomains; preload"') + conf.install() + assert env.apache_restart() == 0 + r = env.get_meta(name, "/name.txt", use_https=True) + assert 'strict-transport-security' in r.response['header'], r.response['header'] + assert r.response['header']['strict-transport-security'] == \ + 'max-age=10886400; includeSubDomains; preload' + + # test override Location header + conf.add(' Redirect /a /name.txt') + conf.add(' Redirect seeother /b /name.txt') + conf.install() + assert env.apache_restart() == 0 + # check: default redirect by mod_md still works + exp_location = "https://%s/name.txt" % name + r = env.get_meta(name, "/name.txt", use_https=False) + assert r.response['status'] == 301 + assert r.response['header']['location'] == exp_location + # check: redirect as given by mod_alias + exp_location = "https://%s/a" % name + r = env.get_meta(name, "/a", use_https=False) + assert r.response['status'] == 301 # FAIL: mod_alias generates Location header instead of mod_md + assert r.response['header']['location'] == exp_location + + def test_md_502_111(self, env): + # test case: vhost with parallel HTTP/HTTPS, check mod_alias redirects + # setup: prepare config + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add_md([name]) + conf.add(" LogLevel alias:debug") + conf.add_vhost(name, port=env.http_port) + conf.add_vhost(name) + conf.install() + assert env.apache_restart() == 0 + # drive it + r = env.a2md(["-v", "drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + assert env.apache_restart() == 0 + + # setup: place redirect rules + conf.add(' Redirect /a /name.txt') + conf.add(' Redirect seeother /b /name.txt') + conf.install() + assert env.apache_restart() == 0 + # check: redirects on HTTP + exp_location = "http://%s:%s/name.txt" % (name, env.http_port) + r = env.get_meta(name, "/a", use_https=False) + assert r.response['status'] == 302 + assert r.response['header']['location'] == exp_location + r = env.get_meta(name, "/b", use_https=False) + assert r.response['status'] == 303 + assert r.response['header']['location'] == exp_location + # check: redirects on HTTPS + exp_location = "https://%s:%s/name.txt" % (name, env.https_port) + r = env.get_meta(name, "/a", use_https=True) + assert r.response['status'] == 302 + assert r.response['header']['location'] == exp_location # FAIL: expected 'https://...' but found 'http://...' + r = env.get_meta(name, "/b", use_https=True) + assert r.response['status'] == 303 + assert r.response['header']['location'] == exp_location + + def test_md_502_120(self, env): + # test case: NP dereference reported by Daniel Caminada + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add_md([name]) + conf.add_vhost(name) + conf.install() + assert env.apache_restart() == 0 + env.run(["openssl", "s_client", + f"-connect", "localhost:{env.https_port}", + "-servername", "example.com", "-crlf" + ], intext="GET https:// HTTP/1.1\nHost: example.com\n\n") + assert env.apache_restart() == 0 + + # --------- critical state change -> drive again --------- + + def test_md_502_200(self, env): + # test case: add dns name on existing valid md + # setup: create md in store + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.apache_restart() == 0 + # setup: drive it + r = env.a2md(["drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + # setup: add second domain + assert env.a2md(["update", name, "domains", name, "test." + domain]).exit_code == 0 + # drive + r = env.a2md(["-vv", "drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + # check new cert + env.check_md_credentials([name, "test." + domain]) + new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + assert not old_cert.same_serial_as(new_cert.get_serial) + + @pytest.mark.parametrize("renew_window,test_data_list", [ + ("14d", [ + {"valid": {"notBefore": -5, "notAfter": 180}, "renew": False}, + {"valid": {"notBefore": -200, "notAfter": 15}, "renew": False}, + {"valid": {"notBefore": -200, "notAfter": 13}, "renew": True}, + ]), + ("30%", [ + {"valid": {"notBefore": -0, "notAfter": 180}, "renew": False}, + {"valid": {"notBefore": -120, "notAfter": 60}, "renew": False}, + {"valid": {"notBefore": -126, "notAfter": 53}, "renew": True}, + ]) + ]) + def test_md_502_201(self, env, renew_window, test_data_list): + # test case: trigger cert renew when entering renew window + # setup: prepare COMPLETE md + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add_renew_window(renew_window) + conf.add_md([name]) + conf.install() + assert env.apache_restart() == 0 + assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_INCOMPLETE + # setup: drive it + r = env.a2md(["drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + cert1 = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE + + # replace cert by self-signed one -> check md status + 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) + md = env.a2md(["list", name]).json['output'][0] + assert md["renew"] == tc["renew"], \ + "Expected renew == {} indicator in {}, test case {}".format(tc["renew"], md, tc) + + @pytest.mark.parametrize("key_type,key_params,exp_key_length", [ + ("RSA", [2048], 2048), + ("RSA", [3072], 3072), + ("RSA", [4096], 4096), + ("Default", [], 2048) + ]) + def test_md_502_202(self, env, key_type, key_params, exp_key_length): + # test case: specify RSA key length and verify resulting cert key + # setup: prepare md + domain = self.test_domain + name = "www." + domain + conf = MDConf(env, admin="admin@" + domain) + conf.add_drive_mode("manual") + conf.add_private_key(key_type, key_params) + conf.add_md([name]) + conf.install() + assert env.apache_restart() == 0 + assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_INCOMPLETE + # setup: drive it + r = env.a2md(["-vv", "drive", name]) + assert r.exit_code == 0, "drive for MDPrivateKeys {} {}: {}".format(key_type, key_params, r.stderr) + assert env.a2md(["list", name]).json['output'][0]['state'] == env.MD_S_COMPLETE + # check cert key length + cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + assert cert.get_key_length() == exp_key_length + + # test_502_203 removed, as ToS agreement is not really checked in ACMEv2 + + # --------- non-critical state change -> keep data --------- + + def test_md_502_300(self, env): + # test case: remove one domain name from existing valid md + # setup: create md in store + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name, "test." + domain, "xxx." + domain]) + assert env.apache_restart() == 0 + # setup: drive it + r = env.a2md(["drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + # setup: remove one domain + assert env.a2md(["update", name, "domains"] + [name, "test." + domain]).exit_code == 0 + # drive + assert env.a2md(["-vv", "drive", name]).exit_code == 0 + # compare cert serial + new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + assert old_cert.same_serial_as(new_cert) + + def test_md_502_301(self, env): + # test case: change contact info on existing valid md + # setup: create md in store + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.apache_restart() == 0 + # setup: drive it + r = env.a2md(["drive", name]) + assert r.exit_code == 0, "a2md drive failed: {0}".format(r.stderr) + old_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + # setup: add second domain + assert env.a2md(["update", name, "contacts", "test@" + domain]).exit_code == 0 + # drive + assert env.a2md(["drive", name]).exit_code == 0 + # compare cert serial + new_cert = MDCertUtil(env.store_domain_file(name, 'pubcert.pem')) + assert old_cert.same_serial_as(new_cert) + + # --------- network problems --------- + + def test_md_502_400(self, env): + # test case: server not reachable + domain = self.test_domain + name = "www." + domain + self._prepare_md(env, [name]) + assert env.a2md( + ["update", name, "ca", "http://localhost:4711/directory"] + ).exit_code == 0 + # drive + r = env.a2md(["drive", name]) + assert r.exit_code == 1 + assert r.json['status'] != 0 + assert r.json['description'] == 'Connection refused' + + # --------- _utils_ --------- + + def _prepare_md(self, env, domains): + assert env.a2md(["add"] + domains).exit_code == 0 + assert env.a2md( + ["update", domains[0], "contacts", "admin@" + domains[0]] + ).exit_code == 0 + assert env.a2md( + ["update", domains[0], "agreement", env.acme_tos] + ).exit_code == 0 + + def _write_res_file(self, doc_root, name, content): + if not os.path.exists(doc_root): + os.makedirs(doc_root) + open(os.path.join(doc_root, name), "w").write(content) + + RE_MSG_OPENSSL_BAD_DECRYPT = re.compile('.*\'bad decrypt\'.*') + + def _check_account_key(self, env, name): + # read encryption key + md_store = json.loads(open(env.path_store_json(), 'r').read()) + encrypt_key = base64.urlsafe_b64decode(str(md_store['key'])) + # check: key file is encrypted PEM + md = env.a2md(["list", name]).json['output'][0] + acc = md['ca']['account'] + MDCertUtil.validate_privkey(env.path_account_key(acc), lambda *args: encrypt_key) -- cgit v1.2.3