From 5021e8ba915f19e37a3c3df346a23a782d7a9e0f Mon Sep 17 00:00:00 2001 From: kevgliss Date: Mon, 27 Jun 2016 15:57:53 -0700 Subject: [PATCH] Adding ACME Support (#178) --- lemur/common/managers.py | 4 +- lemur/plugins/lemur_acme/__init__.py | 5 + lemur/plugins/lemur_acme/plugin.py | 210 ++++++++++++++++++++ lemur/plugins/lemur_acme/route53.py | 86 ++++++++ lemur/plugins/lemur_acme/tests/conftest.py | 1 + lemur/plugins/lemur_acme/tests/test_acme.py | 5 + lemur/plugins/lemur_kubernetes/plugin.py | 2 +- lemur/tests/conf.py | 58 ++++++ setup.py | 1 + 9 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 lemur/plugins/lemur_acme/__init__.py create mode 100644 lemur/plugins/lemur_acme/plugin.py create mode 100644 lemur/plugins/lemur_acme/route53.py create mode 100644 lemur/plugins/lemur_acme/tests/conftest.py create mode 100644 lemur/plugins/lemur_acme/tests/test_acme.py diff --git a/lemur/common/managers.py b/lemur/common/managers.py index 79f4cb1d..9bcdf1c9 100644 --- a/lemur/common/managers.py +++ b/lemur/common/managers.py @@ -58,8 +58,8 @@ class InstanceManager(object): results.append(cls()) else: results.append(cls) - except Exception: - current_app.logger.exception('Unable to import %s', cls_path) + except Exception as e: + current_app.logger.exception('Unable to import %s. Reason: %s', cls_path, e) continue self.cache = results diff --git a/lemur/plugins/lemur_acme/__init__.py b/lemur/plugins/lemur_acme/__init__.py new file mode 100644 index 00000000..8ce5a7f3 --- /dev/null +++ b/lemur/plugins/lemur_acme/__init__.py @@ -0,0 +1,5 @@ +try: + VERSION = __import__('pkg_resources') \ + .get_distribution(__name__).version +except Exception as e: + VERSION = 'unknown' diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py new file mode 100644 index 00000000..88ae171c --- /dev/null +++ b/lemur/plugins/lemur_acme/plugin.py @@ -0,0 +1,210 @@ +""" +.. module: lemur.plugins.lemur_acme.acme + :platform: Unix + :synopsis: This module is responsible for communicating with a ACME CA. + :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 +.. moduleauthor:: Mikhail Khodorovskiy +""" +from flask import current_app + +from acme.client import Client +from acme import jose +from acme import messages + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +import OpenSSL.crypto + +from lemur.plugins.bases import IssuerPlugin +from lemur.plugins import lemur_acme as acme + +from .route53 import delete_txt_record, create_txt_record, wait_for_change + + +def find_dns_challenge(authz): + for combo in authz.body.resolved_combinations: + if ( + len(combo) == 1 and + isinstance(combo[0].chall, acme.challenges.DNS01) + ): + 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, host): + authz = acme_client.request_domain_challenges( + host, acme_client.directory.new_authz + ) + + [dns_challenge] = find_dns_challenge(authz) + + change_id = create_txt_record( + dns_challenge.validation_domain_name(host), + dns_challenge.validation(acme_client.key), + + ) + return AuthorizationRecord( + host, + authz, + dns_challenge, + change_id, + ) + + +def complete_dns_challenge(acme_client, authz_record): + wait_for_change(authz_record.change_id) + + 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() + ) + 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_ASN1, + csr.public_bytes(serialization.Encoding.DER), + ) + ), + authzrs=[authz_record.authz for authz_record in authorizations], + ) + pem_certificate = OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, cert_response.body + ) + pem_certificate_chain = "\n".join( + OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) + for cert in acme_client.fetch_chain(cert_response) + ) + return pem_certificate, pem_certificate_chain + + +def generate_rsa_private_key(): + return rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + + +def setup_acme_client(): + key = current_app.config.get('ACME_PRIVATE_KEY').strip() + acme_email = current_app.config.get('ACME_EMAIL') + acme_tel = current_app.config.get('ACME_TEL') + acme_directory_url = current_app.config('ACME_DIRECTORY_URL'), + contact = ('mailto:{}'.format(acme_email), 'tel:{}'.format(acme_tel)) + + key = serialization.load_pem_private_key( + key, password=None, backend=default_backend() + ) + return acme_client_for_private_key(acme_directory_url, key) + + +def acme_client_for_private_key(acme_directory_url, private_key): + return Client( + acme_directory_url, key=acme.jose.JWKRSA(key=private_key) + ) + + +def register(email): + private_key = generate_rsa_private_key() + acme_client = acme_client_for_private_key(current_app.config('ACME_DIRECTORY_URL'), private_key) + + registration = acme_client.register( + messages.NewRegistration.from_data(email=email) + ) + acme_client.agree_to_tos(registration) + return private_key + + +def get_domains(options): + """ + Fetches all domains currently requested + :param options: + :return: + """ + domains = [options['common_name']] + for name in options['extensions']['sub_alt_name']['names']: + domains.append(name) + return domains + + +def get_authorizations(acme_client, domains): + authorizations = [] + try: + for domain in domains: + authz_record = start_dns_challenge(acme_client, domain) + authorizations.append(authz_record) + + for authz_record in authorizations: + complete_dns_challenge(acme_client, authz_record) + finally: + for authz_record in authorizations: + dns_challenge = authz_record.dns_challenge + delete_txt_record( + authz_record.change_id, + dns_challenge.validation_domain_name(authz_record.host), + dns_challenge.validation(acme_client.key), + ) + + 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 = 'Kevin Glisson' + author_url = 'https://github.com/netflix/lemur.git' + + def __init__(self, *args, **kwargs): + super(ACMEIssuerPlugin, self).__init__(*args, **kwargs) + + def create_certificate(self, csr, issuer_options): + """ + Creates a ACME certificate. + + :param csr: + :param issuer_options: + :return: :raise Exception: + """ + current_app.logger.debug("Requesting a new acme certificate: {0}".format(issuer_options)) + acme_client = setup_acme_client() + domains = get_domains(issuer_options) + authorizations = get_authorizations(acme_client, domains) + pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr) + return pem_certificate, pem_certificate_chain + + @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'} + return current_app.config.get('ACME_ROOT'), "", [role] diff --git a/lemur/plugins/lemur_acme/route53.py b/lemur/plugins/lemur_acme/route53.py new file mode 100644 index 00000000..748e1955 --- /dev/null +++ b/lemur/plugins/lemur_acme/route53.py @@ -0,0 +1,86 @@ +import time +from lemur.plugins.lemur_aws.sts import sts_client + + +@sts_client('route53') +def wait_for_r53_change(change_id, client=None): + _, change_id = change_id + + while True: + response = client.get_change(Id=change_id) + if response["ChangeInfo"]["Status"] == "INSYNC": + return + time.sleep(5) + + +@sts_client('route53') +def find_zone_id(domain, client=None): + paginator = client.get_paginator("list_hosted_zones") + zones = [] + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if domain.endswith(zone["Name"]) or (domain + ".").endswith(zone["Name"]): + if not zone["Config"]["PrivateZone"]: + zones.append((zone["Name"], zone["Id"])) + + if not zones: + raise ValueError( + "Unable to find a Route53 hosted zone for {}".format(domain) + ) + + +@sts_client('route53') +def change_txt_record(action, zone_id, domain, value, client=None): + response = client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": action, + "ResourceRecordSet": { + "Name": domain, + "Type": "TXT", + "TTL": 300, + "ResourceRecords": [ + # For some reason TXT records need to be + # manually quoted. + {"Value": '"{}"'.format(value)} + ], + } + } + ] + } + ) + return response["ChangeInfo"]["Id"] + + +def create_txt_record(host, value): + zone_id = find_zone_id(host) + change_id = change_txt_record( + "CREATE", + zone_id, + host, + value, + ) + return zone_id, change_id + + +def delete_txt_record(change_id, host, value): + zone_id, _ = change_id + change_txt_record( + "DELETE", + zone_id, + host, + value + ) + + +@sts_client('route53') +def wait_for_change(change_id, client=None): + _, change_id = change_id + + while True: + response = client.get_change(Id=change_id) + if response["ChangeInfo"]["Status"] == "INSYNC": + return + time.sleep(5) diff --git a/lemur/plugins/lemur_acme/tests/conftest.py b/lemur/plugins/lemur_acme/tests/conftest.py new file mode 100644 index 00000000..0e1cd89f --- /dev/null +++ b/lemur/plugins/lemur_acme/tests/conftest.py @@ -0,0 +1 @@ +from lemur.tests.conftest import * # noqa diff --git a/lemur/plugins/lemur_acme/tests/test_acme.py b/lemur/plugins/lemur_acme/tests/test_acme.py new file mode 100644 index 00000000..74a01ede --- /dev/null +++ b/lemur/plugins/lemur_acme/tests/test_acme.py @@ -0,0 +1,5 @@ + +def test_get_certificates(app): + from lemur.plugins.base import plugins + p = plugins.get('acme-issuer') + p.create_certificate('', {}) diff --git a/lemur/plugins/lemur_kubernetes/plugin.py b/lemur/plugins/lemur_kubernetes/plugin.py index 85412a8c..fc427878 100644 --- a/lemur/plugins/lemur_kubernetes/plugin.py +++ b/lemur/plugins/lemur_kubernetes/plugin.py @@ -4,11 +4,11 @@ :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more :license: Apache, see LICENSE for more details. -.. moduleauthor:: Mikhail Khodorovskiy The plugin inserts certificates and the private key as Kubernetes secret that can later be used to secure service endpoints running in Kubernetes pods +.. moduleauthor:: Mikhail Khodorovskiy """ from cryptography import x509 from cryptography.hazmat.backends import default_backend diff --git a/lemur/tests/conf.py b/lemur/tests/conf.py index 6e51a31a..c146ca02 100644 --- a/lemur/tests/conf.py +++ b/lemur/tests/conf.py @@ -72,3 +72,61 @@ VERISIGN_PEM_PATH = '~/' VERISIGN_FIRST_NAME = 'Jim' VERISIGN_LAST_NAME = 'Bob' VERSIGN_EMAIL = 'jim@example.com' + + +ACME_PRIVATE_KEY = ''' +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +''' +ACME_URL = 'https://acme-v01.api.letsencrypt.org' +ACME_EMAIL = 'jim@example.com' +ACME_TEL = '4088675309' diff --git a/setup.py b/setup.py index 8f5c10ae..539673e1 100644 --- a/setup.py +++ b/setup.py @@ -168,6 +168,7 @@ setup( ], 'lemur.plugins': [ 'verisign_issuer = lemur.plugins.lemur_verisign.plugin:VerisignIssuerPlugin', + 'acme_issuer = lemur.plugins.lemur_acme.plugin:ACMEIssuerPlugin', 'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin', 'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin', 'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',