diff --git a/docs/administration.rst b/docs/administration.rst index fe6a5581..a3225fc2 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -172,15 +172,16 @@ Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create c PUBLIC_CA_MAX_VALIDITY_DAYS = 365 -.. data:: DEFAULT_MAX_VALIDITY_DAYS +.. data:: DEFAULT_VALIDITY_DAYS :noindex: - Use this config to override the default limit of 1095 days (3 years) of validity. Any CA which is not listed in - PUBLIC_CA_AUTHORITY_NAMES will be using this validity to display date range on UI. Below example overrides the - default validity of 1095 days and sets it to 365 days. + Use this config to override the default validity of 365 days for certificates offered through Lemur UI. Any CA which + is not listed in PUBLIC_CA_AUTHORITY_NAMES will be using this value as default validity to be displayed on UI. Please + note that this config is used for cert issuance only through Lemur UI. Below example overrides the default validity + of 365 days and sets it to 1095 days (3 years). :: - DEFAULT_MAX_VALIDITY_DAYS = 365 + DEFAULT_VALIDITY_DAYS = 1095 .. data:: DEBUG_DUMP diff --git a/lemur/authorities/schemas.py b/lemur/authorities/schemas.py index 7f9f57d4..ef6263a8 100644 --- a/lemur/authorities/schemas.py +++ b/lemur/authorities/schemas.py @@ -112,6 +112,7 @@ class RootAuthorityCertificateOutputSchema(LemurOutputSchema): not_after = fields.DateTime() not_before = fields.DateTime() max_issuance_days = fields.Integer() + default_validity_days = fields.Integer() owner = fields.Email() status = fields.Boolean() user = fields.Nested(UserNestedOutputSchema) @@ -137,7 +138,7 @@ class AuthorityNestedOutputSchema(LemurOutputSchema): owner = fields.Email() plugin = fields.Nested(PluginOutputSchema) active = fields.Boolean() - authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema, only=["max_issuance_days"]) + authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema, only=["max_issuance_days", "default_validity_days"]) authority_update_schema = AuthorityUpdateSchema() diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index 5f6c4ba9..675cecb4 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -9,9 +9,10 @@ from datetime import timedelta import arrow from cryptography import x509 -from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric import rsa, ec from flask import current_app from idna.core import InvalidCodepoint +from lemur.common.utils import get_key_type_from_ec_curve from sqlalchemy import ( event, Integer, @@ -302,6 +303,8 @@ class Certificate(db.Model): return "RSA{key_size}".format( key_size=self.parsed_cert.public_key().key_size ) + elif isinstance(self.parsed_cert.public_key(), ec.EllipticCurvePublicKey): + return get_key_type_from_ec_curve(self.parsed_cert.public_key().curve.name) @property def validity_remaining(self): @@ -317,7 +320,13 @@ class Certificate(db.Model): if self.name.lower() in [ca.lower() for ca in public_CA]: return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397) - return current_app.config.get("DEFAULT_MAX_VALIDITY_DAYS", 1095) # 3 years default + @property + def default_validity_days(self): + public_CA = current_app.config.get("PUBLIC_CA_AUTHORITY_NAMES", []) + if self.name.lower() in [ca.lower() for ca in public_CA]: + return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397) + + return current_app.config.get("DEFAULT_VALIDITY_DAYS", 365) # 1 year default @property def subject(self): diff --git a/lemur/certificates/schemas.py b/lemur/certificates/schemas.py index 42e444bc..56c91196 100644 --- a/lemur/certificates/schemas.py +++ b/lemur/certificates/schemas.py @@ -148,6 +148,13 @@ class CertificateInputSchema(CertificateCreationSchema): data["extensions"]["subAltNames"]["names"] = [] data["extensions"]["subAltNames"]["names"] = csr_sans + + common_name = cert_utils.get_cn_from_csr(data["csr"]) + if common_name: + data["common_name"] = common_name + key_type = cert_utils.get_key_type_from_csr(data["csr"]) + if key_type: + data["key_type"] = key_type return missing.convert_validity_years(data) diff --git a/lemur/certificates/utils.py b/lemur/certificates/utils.py index 4e6cc4f1..e642e058 100644 --- a/lemur/certificates/utils.py +++ b/lemur/certificates/utils.py @@ -12,6 +12,8 @@ Utils to parse certificate data. from cryptography import x509 from cryptography.hazmat.backends import default_backend from marshmallow.exceptions import ValidationError +from cryptography.hazmat.primitives.asymmetric import rsa, ec +from lemur.common.utils import get_key_type_from_ec_curve def get_sans_from_csr(data): @@ -39,3 +41,45 @@ def get_sans_from_csr(data): pass return sub_alt_names + + +def get_cn_from_csr(data): + """ + Fetches common name (CN) from CSR. + Works with any kind of SubjectAlternativeName + :param data: PEM-encoded string with CSR + :return: the common name + """ + try: + request = x509.load_pem_x509_csr(data.encode("utf-8"), default_backend()) + except Exception: + raise ValidationError("CSR presented is not valid.") + + common_name = request.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) + return common_name[0].value + + +def get_key_type_from_csr(data): + """ + Fetches key_type from CSR. + Works with any kind of SubjectAlternativeName + :param data: PEM-encoded string with CSR + :return: key_type + """ + try: + request = x509.load_pem_x509_csr(data.encode("utf-8"), default_backend()) + except Exception: + raise ValidationError("CSR presented is not valid.") + + try: + if isinstance(request.public_key(), rsa.RSAPublicKey): + return "RSA{key_size}".format( + key_size=request.public_key().key_size + ) + elif isinstance(request.public_key(), ec.EllipticCurvePublicKey): + return get_key_type_from_ec_curve(request.public_key().curve.name) + else: + raise Exception("Unsupported key type") + + except NotImplemented: + raise NotImplemented() diff --git a/lemur/common/utils.py b/lemur/common/utils.py index c33722b2..01cc64ae 100644 --- a/lemur/common/utils.py +++ b/lemur/common/utils.py @@ -114,6 +114,39 @@ def get_authority_key(body): return authority_key.hex() +def get_key_type_from_ec_curve(curve_name): + """ + Give an EC curve name, return the matching key_type. + + :param: curve_name + :return: key_type + """ + + _CURVE_TYPES = { + ec.SECP192R1().name: "ECCPRIME192V1", + ec.SECP256R1().name: "ECCPRIME256V1", + ec.SECP224R1().name: "ECCSECP224R1", + ec.SECP384R1().name: "ECCSECP384R1", + ec.SECP521R1().name: "ECCSECP521R1", + ec.SECP256K1().name: "ECCSECP256K1", + ec.SECT163K1().name: "ECCSECT163K1", + ec.SECT233K1().name: "ECCSECT233K1", + ec.SECT283K1().name: "ECCSECT283K1", + ec.SECT409K1().name: "ECCSECT409K1", + ec.SECT571K1().name: "ECCSECT571K1", + ec.SECT163R2().name: "ECCSECT163R2", + ec.SECT233R1().name: "ECCSECT233R1", + ec.SECT283R1().name: "ECCSECT283R1", + ec.SECT409R1().name: "ECCSECT409R1", + ec.SECT571R1().name: "ECCSECT571R2", + } + + if curve_name in _CURVE_TYPES.keys(): + return _CURVE_TYPES[curve_name] + else: + return None + + def generate_private_key(key_type): """ Generates a new private key based on key_type. @@ -128,11 +161,11 @@ def generate_private_key(key_type): """ _CURVE_TYPES = { - "ECCPRIME192V1": ec.SECP192R1(), - "ECCPRIME256V1": ec.SECP256R1(), - "ECCSECP192R1": ec.SECP192R1(), + "ECCPRIME192V1": ec.SECP192R1(), # duplicate + "ECCPRIME256V1": ec.SECP256R1(), # duplicate + "ECCSECP192R1": ec.SECP192R1(), # duplicate "ECCSECP224R1": ec.SECP224R1(), - "ECCSECP256R1": ec.SECP256R1(), + "ECCSECP256R1": ec.SECP256R1(), # duplicate "ECCSECP384R1": ec.SECP384R1(), "ECCSECP521R1": ec.SECP521R1(), "ECCSECP256K1": ec.SECP256K1(), diff --git a/lemur/plugins/lemur_digicert/plugin.py b/lemur/plugins/lemur_digicert/plugin.py index fd8c4e2d..3948acbb 100644 --- a/lemur/plugins/lemur_digicert/plugin.py +++ b/lemur/plugins/lemur_digicert/plugin.py @@ -18,8 +18,9 @@ import json import arrow import pem import requests +import sys from cryptography import x509 -from flask import current_app +from flask import current_app, g from lemur.common.utils import validate_conf from lemur.extensions import metrics from lemur.plugins import lemur_digicert as digicert @@ -129,6 +130,9 @@ def map_fields(options, csr): data["validity_years"] = determine_validity_years(options.get("validity_years")) elif options.get("validity_end"): data["custom_expiration_date"] = determine_end_date(options.get("validity_end")).format("YYYY-MM-DD") + # check if validity got truncated. If resultant validity is not equal to requested validity, it just got truncated + if data["custom_expiration_date"] != options.get("validity_end").format("YYYY-MM-DD"): + log_validity_truncation(options, f"{__name__}.{sys._getframe().f_code.co_name}") else: data["validity_years"] = determine_validity_years(0) @@ -154,6 +158,9 @@ def map_cis_fields(options, csr): validity_end = determine_end_date(arrow.utcnow().shift(years=options["validity_years"])) elif options.get("validity_end"): validity_end = determine_end_date(options.get("validity_end")) + # check if validity got truncated. If resultant validity is not equal to requested validity, it just got truncated + if validity_end != options.get("validity_end"): + log_validity_truncation(options, f"{__name__}.{sys._getframe().f_code.co_name}") else: validity_end = determine_end_date(False) @@ -179,6 +186,18 @@ def map_cis_fields(options, csr): return data +def log_validity_truncation(options, function): + log_data = { + "cn": options["common_name"], + "creator": g.user.username + } + metrics.send("digicert_validity_truncated", "counter", 1, metric_tags=log_data) + + log_data["function"] = function + log_data["message"] = "Digicert Plugin truncated the validity of certificate" + current_app.logger.info(log_data) + + def handle_response(response): """ Handle the DigiCert API response and any errors it might have experienced. diff --git a/lemur/plugins/lemur_entrust/__init__.py b/lemur/plugins/lemur_entrust/__init__.py new file mode 100644 index 00000000..b902ed7a --- /dev/null +++ b/lemur/plugins/lemur_entrust/__init__.py @@ -0,0 +1,5 @@ +"""Set the version information.""" +try: + VERSION = __import__("pkg_resources").get_distribution(__name__).version +except Exception as e: + VERSION = "unknown" diff --git a/lemur/plugins/lemur_entrust/plugin.py b/lemur/plugins/lemur_entrust/plugin.py new file mode 100644 index 00000000..315da8bd --- /dev/null +++ b/lemur/plugins/lemur_entrust/plugin.py @@ -0,0 +1,228 @@ +from lemur.plugins.bases import IssuerPlugin, SourcePlugin +import arrow +import requests +import json +from lemur.plugins import lemur_entrust as ENTRUST +from flask import current_app +from lemur.extensions import metrics +from lemur.common.utils import validate_conf + + +def log_status_code(r, *args, **kwargs): + """ + Is a request hook that logs all status codes to the ENTRUST api. + + :param r: + :param args: + :param kwargs: + :return: + """ + metrics.send("ENTRUST_status_code_{}".format(r.status_code), "counter", 1) + + +def determine_end_date(end_date): + """ + Determine appropriate end date + :param end_date: + :return: validity_end + """ + # ENTRUST only allows 13 months of max certificate duration + max_validity_end = arrow.utcnow().shift(years=1, months=+1).format('YYYY-MM-DD') + + if not end_date: + end_date = max_validity_end + + if end_date > max_validity_end: + end_date = max_validity_end + return end_date + + +def process_options(options): + """ + Processes and maps the incoming issuer options to fields/options that + Entrust understands + + :param options: + :return: dict of valid entrust options + """ + # if there is a config variable ENTRUST_PRODUCT_ + # take the value as Cert product-type + # else default to "STANDARD_SSL" + authority = options.get("authority").name.upper() + product_type = current_app.config.get("ENTRUST_PRODUCT_{0}".format(authority), "STANDARD_SSL") + + if options.get("validity_end"): + validity_end = determine_end_date(options.get("validity_end")) + else: + validity_end = determine_end_date(False) + + tracking_data = { + "requesterName": current_app.config.get("ENTRUST_NAME"), + "requesterEmail": current_app.config.get("ENTRUST_EMAIL"), + "requesterPhone": current_app.config.get("ENTRUST_PHONE") + } + + data = { + "signingAlg": "SHA-2", + "eku": "SERVER_AND_CLIENT_AUTH", + "certType": product_type, + "certExpiryDate": validity_end, + "tracking": tracking_data + } + return data + + +def handle_response(my_response): + """ + Helper function for parsing responses from the Entrust API. + :param content: + :return: :raise Exception: + """ + msg = { + 200: "The request had the validateOnly flag set to true and validation was successful.", + 201: "Certificate created", + 202: "Request accepted and queued for approval", + 400: "Invalid request parameters", + 404: "Unknown jobId", + 429: "Too many requests" + } + try: + d = json.loads(my_response.content) + except Exception as e: + # catch an empty jason object here + d = {'errors': 'No detailled message'} + s = my_response.status_code + if s > 399: + raise Exception("ENTRUST error: {0}\n{1}".format(msg.get(s, s), d['errors'])) + current_app.logger.info("Response: {0}, {1} ".format(s, d)) + return d + + +class EntrustIssuerPlugin(IssuerPlugin): + title = "ENTRUST" + slug = "entrust-issuer" + description = "Enables the creation of certificates by ENTRUST" + version = ENTRUST.VERSION + + author = "sirferl" + author_url = "https://github.com/sirferl/lemur" + + def __init__(self, *args, **kwargs): + """Initialize the issuer with the appropriate details.""" + required_vars = [ + "ENTRUST_API_CERT", + "ENTRUST_API_KEY", + "ENTRUST_API_USER", + "ENTRUST_API_PASS", + "ENTRUST_URL", + "ENTRUST_ROOT", + "ENTRUST_NAME", + "ENTRUST_EMAIL", + "ENTRUST_PHONE", + "ENTRUST_ISSUING", + ] + validate_conf(current_app, required_vars) + + self.session = requests.Session() + cert_file = current_app.config.get("ENTRUST_API_CERT") + key_file = current_app.config.get("ENTRUST_API_KEY") + user = current_app.config.get("ENTRUST_API_USER") + password = current_app.config.get("ENTRUST_API_PASS") + self.session.cert = (cert_file, key_file) + self.session.auth = (user, password) + self.session.hooks = dict(response=log_status_code) + # self.session.config['keep_alive'] = False + super(EntrustIssuerPlugin, self).__init__(*args, **kwargs) + + def create_certificate(self, csr, issuer_options): + """ + Creates an Entrust certificate. + + :param csr: + :param issuer_options: + :return: :raise Exception: + """ + current_app.logger.info( + "Requesting options: {0}".format(issuer_options) + ) + + url = current_app.config.get("ENTRUST_URL") + "/certificates" + + data = process_options(issuer_options) + data["csr"] = csr + + try: + response = self.session.post(url, json=data, timeout=(15, 40)) + except requests.exceptions.Timeout: + raise Exception("Timeout for POST") + except requests.exceptions.RequestException as e: + raise Exception("Error for POST {0}".format(e)) + + response_dict = handle_response(response) + external_id = response_dict['trackingId'] + cert = response_dict['endEntityCert'] + chain = response_dict['chainCerts'][1] + current_app.logger.info( + "Received Chain: {0}".format(chain) + ) + + return cert, chain, external_id + + def revoke_certificate(self, certificate, comments): + """Revoke a Digicert certificate.""" + base_url = current_app.config.get("ENTRUST_URL") + + # make certificate revoke request + revoke_url = "{0}/certificates/{1}/revocations".format( + base_url, certificate.external_id + ) + metrics.send("entrust_revoke_certificate", "counter", 1) + if comments == '' or not comments: + comments = "revoked via API" + data = { + "crlReason": "superseded", + "revocationComment": comments + } + response = self.session.post(revoke_url, json=data) + + data = handle_response(response) + + @staticmethod + def create_authority(options): + """Create an authority. + 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: + """ + entrust_root = current_app.config.get("ENTRUST_ROOT") + entrust_issuing = current_app.config.get("ENTRUST_ISSUING") + role = {"username": "", "password": "", "name": "entrust"} + current_app.logger.info("Creating Auth: {0} {1}".format(options, entrust_issuing)) + return entrust_root, "", [role] + + def get_ordered_certificate(self, order_id): + raise NotImplementedError("Not implemented\n", self, order_id) + + def canceled_ordered_certificate(self, pending_cert, **kwargs): + raise NotImplementedError("Not implemented\n", self, pending_cert, **kwargs) + + +class EntrustSourcePlugin(SourcePlugin): + title = "ENTRUST" + slug = "entrust-source" + description = "Enables the collecion of certificates" + version = ENTRUST.VERSION + + author = "sirferl" + author_url = "https://github.com/sirferl/lemur" + + def get_certificates(self, options, **kwargs): + # Not needed for ENTRUST + raise NotImplementedError("Not implemented\n", self, options, **kwargs) + + def get_endpoints(self, options, **kwargs): + # There are no endpoints in ENTRUST + raise NotImplementedError("Not implemented\n", self, options, **kwargs) diff --git a/lemur/static/app/angular/certificates/certificate/certificate.js b/lemur/static/app/angular/certificates/certificate/certificate.js index 155658e6..6b275328 100644 --- a/lemur/static/app/angular/certificates/certificate/certificate.js +++ b/lemur/static/app/angular/certificates/certificate/certificate.js @@ -107,7 +107,6 @@ angular.module('lemur') startingDay: 1 }; - $scope.open1 = function() { $scope.popup1.opened = true; }; @@ -140,6 +139,14 @@ angular.module('lemur') ); $scope.create = function (certificate) { + if(certificate.validityType === 'customDates' && + (!certificate.validityStart || !certificate.validityEnd)) { // these are not mandatory fields in schema, thus handling validation in js + return showMissingDateError(); + } + if(certificate.validityType === 'defaultDays') { + populateValidityDateAsPerDefault(certificate); + } + WizardHandler.wizard().context.loading = true; CertificateService.create(certificate).then( function () { @@ -164,6 +171,30 @@ angular.module('lemur') }); }; + function showMissingDateError() { + let error = {}; + error.message = ''; + error.reasons = {}; + error.reasons.validityRange = 'Valid start and end dates are needed, else select Default option'; + + toaster.pop({ + type: 'error', + title: 'Validation Error', + body: 'lemur-bad-request', + bodyOutputType: 'directive', + directiveData: error, + timeout: 100000 + }); + } + + function populateValidityDateAsPerDefault(certificate) { + // calculate start and end date as per default validity + let startDate = new Date(), endDate = new Date(); + endDate.setDate(startDate.getDate() + certificate.authority.authorityCertificate.defaultValidityDays); + certificate.validityStart = startDate; + certificate.validityEnd = endDate; + } + $scope.templates = [ { 'name': 'Client Certificate', @@ -277,6 +308,14 @@ angular.module('lemur') }; $scope.create = function (certificate) { + if(certificate.validityType === 'customDates' && + (!certificate.validityStart || !certificate.validityEnd)) { // these are not mandatory fields in schema, thus handling validation in js + return showMissingDateError(); + } + if(certificate.validityType === 'defaultDays') { + populateValidityDateAsPerDefault(certificate); + } + WizardHandler.wizard().context.loading = true; CertificateService.create(certificate).then( function () { @@ -301,6 +340,30 @@ angular.module('lemur') }); }; + function showMissingDateError() { + let error = {}; + error.message = ''; + error.reasons = {}; + error.reasons.validityRange = 'Valid start and end dates are needed, else select Default option'; + + toaster.pop({ + type: 'error', + title: 'Validation Error', + body: 'lemur-bad-request', + bodyOutputType: 'directive', + directiveData: error, + timeout: 100000 + }); + } + + function populateValidityDateAsPerDefault(certificate) { + // calculate start and end date as per default validity + let startDate = new Date(), endDate = new Date(); + endDate.setDate(startDate.getDate() + certificate.authority.authorityCertificate.defaultValidityDays); + certificate.validityStart = startDate; + certificate.validityEnd = endDate; + } + $scope.templates = [ { 'name': 'Client Certificate', diff --git a/lemur/static/app/angular/certificates/certificate/options.tpl.html b/lemur/static/app/angular/certificates/certificate/options.tpl.html index 7e47cf18..7e6ad428 100644 --- a/lemur/static/app/angular/certificates/certificate/options.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/options.tpl.html @@ -20,7 +20,7 @@ name="certificate signing request" ng-model="certificate.csr" placeholder="PEM encoded string..." class="form-control" - ng-pattern="/^-----BEGIN CERTIFICATE REQUEST-----/"> + ng-pattern="/(^-----BEGIN CERTIFICATE REQUEST-----[\S\s]*-----END CERTIFICATE REQUEST-----)|(^-----BEGIN NEW CERTIFICATE REQUEST-----[\S\s]*-----END NEW CERTIFICATE REQUEST-----)/">

Enter a valid certificate signing request.

diff --git a/lemur/static/app/angular/certificates/certificate/tracking.tpl.html b/lemur/static/app/angular/certificates/certificate/tracking.tpl.html index 07d6b0f4..d60a1a6a 100644 --- a/lemur/static/app/angular/certificates/certificate/tracking.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/tracking.tpl.html @@ -96,7 +96,7 @@ Certificate Authority
- + {{$select.selected.name}}
-
- +
+
+ + +
- - or - -
+
-
+
-
- -