""" .. module: lemur.common.services.issuers.plugins.cloudca :platform: Unix :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ import ssl import base64 from json import dumps import arrow import requests from requests.adapters import HTTPAdapter from flask import current_app from lemur.exceptions import LemurException from lemur.common.services.issuers.issuer import Issuer from lemur.common.services.issuers.plugins import cloudca from lemur.authorities import service as authority_service API_ENDPOINT = '/v1/ca/netflix' class CloudCAException(LemurException): def __init__(self, message): self.message = message current_app.logger.error(self) def __str__(self): return repr("CloudCA request failed: {0}".format(self.message)) class CloudCAHostNameCheckingAdapter(HTTPAdapter): def cert_verify(self, conn, url, verify, cert): super(CloudCAHostNameCheckingAdapter, self).cert_verify(conn, url, verify, cert) conn.assert_hostname = False def remove_none(options): """ Simple function that traverse the options and removed any None items CloudCA really dislikes null values. :param options: :return: """ new_dict = {} for k, v in options.items(): if v: new_dict[k] = v # this is super hacky and gross, cloudca doesn't like null values if new_dict.get('extensions'): if len(new_dict['extensions']['subAltNames']['names']) == 0: del new_dict['extensions']['subAltNames'] return new_dict def get_default_issuance(options): """ Gets the default time range for certificates :param options: :return: """ if not options.get('validityStart') and not options.get('validityEnd'): start = arrow.utcnow() options['validityStart'] = start.floor('second').isoformat() options['validityEnd'] = start.replace(years=current_app.config.get('CLOUDCA_DEFAULT_VALIDITY')).ceil('second').isoformat() return options def convert_to_pem(der): """ Converts DER to PEM Lemur uses PEM internally :param der: :return: """ decoded = base64.b64decode(der) return ssl.DER_cert_to_PEM_cert(decoded) def convert_date_to_utc_time(date): """ Converts a python `datetime` object to the current date + current time in UTC. :param date: :return: """ d = arrow.get(date) return arrow.utcnow().replace(day=d.naive.day).replace(month=d.naive.month).replace(year=d.naive.year).replace(microsecond=0) def process_response(response): """ Helper function that processes responses from CloudCA. :param response: :return: :raise CloudCAException: """ if response.status_code == 200: res = response.json() if res['returnValue'] != 'success': current_app.logger.debug(res) if res.get('data'): raise CloudCAException(" ".join([res['returnMessage'], res['data']['dryRunResultMessage']])) else: raise CloudCAException(res['returnMessage']) else: raise CloudCAException("There was an error with your request: {0}".format(response.status_code)) return response.json() def get_auth_data(ca_name): """ Creates the authentication record needed to authenticate a user request to CloudCA. :param ca_name: :return: :raise CloudCAException: """ role = authority_service.get_authority_role(ca_name) if role: return { "authInfo": { "credType": "password", "credentials": { "username": role.username, "password": role.password # we only decrypt when we need to } } } raise CloudCAException("You do not have the required role to issue certificates from {0}".format(ca_name)) class CloudCA(Issuer): title = 'CloudCA' slug = 'cloudca' description = 'Enables the creation of certificates from the cloudca API.' version = cloudca.VERSION author = 'Kevin Glisson' author_url = 'https://github.com/netflix/lemur' def __init__(self, *args, **kwargs): self.session = requests.Session() self.session.mount('https://', CloudCAHostNameCheckingAdapter()) self.url = current_app.config.get('CLOUDCA_URL') if current_app.config.get('CLOUDCA_PEM_PATH') and current_app.config.get('CLOUDCA_BUNDLE'): self.session.cert = current_app.config.get('CLOUDCA_PEM_PATH') self.ca_bundle = current_app.config.get('CLOUDCA_BUNDLE') else: current_app.logger.warning("No CLOUDCA credentials found, lemur will be unable to request certificates from CLOUDCA") super(CloudCA, self).__init__(*args, **kwargs) def create_authority(self, options): """ Creates a new certificate authority :param options: :return: """ # this is weird and I don't like it endpoint = '{0}/createCA'.format(API_ENDPOINT) options['caDN']['email'] = options['ownerEmail'] if options['caType'] == 'subca': options = dict(options.items() + self.auth_data(options['caParent']).items()) options['validityStart'] = convert_date_to_utc_time(options['validityStart']).isoformat() options['validityEnd'] = convert_date_to_utc_time(options['validityEnd']).isoformat() response = self.session.post(self.url + endpoint, data=dumps(remove_none(options)), timeout=10, verify=self.ca_bundle) json = process_response(response) roles = [] for cred in json['data']['authInfo']: role = { 'username': cred['credentials']['username'], 'password': cred['credentials']['password'], 'name': "_".join([options['caName'], cred['credentials']['username']]) } roles.append(role) if options['caType'] == 'subca': cert = convert_to_pem(json['data']['certificate']) else: cert = convert_to_pem(json['data']['rootCertificate']) intermediates = [] for i in json['data']['intermediateCertificates']: intermediates.append(convert_to_pem(i)) return cert, "".join(intermediates), roles, def get_authorities(self): """ Retrieves authorities that were made outside of Lemur. :return: """ endpoint = '{0}/listCAs'.format(API_ENDPOINT) authorities = [] for ca in self.get(endpoint)['data']['caList']: try: authorities.append(ca['caName']) except AttributeError as e: current_app.logger.error("No authority has been defined for {}".format(ca['caName'])) return authorities def create_certificate(self, csr, options): """ Creates a new certificate from cloudca If no start and end date are specified the default issue range will be used. :param csr: :param options: """ endpoint = '{0}/enroll'.format(API_ENDPOINT) # lets default to two years if it's not specified # we do some last minute data massaging options = get_default_issuance(options) cloudca_options = { 'extensions': options['extensions'], 'validityStart': convert_date_to_utc_time(options['validityStart']).isoformat(), 'validityEnd': convert_date_to_utc_time(options['validityEnd']).isoformat(), 'creator': options['creator'], 'ownerEmail': options['owner'], 'caName': options['authority'].name, 'csr': csr, 'comment': options['description'] } response = self.post(endpoint, remove_none(cloudca_options)) # we return a concatenated list of intermediate because that is what aws # expects cert = convert_to_pem(response['data']['certificate']) intermediates = [convert_to_pem(response['data']['rootCertificate'])] for i in response['data']['intermediateCertificates']: intermediates.append(convert_to_pem(i)) return cert, "".join(intermediates), def get_csr_config(self, issuer_options): """ Get a valid CSR for use with CloudCA :param issuer_options: :return: """ return cloudca.constants.CSR_CONFIG.format(**issuer_options) def random(self, length=10): """ Uses CloudCA as a decent source of randomness. :param length: :return: """ endpoint = '/v1/random/{0}'.format(length) response = self.session.get(self.url + endpoint, verify=self.ca_bundle) return response def get_cert(self, ca_name=None, cert_handle=None): """ Returns a given cert from CloudCA. :param ca_name: :param cert_handle: :return: """ endpoint = '{0}/getCert'.format(API_ENDPOINT) response = self.session.post(self.url + endpoint, data=dumps({'caName': ca_name}), timeout=10, verify=self.ca_bundle) raw = process_response(response) certs = [] for c in raw['data']['certList']: cert = convert_to_pem(c['certValue']) intermediates = [] for i in c['intermediateCertificates']: intermediates.append(convert_to_pem(i)) certs.append({ 'public_certificate': cert, 'intermediate_cert': "\n".join(intermediates), 'owner': c['ownerEmail'] }) return certs def post(self, endpoint, data): """ HTTP POST to CloudCA :param endpoint: :param data: :return: """ if self.dry_run: endpoint += '?dry_run=1' data = dumps(dict(data.items() + get_auth_data(data['caName']).items())) # we set a low timeout, if cloudca is down it shouldn't bring down # lemur response = self.session.post(self.url + endpoint, data=data, timeout=10, verify=self.ca_bundle) return process_response(response) def get(self, endpoint): """ HTTP GET to CloudCA :param endpoint: :return: """ if self.dry_run: endpoint += '?dry_run=1' response = self.session.get(self.url + endpoint, timeout=10, verify=self.ca_bundle) return process_response(response) def init(): return CloudCA()