acme v2 support

This commit is contained in:
Curtis Castrapel 2018-05-16 07:46:37 -07:00
parent a9b9b27a0b
commit 680f4966a1
6 changed files with 125 additions and 97 deletions

View File

@ -11,14 +11,16 @@
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com>
"""
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 = {

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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