lemur/lemur/plugins/lemur_acme/plugin.py

385 lines
15 KiB
Python
Raw Normal View History

2016-06-28 00:57:53 +02:00
"""
.. module: lemur.plugins.lemur_acme.plugin
2016-06-28 00:57:53 +02:00
:platform: Unix
:synopsis: This module is responsible for communicating with an ACME CA.
2016-06-28 00:57:53 +02:00
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
Snippets from https://raw.githubusercontent.com/alex/letsencrypt-aws/master/letsencrypt-aws.py
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Curtis Castrapel <ccastrapel@netflix.com>
2016-06-28 00:57:53 +02:00
"""
import json
2018-03-21 20:45:26 +01:00
2018-05-07 18:58:24 +02:00
import OpenSSL.crypto
import josepy as jose
2018-05-05 00:00:43 +02:00
from acme import challenges, messages
2018-05-07 18:58:24 +02:00
from acme.client import Client
from acme.messages import Error as AcmeError
2018-05-05 00:00:43 +02:00
from acme.errors import PollError
from botocore.exceptions import ClientError
2018-05-07 18:58:24 +02:00
from flask import current_app
2016-06-28 00:57:53 +02:00
from lemur.authorizations import service as authorization_service
2018-05-07 18:58:24 +02:00
from lemur.common.utils import generate_private_key
from lemur.dns_providers import service as dns_provider_service
2018-05-08 20:03:17 +02:00
from lemur.exceptions import InvalidAuthority, InvalidConfiguration, UnknownProvider
2016-06-28 00:57:53 +02:00
from lemur.plugins import lemur_acme as acme
2018-05-07 18:58:24 +02:00
from lemur.plugins.bases import IssuerPlugin
2018-05-08 20:03:17 +02:00
from lemur.plugins.lemur_acme import cloudflare, dyn, route53
2016-06-28 00:57:53 +02:00
def find_dns_challenge(authz):
for combo in authz.body.resolved_combinations:
if (
len(combo) == 1 and
isinstance(combo[0].chall, challenges.DNS01)
2016-06-28 00:57:53 +02:00
):
yield combo[0]
class AuthorizationRecord(object):
def __init__(self, host, authz, dns_challenge, change_id):
self.host = host
self.authz = authz
self.dns_challenge = dns_challenge
self.change_id = change_id
def start_dns_challenge(acme_client, account_number, host, dns_provider):
current_app.logger.debug("Starting DNS challenge for {0}".format(host))
authz = acme_client.request_domain_challenges(host)
2016-06-28 00:57:53 +02:00
[dns_challenge] = find_dns_challenge(authz)
change_id = dns_provider.create_txt_record(
2016-06-28 00:57:53 +02:00
dns_challenge.validation_domain_name(host),
dns_challenge.validation(acme_client.key),
account_number
2016-06-28 00:57:53 +02:00
)
2016-12-19 03:21:22 +01:00
2016-06-28 00:57:53 +02:00
return AuthorizationRecord(
host,
authz,
dns_challenge,
change_id,
)
def complete_dns_challenge(acme_client, account_number, authz_record, dns_provider):
2018-05-05 00:00:43 +02:00
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)
2016-06-28 00:57:53 +02:00
response = authz_record.dns_challenge.response(acme_client.key)
verified = response.simple_verify(
authz_record.dns_challenge.chall,
authz_record.host,
acme_client.key.public_key()
)
2016-12-19 03:21:22 +01:00
2016-06-28 00:57:53 +02:00
if not verified:
raise ValueError("Failed verification")
acme_client.answer_challenge(authz_record.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
2016-06-28 00:57:53 +02:00
)
),
authzrs=[authz_record.authz for authz_record in authorizations],
2018-05-05 00:00:43 +02:00
mintime=60,
max_attempts=10,
2016-06-28 00:57:53 +02:00
)
2016-12-19 03:21:22 +01:00
2016-06-28 00:57:53 +02:00
pem_certificate = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert_response.body
).decode('utf-8')
2016-12-19 03:21:22 +01:00
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)
2016-12-19 03:21:22 +01:00
current_app.logger.debug("{0} {1}".format(type(pem_certificate), type(pem_certificate_chain)))
2016-06-28 00:57:53 +02:00
return pem_certificate, pem_certificate_chain
def setup_acme_client(authority):
if not authority.options:
2018-05-05 00:00:43 +02:00
raise InvalidAuthority("Invalid authority. Options not set")
options = {}
2018-04-27 20:18:41 +02:00
for option in json.loads(authority.options):
2018-05-05 00:00:43 +02:00
options[option["name"]] = option.get("value")
2018-04-27 20:18:41 +02:00
email = options.get('email', current_app.config.get('ACME_EMAIL'))
tel = options.get('telephone', current_app.config.get('ACME_TEL'))
directory_url = options.get('acme_url', current_app.config.get('ACME_DIRECTORY_URL'))
2016-06-28 00:57:53 +02:00
key = jose.JWKRSA(key=generate_private_key('RSA2048'))
2016-06-28 00:57:53 +02:00
current_app.logger.debug("Connecting with directory at {0}".format(directory_url))
client = Client(directory_url, key)
2016-06-28 00:57:53 +02:00
registration = client.register(
2016-06-28 00:57:53 +02:00
messages.NewRegistration.from_data(email=email)
)
2016-12-19 03:21:22 +01:00
current_app.logger.debug("Connected: {0}".format(registration.uri))
client.agree_to_tos(registration)
return client, registration
2016-06-28 00:57:53 +02:00
def get_domains(options):
"""
Fetches all domains currently requested
:param options:
:return:
"""
current_app.logger.debug("Fetching domains")
2016-06-28 00:57:53 +02:00
domains = [options['common_name']]
if options.get('extensions'):
for name in options['extensions']['sub_alt_names']['names']:
domains.append(name)
current_app.logger.debug("Got these domains: {0}".format(domains))
2016-06-28 00:57:53 +02:00
return domains
def get_authorizations(acme_client, account_number, domains, dns_provider):
2016-06-28 00:57:53 +02:00
authorizations = []
for domain in domains:
authz_record = start_dns_challenge(acme_client, account_number, domain, dns_provider)
authorizations.append(authz_record)
return authorizations
2016-06-28 00:57:53 +02:00
def finalize_authorizations(acme_client, account_number, dns_provider, authorizations):
try:
2016-06-28 00:57:53 +02:00
for authz_record in authorizations:
complete_dns_challenge(acme_client, account_number, authz_record, dns_provider)
2016-06-28 00:57:53 +02:00
finally:
for authz_record in authorizations:
dns_challenge = authz_record.dns_challenge
dns_provider.delete_txt_record(
2016-06-28 00:57:53 +02:00
authz_record.change_id,
account_number,
2016-06-28 00:57:53 +02:00
dns_challenge.validation_domain_name(authz_record.host),
dns_challenge.validation(acme_client.key)
2016-06-28 00:57:53 +02:00
)
return authorizations
class ACMEIssuerPlugin(IssuerPlugin):
title = 'Acme'
slug = 'acme-issuer'
description = 'Enables the creation of certificates via ACME CAs (including Let\'s Encrypt)'
version = acme.VERSION
author = 'Netflix'
2016-06-28 00:57:53 +02:00
author_url = 'https://github.com/netflix/lemur.git'
options = [
{
'name': 'acme_url',
'type': 'str',
'required': True,
'validation': '/^http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+$/',
'helpMessage': 'Must be a valid web url starting with http[s]://',
},
{
'name': 'telephone',
'type': 'str',
'default': '',
'helpMessage': 'Telephone to use'
},
{
'name': 'email',
'type': 'str',
'default': '',
'validation': '/^?([-a-zA-Z0-9.`?{}]+@\w+\.\w+)$/',
'helpMessage': 'Email to use'
},
{
'name': 'certificate',
'type': 'textarea',
'default': '',
'validation': '/^-----BEGIN CERTIFICATE-----/',
'helpMessage': 'Certificate to use'
},
]
2016-06-28 00:57:53 +02:00
def __init__(self, *args, **kwargs):
super(ACMEIssuerPlugin, self).__init__(*args, **kwargs)
2018-05-05 00:00:43 +02:00
def get_dns_provider(self, type):
provider_types = {
'cloudflare': cloudflare,
'dyn': dyn,
'route53': route53,
}
2018-05-08 20:03:17 +02:00
provider = provider_types.get(type)
if not provider:
raise UnknownProvider("No such DNS provider: {}".format(type))
return provider
2018-05-05 00:00:43 +02:00
def get_ordered_certificate(self, pending_cert):
acme_client, registration = setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
2018-05-05 00:00:43 +02:00
dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
try:
authorizations = get_authorizations(
acme_client, order_info.account_number, order_info.domains, dns_provider_type)
except ClientError:
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert.name), exc_info=True)
return False
2018-05-05 00:00:43 +02:00
authorizations = finalize_authorizations(
acme_client, order_info.account_number, dns_provider_type, authorizations)
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, pending_cert.csr)
cert = {
'body': "\n".join(str(pem_certificate).splitlines()),
'chain': "\n".join(str(pem_certificate_chain).splitlines()),
'external_id': str(pending_cert.external_id)
}
return cert
2018-04-30 19:48:48 +02:00
def get_ordered_certificates(self, pending_certs):
pending = []
2018-05-05 00:00:43 +02:00
certs = []
2018-04-30 19:48:48 +02:00
for pending_cert in pending_certs:
acme_client, registration = setup_acme_client(pending_cert.authority)
order_info = authorization_service.get(pending_cert.external_id)
dns_provider = dns_provider_service.get(pending_cert.dns_provider_id)
2018-05-05 00:00:43 +02:00
dns_provider_type = self.get_dns_provider(dns_provider.provider_type)
try:
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,
})
except (ClientError, ValueError, Exception):
2018-05-05 00:00:43 +02:00
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True)
certs.append({
"cert": False,
"pending_cert": pending_cert,
})
2018-04-30 19:48:48 +02:00
for entry in pending:
2018-05-05 00:00:43 +02:00
try:
entry["authorizations"] = finalize_authorizations(
entry["acme_client"],
entry["account_number"],
entry["dns_provider_type"],
entry["authorizations"]
)
2018-05-05 00:00:43 +02:00
pem_certificate, pem_certificate_chain = request_certificate(
entry["acme_client"],
entry["authorizations"],
entry["pending_cert"].csr
)
cert = {
'body': "\n".join(str(pem_certificate).splitlines()),
'chain': "\n".join(str(pem_certificate_chain).splitlines()),
'external_id': str(entry["pending_cert"].external_id)
}
certs.append({
"cert": cert,
"pending_cert": entry["pending_cert"],
})
except (PollError, AcmeError):
2018-05-05 00:00:43 +02:00
current_app.logger.error("Unable to resolve pending cert: {}".format(pending_cert), exc_info=True)
certs.append({
"cert": False,
"pending_cert": entry["pending_cert"],
})
2018-04-30 19:48:48 +02:00
return certs
2016-06-28 00:57:53 +02:00
def create_certificate(self, csr, issuer_options):
"""
Creates an ACME certificate.
2016-06-28 00:57:53 +02:00
:param csr:
:param issuer_options:
:return: :raise Exception:
"""
authority = issuer_options.get('authority')
create_immediately = issuer_options.get('create_immediately', False)
acme_client, registration = setup_acme_client(authority)
dns_provider_d = issuer_options.get('dns_provider')
if not dns_provider_d:
try:
dns_provider = dns_provider_service.get(issuer_options['dns_provider_id'])
except KeyError:
raise InvalidConfiguration("DNS Provider setting is required for ACME certificates.")
else:
dns_provider = dns_provider_service.get(dns_provider_d.get("id"))
credentials = json.loads(dns_provider.credentials)
current_app.logger.debug("Using DNS provider: {0}".format(dns_provider.provider_type))
dns_provider_type = __import__(dns_provider.provider_type, globals(), locals(), [], 1)
2018-04-27 20:18:41 +02:00
account_number = credentials.get("account_id")
if dns_provider.provider_type == 'route53' and not account_number:
2018-05-05 00:00:43 +02:00
error = "Route53 DNS Provider {} does not have an account number configured.".format(dns_provider.name)
current_app.logger.error(error)
2018-05-05 00:00:43 +02:00
raise InvalidConfiguration(error)
2016-06-28 00:57:53 +02:00
domains = get_domains(issuer_options)
if not create_immediately:
# Create pending authorizations that we'll need to do the creation
authz_domains = []
for d in domains:
if type(d) == str:
authz_domains.append(d)
else:
authz_domains.append(d.value)
dns_authorization = authorization_service.create(account_number, authz_domains, dns_provider.provider_type)
# Return id of the DNS Authorization
return None, None, dns_authorization.id
authorizations = get_authorizations(acme_client, account_number, domains, dns_provider_type)
finalize_authorizations(acme_client, account_number, dns_provider_type, authorizations)
2016-06-28 00:57:53 +02:00
pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr)
# TODO add external ID (if possible)
return pem_certificate, pem_certificate_chain, None
2016-06-28 00:57:53 +02:00
@staticmethod
def create_authority(options):
"""
Creates an authority, this authority is then used by Lemur to allow a user
to specify which Certificate Authority they want to sign their certificate.
:param options:
:return:
"""
role = {'username': '', 'password': '', 'name': 'acme'}
2018-05-05 00:00:43 +02:00
plugin_options = options.get('plugin', {}).get('plugin_options')
if not plugin_options:
error = "Invalid options for lemur_acme plugin: {}".format(options)
current_app.logger.error(error)
raise InvalidConfiguration(error)
# Define static acme_root based off configuration variable by default. However, if user has passed a
# certificate, use this certificate as the root.
acme_root = current_app.config.get('ACME_ROOT')
for option in plugin_options:
if option.get('name') == 'certificate':
acme_root = option.get('value')
return acme_root, "", [role]