diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py index 47042308..ba468ca3 100644 --- a/lemur/plugins/lemur_acme/plugin.py +++ b/lemur/plugins/lemur_acme/plugin.py @@ -11,14 +11,16 @@ .. moduleauthor:: Mikhail Khodorovskiy .. moduleauthor:: Curtis Castrapel """ +import datetime import json +import time import OpenSSL.crypto import josepy as jose from acme import challenges, messages -from acme.client import Client +from acme.client import BackwardsCompatibleClientV2, ClientNetwork from acme.messages import Error as AcmeError -from acme.errors import PollError +from acme.errors import PollError, WildcardUnsupportedError from botocore.exceptions import ClientError from flask import current_app @@ -31,13 +33,13 @@ from lemur.plugins.bases import IssuerPlugin from lemur.plugins.lemur_acme import cloudflare, dyn, route53 -def find_dns_challenge(authz): - for combo in authz.body.resolved_combinations: - if ( - len(combo) == 1 and - isinstance(combo[0].chall, challenges.DNS01) - ): - yield combo[0] +def find_dns_challenge(authorizations): + dns_challenges = [] + for authz in authorizations: + for combo in authz.body.challenges: + if isinstance(combo.chall, challenges.DNS01): + dns_challenges.append(combo) + return dns_challenges class AuthorizationRecord(object): @@ -48,66 +50,65 @@ class AuthorizationRecord(object): self.change_id = change_id -def start_dns_challenge(acme_client, account_number, host, dns_provider): +def maybe_remove_wildcard(host): + return host.replace("*.", "") + + +def start_dns_challenge(acme_client, account_number, host, dns_provider, order): current_app.logger.debug("Starting DNS challenge for {0}".format(host)) - authz = acme_client.request_domain_challenges(host) - [dns_challenge] = find_dns_challenge(authz) + dns_challenges = find_dns_challenge(order.authorizations) + change_ids = [] - change_id = dns_provider.create_txt_record( - dns_challenge.validation_domain_name(host), - dns_challenge.validation(acme_client.key), - account_number - ) + for dns_challenge in find_dns_challenge(order.authorizations): + change_id = dns_provider.create_txt_record( + dns_challenge.validation_domain_name(maybe_remove_wildcard(host)), + dns_challenge.validation(acme_client.client.net.key), + account_number + ) + change_ids.append(change_id) return AuthorizationRecord( host, - authz, - dns_challenge, - change_id, + order.authorizations, + dns_challenges, + change_ids ) def complete_dns_challenge(acme_client, account_number, authz_record, dns_provider): - current_app.logger.debug("Finalizing DNS challenge for {0}".format(authz_record.host)) - dns_provider.wait_for_dns_change(authz_record.change_id, account_number=account_number) + current_app.logger.debug("Finalizing DNS challenge for {0}".format(authz_record.authz[0].body.identifier.value)) + for change_id in authz_record.change_id: + dns_provider.wait_for_dns_change(change_id, account_number=account_number) - response = authz_record.dns_challenge.response(acme_client.key) + for dns_challenge in authz_record.dns_challenge: - verified = response.simple_verify( - authz_record.dns_challenge.chall, - authz_record.host, - acme_client.key.public_key() - ) + response = dns_challenge.response(acme_client.client.net.key) - if not verified: - raise ValueError("Failed verification") + verified = response.simple_verify( + dns_challenge.chall, + authz_record.host, + acme_client.client.net.key.public_key() + ) - acme_client.answer_challenge(authz_record.dns_challenge, response) + if not verified: + raise ValueError("Failed verification") + + time.sleep(5) + acme_client.answer_challenge(dns_challenge, response) -def request_certificate(acme_client, authorizations, csr): - cert_response, _ = acme_client.poll_and_request_issuance( - jose.util.ComparableX509( - OpenSSL.crypto.load_certificate_request( - OpenSSL.crypto.FILETYPE_PEM, - csr - ) - ), - authzrs=[authz_record.authz for authz_record in authorizations], - mintime=60, - max_attempts=10, - ) +def request_certificate(acme_client, authorizations, csr, order): + for authorization in authorizations: + for authz in authorization.authz: + authorization_resource, _ = acme_client.poll(authz) - pem_certificate = OpenSSL.crypto.dump_certificate( - OpenSSL.crypto.FILETYPE_PEM, cert_response.body - ).decode('utf-8') - - full_chain = [] - for cert in acme_client.fetch_chain(cert_response): - chain = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) - full_chain.append(chain.decode("utf-8")) - pem_certificate_chain = "\n".join(full_chain) + deadline = datetime.datetime.now() + datetime.timedelta(seconds=90) + orderr = acme_client.finalize_order(order, deadline) + pem_certificate = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, + OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + orderr.fullchain_pem)).decode() + pem_certificate_chain = orderr.fullchain_pem[len(pem_certificate):].lstrip() current_app.logger.debug("{0} {1}".format(type(pem_certificate), type(pem_certificate_chain))) return pem_certificate, pem_certificate_chain @@ -127,15 +128,12 @@ def setup_acme_client(authority): key = jose.JWKRSA(key=generate_private_key('RSA2048')) current_app.logger.debug("Connecting with directory at {0}".format(directory_url)) - client = Client(directory_url, key) - - registration = client.register( - messages.NewRegistration.from_data(email=email) - ) + net = ClientNetwork(key, account=None) + client = BackwardsCompatibleClientV2(net, key, directory_url) + registration = client.new_account_and_tos(messages.NewRegistration.from_data(email=email)) current_app.logger.debug("Connected: {0}".format(registration.uri)) - client.agree_to_tos(registration) return client, registration @@ -156,26 +154,25 @@ def get_domains(options): return domains -def get_authorizations(acme_client, account_number, domains, dns_provider): +def get_authorizations(acme_client, order, order_info, dns_provider): authorizations = [] - for domain in domains: - authz_record = start_dns_challenge(acme_client, account_number, domain, dns_provider) + for domain in order.body.identifiers: + authz_record = start_dns_challenge(acme_client, order_info.account_number, domain.value, dns_provider, order) authorizations.append(authz_record) return authorizations def finalize_authorizations(acme_client, account_number, dns_provider, authorizations): - try: - for authz_record in authorizations: - complete_dns_challenge(acme_client, account_number, authz_record, dns_provider) - finally: - for authz_record in authorizations: - dns_challenge = authz_record.dns_challenge + for authz_record in authorizations: + complete_dns_challenge(acme_client, account_number, authz_record, dns_provider) + for authz_record in authorizations: + dns_challenges = authz_record.dns_challenge + for dns_challenge in dns_challenges: dns_provider.delete_txt_record( authz_record.change_id, account_number, - dns_challenge.validation_domain_name(authz_record.host), - dns_challenge.validation(acme_client.key) + dns_challenge.validation_domain_name(maybe_remove_wildcard(authz_record.host)), + dns_challenge.validation(acme_client.client.net.key) ) return authorizations @@ -265,15 +262,21 @@ class ACMEIssuerPlugin(IssuerPlugin): order_info = authorization_service.get(pending_cert.external_id) dns_provider = dns_provider_service.get(pending_cert.dns_provider_id) dns_provider_type = self.get_dns_provider(dns_provider.provider_type) + try: + order = acme_client.new_order(pending_cert.csr) + except WildcardUnsupportedError: + raise Exception("The currently selected ACME CA endpoint does" + " not support issuing wildcard certificates.") + + authorizations = get_authorizations(acme_client, order, order_info, dns_provider_type) - authorizations = get_authorizations( - acme_client, order_info.account_number, order_info.domains, dns_provider_type) pending.append({ "acme_client": acme_client, "account_number": order_info.account_number, "dns_provider_type": dns_provider_type, "authorizations": authorizations, "pending_cert": pending_cert, + "order": order, }) except (ClientError, ValueError, Exception): current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True) @@ -288,12 +291,13 @@ class ACMEIssuerPlugin(IssuerPlugin): entry["acme_client"], entry["account_number"], entry["dns_provider_type"], - entry["authorizations"] + entry["authorizations"], ) pem_certificate, pem_certificate_chain = request_certificate( entry["acme_client"], entry["authorizations"], - entry["pending_cert"].csr + entry["pending_cert"].csr, + entry["order"] ) cert = { diff --git a/lemur/plugins/lemur_acme/tests/test_acme.py b/lemur/plugins/lemur_acme/tests/test_acme.py index 80d6c860..cc9b994f 100644 --- a/lemur/plugins/lemur_acme/tests/test_acme.py +++ b/lemur/plugins/lemur_acme/tests/test_acme.py @@ -35,6 +35,7 @@ class TestAcme(unittest.TestCase): @patch('lemur.plugins.lemur_acme.plugin.find_dns_challenge') def test_start_dns_challenge(self, mock_find_dns_challenge, mock_len, mock_app, mock_acme): assert mock_len + mock_order = Mock() mock_app.logger.debug = Mock() mock_authz = Mock() mock_authz.body.resolved_combinations = [] @@ -51,7 +52,7 @@ class TestAcme(unittest.TestCase): iterable = mock_find_dns_challenge.return_value iterator = iter(values) iterable.__iter__.return_value = iterator - result = plugin.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider) + result = plugin.start_dns_challenge(mock_acme, "accountid", "host", mock_dns_provider, mock_order) self.assertEqual(type(result), plugin.AuthorizationRecord) @patch('acme.client.Client') @@ -63,7 +64,15 @@ class TestAcme(unittest.TestCase): mock_authz = Mock() mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True) - + mock_authz.authz = [] + mock_authz_record = Mock() + mock_authz_record.body.identifier.value = "test" + mock_authz.authz.append(mock_authz_record) + mock_authz.change_id = [] + mock_authz.change_id.append("123") + mock_authz.dns_challenge = [] + dns_challenge = Mock() + mock_authz.dns_challenge.append(dns_challenge) plugin.complete_dns_challenge(mock_acme, "accountid", mock_authz, mock_dns_provider) @patch('acme.client.Client') @@ -75,6 +84,15 @@ class TestAcme(unittest.TestCase): mock_authz = Mock() mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge.response.simple_verify = Mock(return_value=False) + mock_authz.authz = [] + mock_authz_record = Mock() + mock_authz_record.body.identifier.value = "test" + mock_authz.authz.append(mock_authz_record) + mock_authz.change_id = [] + mock_authz.change_id.append("123") + mock_authz.dns_challenge = [] + dns_challenge = Mock() + mock_authz.dns_challenge.append(dns_challenge) self.assertRaises( ValueError, plugin.complete_dns_challenge(mock_acme, "accountid", mock_authz, mock_dns_provider) @@ -96,8 +114,8 @@ class TestAcme(unittest.TestCase): mock_authz.append(mock_authz_record) mock_acme.fetch_chain = Mock(return_value="mock_chain") mock_crypto.dump_certificate = Mock(return_value=b'chain') - - plugin.request_certificate(mock_acme, [], "mock_csr") + mock_order = Mock() + plugin.request_certificate(mock_acme, [], "mock_csr", mock_order) def test_setup_acme_client_fail(self): mock_authority = Mock() @@ -105,7 +123,7 @@ class TestAcme(unittest.TestCase): with self.assertRaises(Exception): plugin.setup_acme_client(mock_authority) - @patch('lemur.plugins.lemur_acme.plugin.Client') + @patch('lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2') @patch('lemur.plugins.lemur_acme.plugin.current_app') def test_setup_acme_client_success(self, mock_current_app, mock_acme): mock_authority = Mock() @@ -146,7 +164,13 @@ class TestAcme(unittest.TestCase): @patch('lemur.plugins.lemur_acme.plugin.start_dns_challenge', return_value="test") def test_get_authorizations(self, mock_start_dns_challenge): - result = plugin.get_authorizations("acme_client", "account_number", ["domains"], "dns_provider") + mock_order = Mock() + mock_order.body.identifiers = [] + mock_domain = Mock() + mock_order.body.identifiers.append(mock_domain) + mock_order_info = Mock() + mock_order_info.account_number = 1 + result = plugin.get_authorizations("acme_client", mock_order, mock_order_info, "dns_provider") self.assertEqual(result, ["test"]) @patch('lemur.plugins.lemur_acme.plugin.complete_dns_challenge', return_value="test") diff --git a/requirements-dev.txt b/requirements-dev.txt index f8460523..113da73c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,7 +4,7 @@ # # pip-compile --no-index --output-file requirements-dev.txt requirements-dev.in # -aspy.yaml==1.1.0 # via pre-commit +aspy.yaml==1.1.1 # via pre-commit cached-property==1.4.2 # via pre-commit certifi==2018.4.16 # via requests cfgv==1.0.0 # via pre-commit @@ -12,7 +12,7 @@ chardet==3.0.4 # via requests flake8==3.5.0 identify==1.0.16 # via pre-commit idna==2.6 # via requests -invoke==0.23.0 +invoke==1.0.0 mccabe==0.6.1 # via flake8 nodeenv==1.3.0 pkginfo==1.4.2 # via twine diff --git a/requirements-docs.txt b/requirements-docs.txt index 95bd84ca..2f3954bf 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -15,8 +15,8 @@ asyncpool==1.0 babel==2.5.3 # via sphinx bcrypt==3.1.4 blinker==1.4 -boto3==1.7.14 -botocore==1.10.14 +boto3==1.7.19 +botocore==1.10.19 certifi==2018.4.16 cffi==1.11.5 click==6.7 @@ -49,7 +49,7 @@ lockfile==0.12.2 mako==1.0.7 markupsafe==1.0 marshmallow-sqlalchemy==0.13.2 -marshmallow==2.15.1 +marshmallow==2.15.2 mock==2.0.0 ndg-httpsclient==0.5.0 packaging==17.1 # via sphinx @@ -66,11 +66,11 @@ pynacl==1.2.1 pyopenssl==17.2.0 pyparsing==2.2.0 # via packaging pyrfc3339==1.0 -python-dateutil==2.7.2 +python-dateutil==2.7.3 python-editor==1.0.3 pytz==2018.4 pyyaml==3.12 -raven[flask]==6.7.0 +raven[flask]==6.8.0 requests[security]==2.11.1 retrying==1.3.3 s3transfer==0.1.13 diff --git a/requirements-tests.txt b/requirements-tests.txt index 9d64116f..70825609 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -7,9 +7,9 @@ asn1crypto==0.24.0 # via cryptography attrs==18.1.0 # via pytest aws-xray-sdk==0.95 # via moto -boto3==1.7.16 # via moto +boto3==1.7.21 # via moto boto==2.48.0 # via moto -botocore==1.10.16 # via boto3, moto, s3transfer +botocore==1.10.21 # via boto3, moto, s3transfer certifi==2018.4.16 # via requests cffi==1.11.5 # via cryptography chardet==3.0.4 # via requests @@ -21,7 +21,7 @@ docker-pycreds==0.2.3 # via docker docker==3.3.0 # via moto docutils==0.14 # via botocore factory-boy==2.11.1 -faker==0.8.13 +faker==0.8.15 flask==1.0.2 # via pytest-flask freezegun==0.3.10 idna==2.6 # via cryptography, requests @@ -35,7 +35,7 @@ mock==2.0.0 # via moto more-itertools==4.1.0 # via pytest moto==1.3.3 nose==1.3.7 -pbr==4.0.2 # via mock +pbr==4.0.3 # via mock pluggy==0.6.0 # via pytest py==1.5.3 # via pytest pyaml==17.12.1 # via moto @@ -47,7 +47,7 @@ pytest==3.5.1 python-dateutil==2.6.1 # via botocore, faker, freezegun, moto pytz==2018.4 # via moto pyyaml==3.12 # via pyaml -requests-mock==1.4.0 +requests-mock==1.5.0 requests==2.18.4 # via aws-xray-sdk, docker, moto, requests-mock, responses responses==0.9.0 # via moto s3transfer==0.1.13 # via boto3 diff --git a/requirements.txt b/requirements.txt index 9523c4f6..825b98bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,8 +13,8 @@ asn1crypto==0.24.0 # via cryptography asyncpool==1.0 bcrypt==3.1.4 # via flask-bcrypt, paramiko blinker==1.4 # via flask-mail, flask-principal, raven -boto3==1.7.16 -botocore==1.10.16 # via boto3, s3transfer +boto3==1.7.21 +botocore==1.10.21 # via boto3, s3transfer certifi==2018.4.16 cffi==1.11.5 # via bcrypt, cryptography, pynacl click==6.7 # via flask @@ -46,11 +46,11 @@ lockfile==0.12.2 mako==1.0.7 # via alembic markupsafe==1.0 # via jinja2, mako marshmallow-sqlalchemy==0.13.2 -marshmallow==2.15.1 +marshmallow==2.15.2 mock==2.0.0 # via acme ndg-httpsclient==0.5.0 paramiko==2.4.1 -pbr==4.0.2 # via mock +pbr==4.0.3 # via mock pem==17.1.0 psycopg2==2.7.4 pyasn1-modules==0.2.1 # via python-ldap @@ -60,12 +60,12 @@ pyjwt==1.6.1 pynacl==1.2.1 # via paramiko pyopenssl==17.2.0 pyrfc3339==1.0 # via acme -python-dateutil==2.7.2 # via alembic, arrow, botocore +python-dateutil==2.7.3 # via alembic, arrow, botocore python-editor==1.0.3 # via alembic python-ldap==3.0.0 pytz==2018.4 # via acme, flask-restful, pyrfc3339 pyyaml==3.12 # via cloudflare -raven[flask]==6.7.0 +raven[flask]==6.8.0 requests[security]==2.11.1 retrying==1.3.3 s3transfer==0.1.13 # via boto3