Adding ACME Support (#178)

This commit is contained in:
kevgliss 2016-06-27 15:57:53 -07:00 committed by GitHub
parent f846d78778
commit 5021e8ba91
9 changed files with 369 additions and 3 deletions

View File

@ -58,8 +58,8 @@ class InstanceManager(object):
results.append(cls()) results.append(cls())
else: else:
results.append(cls) results.append(cls)
except Exception: except Exception as e:
current_app.logger.exception('Unable to import %s', cls_path) current_app.logger.exception('Unable to import %s. Reason: %s', cls_path, e)
continue continue
self.cache = results self.cache = results

View File

@ -0,0 +1,5 @@
try:
VERSION = __import__('pkg_resources') \
.get_distribution(__name__).version
except Exception as e:
VERSION = 'unknown'

View File

@ -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 <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
"""
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]

View File

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

View File

@ -0,0 +1 @@
from lemur.tests.conftest import * # noqa

View File

@ -0,0 +1,5 @@
def test_get_certificates(app):
from lemur.plugins.base import plugins
p = plugins.get('acme-issuer')
p.create_certificate('', {})

View File

@ -4,11 +4,11 @@
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details. :license: Apache, see LICENSE for more details.
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
The plugin inserts certificates and the private key as Kubernetes secret that The plugin inserts certificates and the private key as Kubernetes secret that
can later be used to secure service endpoints running in Kubernetes pods can later be used to secure service endpoints running in Kubernetes pods
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
""" """
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend

View File

@ -72,3 +72,61 @@ VERISIGN_PEM_PATH = '~/'
VERISIGN_FIRST_NAME = 'Jim' VERISIGN_FIRST_NAME = 'Jim'
VERISIGN_LAST_NAME = 'Bob' VERISIGN_LAST_NAME = 'Bob'
VERSIGN_EMAIL = 'jim@example.com' 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'

View File

@ -168,6 +168,7 @@ setup(
], ],
'lemur.plugins': [ 'lemur.plugins': [
'verisign_issuer = lemur.plugins.lemur_verisign.plugin:VerisignIssuerPlugin', '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_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin',
'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin', 'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin',
'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin', 'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',