diff --git a/.travis.yml b/.travis.yml index f1abf3f3..f38555a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,8 @@ cache: env: global: - PIP_DOWNLOAD_CACHE=".pip_download_cache" + # The following line is a temporary workaround for this issue: https://github.com/pypa/setuptools/issues/2230 + - SETUPTOOLS_USE_DISTUTILS=stdlib # do not load /etc/boto.cfg with Python 3 incompatible plugin # https://github.com/travis-ci/travis-ci/issues/5246#issuecomment-166460882 - BOTO_CONFIG=/doesnotexist diff --git a/Dockerfile b/Dockerfile index fc83a034..a7f8c878 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN apt-get install -y make software-properties-common curl RUN curl -sL https://deb.nodesource.com/setup_7.x | bash - RUN apt-get update RUN apt-get install -y npm libldap2-dev libsasl2-dev libldap2-dev libssl-dev +RUN pip install pip==20.0.2 RUN pip install -U setuptools RUN pip install coveralls bandit WORKDIR /app diff --git a/Makefile b/Makefile index 069eb29b..3312a41d 100644 --- a/Makefile +++ b/Makefile @@ -50,8 +50,10 @@ reset-db: setup-git: @echo "--> Installing git hooks" - git config branch.autosetuprebase always - cd .git/hooks && ln -sf ../../hooks/* ./ + if [ -d .git/hooks ]; then \ + git config branch.autosetuprebase always; \ + cd .git/hooks && ln -sf ../../hooks/* ./; \ + fi @echo "" clean: diff --git a/docs/administration.rst b/docs/administration.rst index 157af478..a3225fc2 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -66,7 +66,7 @@ Basic Configuration .. data:: SQLALCHEMY_POOL_SIZE -:noindex: + :noindex: The default connection pool size is 5 for sqlalchemy managed connections. Depending on the number of Lemur instances, please specify per instance connection pool size. Below is an example to set connection pool size to 10. @@ -80,7 +80,7 @@ Basic Configuration This is an optional setting but important to review and set for optimal database connection usage and for overall database performance. .. data:: SQLALCHEMY_MAX_OVERFLOW -:noindex: + :noindex: This setting allows to create connections in addition to specified number of connections in pool size. By default, sqlalchemy allows 10 connections to create in addition to the pool size. This is also an optional setting. If `SQLALCHEMY_POOL_SIZE` and @@ -155,6 +155,34 @@ Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create c LEMUR_ENCRYPTION_KEYS = ['1YeftooSbxCiX2zo8m1lXtpvQjy27smZcUUaGmffhMY=', 'LAfQt6yrkLqOK5lwpvQcT4jf2zdeTQJV1uYeh9coT5s='] +.. data:: PUBLIC_CA_AUTHORITY_NAMES + :noindex: + A list of public issuers which would be checked against to determine whether limit of max validity of 397 days + should be applied to the certificate. Configure public CA authority names in this list to enforce validity check. + This is an optional setting. Using this will allow the sanity check as mentioned. The name check is a case-insensitive + string comparision. + +.. data:: PUBLIC_CA_MAX_VALIDITY_DAYS + :noindex: + Use this config to override the limit of 397 days of validity for certificates issued by public issuers configured + using PUBLIC_CA_AUTHORITY_NAMES. Below example overrides the default validity of 397 days and sets it to 365 days. + + :: + + PUBLIC_CA_MAX_VALIDITY_DAYS = 365 + + +.. data:: DEFAULT_VALIDITY_DAYS + :noindex: + 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_VALIDITY_DAYS = 1095 + .. data:: DEBUG_DUMP :noindex: @@ -213,7 +241,7 @@ and are used when Lemur creates the CSR for your certificates. :: - LEMUR_DEFAULT_ORGANIZATIONAL_UNIT = "Operations" + LEMUR_DEFAULT_ORGANIZATIONAL_UNIT = "" .. data:: LEMUR_DEFAULT_ISSUER_PLUGIN @@ -625,13 +653,20 @@ Active Directory Certificate Services Plugin :noindex: Template to be used for certificate issuing. Usually display name w/o spaces + +.. data:: ADCS_TEMPLATE_ + :noindex: + If there is a config variable ADCS_TEMPLATE_ take the value as Cert template else default to ADCS_TEMPLATE to be compatible with former versions. Template to be used for certificate issuing. Usually display name w/o spaces .. data:: ADCS_START :noindex: + Used in ADCS-Sourceplugin. Minimum id of the first certificate to be returned. ID is increased by one until ADCS_STOP. Missing cert-IDs are ignored .. data:: ADCS_STOP :noindex: + Used for ADCS-Sourceplugin. Maximum id of the certificates returned. + .. data:: ADCS_ISSUING :noindex: @@ -644,6 +679,68 @@ Active Directory Certificate Services Plugin Contains the root cert of the CA +Entrust Plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Enables the creation of Entrust certificates. You need to set the API access up with Entrust support. Check the information in the Entrust Portal as well. +Certificates are created as "SERVER_AND_CLIENT_AUTH". +Caution: Sometimes the entrust API does not respond in a timely manner. This error is handled and reported by the plugin. Should this happen you just have to hit the create button again after to create a valid certificate. +The following parameters have to be set in the configuration files. + +.. data:: ENTRUST_URL + :noindex: + + This is the url for the Entrust API. Refer to the API documentation. + +.. data:: ENTRUST_API_CERT + :noindex: + + Path to the certificate file in PEM format. This certificate is created in the onboarding process. Refer to the API documentation. + +.. data:: ENTRUST_API_KEY + :noindex: + + Path to the key file in RSA format. This certificate is created in the onboarding process. Refer to the API documentation. Caution: the request library cannot handle encrypted keys. The keyfile therefore has to contain the unencrypted key. Please put this in a secure location on the server. + +.. data:: ENTRUST_API_USER + :noindex: + + String with the API user. This user is created in the onboarding process. Refer to the API documentation. + +.. data:: ENTRUST_API_PASS + :noindex: + + String with the password for the API user. This password is created in the onboarding process. Refer to the API documentation. + +.. data:: ENTRUST_NAME + :noindex: + + String with the name that should appear as certificate owner in the Entrust portal. Refer to the API documentation. + +.. data:: ENTRUST_EMAIL + :noindex: + + String with the email address that should appear as certificate contact email in the Entrust portal. Refer to the API documentation. + +.. data:: ENTRUST_PHONE + :noindex: + + String with the phone number that should appear as certificate contact in the Entrust portal. Refer to the API documentation. + +.. data:: ENTRUST_ISSUING + :noindex: + + Contains the issuing cert of the CA + +.. data:: ENTRUST_ROOT + :noindex: + + Contains the root cert of the CA + +.. data:: ENTRUST_PRODUCT_ + :noindex: + + If there is a config variable ENTRUST_PRODUCT_ take the value as cert product name else default to "STANDARD_SSL". Refer to the API documentation for valid products names. Verisign Issuer Plugin ~~~~~~~~~~~~~~~~~~~~~~ @@ -729,16 +826,16 @@ The following configuration properties are required to use the Digicert issuer p This is the root to be used for your CA chain -.. data:: DIGICERT_DEFAULT_VALIDITY +.. data:: DIGICERT_DEFAULT_VALIDITY_DAYS :noindex: - This is the default validity (in years), if no end date is specified. (Default: 1) + This is the default validity (in days), if no end date is specified. (Default: 397) -.. data:: DIGICERT_MAX_VALIDITY +.. data:: DIGICERT_MAX_VALIDITY_DAYS :noindex: - This is the maximum validity (in years). (Default: value of DIGICERT_DEFAULT_VALIDITY) + This is the maximum validity (in days). (Default: value of DIGICERT_DEFAULT_VALIDITY_DAYS) .. data:: DIGICERT_PRIVATE diff --git a/docs/production/index.rst b/docs/production/index.rst index b91ed6bd..67e97dae 100644 --- a/docs/production/index.rst +++ b/docs/production/index.rst @@ -451,3 +451,53 @@ LetsEncrypt flow to function. However, Lemur will attempt to automatically deter possible. To enable this functionality, periodically (or through Cron/Celery) run `lemur dns_providers get_all_zones`. This command will traverse all DNS providers, determine which zones they control, and upload this list of zones to Lemur's database (in the dns_providers table). Alternatively, you can manually input this data. + + +LetsEncrypt: pinning to cross-signed ICA +---------------------------------------- + +Let's Encrypt has been using a `cross-signed `_ intermediate CA by DST Root CA X3, +which is included in many older devices' TrustStore. + + +Let's Encrypt is `transitioning `_ to use +the intermediate CA issued by their own root (ISRG X1) starting from September 29th 2020. +This is in preparation of concluding the initial bootstrapping of their CA, by having it cross-signed by an older CA. + + +Lemur can temporarily pin to the cross-signed intermediate CA (same public/private key pair as the ICA signed by ISRG X1). +This will prolong support for incompatible devices. + +The following must be added to the config file to activate the pinning (the pinning will be removed by September 2021):: + + # remove or update after Mar 17 16:40:46 2021 GMT + IDENTRUST_CROSS_SIGNED_LE_ICA_EXPIRATION_DATE = "17/03/21" + IDENTRUST_CROSS_SIGNED_LE_ICA = """ + -----BEGIN CERTIFICATE----- + MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/ + MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT + DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow + SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT + GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC + AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF + q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8 + SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0 + Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA + a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj + /PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T + AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG + CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv + bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k + c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw + VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC + ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz + MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu + Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF + AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo + uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/ + wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu + X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG + PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6 + KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg== + -----END CERTIFICATE----- + """ diff --git a/lemur/authorities/schemas.py b/lemur/authorities/schemas.py index c78aec94..ef6263a8 100644 --- a/lemur/authorities/schemas.py +++ b/lemur/authorities/schemas.py @@ -23,6 +23,7 @@ from lemur.common.schema import LemurInputSchema, LemurOutputSchema from lemur.common import validators, missing from lemur.common.fields import ArrowDateTime +from lemur.constants import CERTIFICATE_KEY_TYPES class AuthorityInputSchema(LemurInputSchema): @@ -56,11 +57,12 @@ class AuthorityInputSchema(LemurInputSchema): type = fields.String(validate=validate.OneOf(["root", "subca"]), missing="root") parent = fields.Nested(AssociatedAuthoritySchema) signing_algorithm = fields.String( - validate=validate.OneOf(["sha256WithRSA", "sha1WithRSA"]), + validate=validate.OneOf(["sha256WithRSA", "sha1WithRSA", + "sha256WithECDSA", "SHA384withECDSA", "SHA512withECDSA"]), missing="sha256WithRSA", ) key_type = fields.String( - validate=validate.OneOf(["RSA2048", "RSA4096"]), missing="RSA2048" + validate=validate.OneOf(CERTIFICATE_KEY_TYPES), missing="RSA2048" ) key_name = fields.String() sensitivity = fields.String( @@ -109,6 +111,8 @@ class RootAuthorityCertificateOutputSchema(LemurOutputSchema): cn = fields.String() 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) @@ -134,6 +138,7 @@ class AuthorityNestedOutputSchema(LemurOutputSchema): owner = fields.Email() plugin = fields.Nested(PluginOutputSchema) active = fields.Boolean() + 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 58630ee6..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): @@ -311,6 +314,20 @@ class Certificate(db.Model): def validity_range(self): return self.not_after - self.not_before + @property + def max_issuance_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) + + @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): return self.parsed_cert.subject 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_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py index 3fc1df61..16d61a0f 100644 --- a/lemur/plugins/lemur_acme/plugin.py +++ b/lemur/plugins/lemur_acme/plugin.py @@ -205,9 +205,15 @@ class AcmeHandler(object): OpenSSL.crypto.FILETYPE_PEM, orderr.fullchain_pem ), ).decode() - pem_certificate_chain = orderr.fullchain_pem[ - len(pem_certificate) : # noqa - ].lstrip() + + if current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA", False) \ + and datetime.datetime.now() < datetime.datetime.strptime( + current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA_EXPIRATION_DATE", "17/03/21"), '%d/%m/%y'): + pem_certificate_chain = current_app.config.get("IDENTRUST_CROSS_SIGNED_LE_ICA") + else: + pem_certificate_chain = orderr.fullchain_pem[ + len(pem_certificate) : # noqa + ].lstrip() current_app.logger.debug( "{0} {1}".format(type(pem_certificate), type(pem_certificate_chain)) diff --git a/lemur/plugins/lemur_acme/tests/test_acme.py b/lemur/plugins/lemur_acme/tests/test_acme.py index 94949a74..8320a2de 100644 --- a/lemur/plugins/lemur_acme/tests/test_acme.py +++ b/lemur/plugins/lemur_acme/tests/test_acme.py @@ -156,6 +156,7 @@ class TestAcme(unittest.TestCase): mock_acme.fetch_chain = Mock(return_value="mock_chain") mock_crypto.dump_certificate = Mock(return_value=b"chain") mock_order = Mock() + mock_current_app.config = {} self.acme.request_certificate(mock_acme, [], mock_order) def test_setup_acme_client_fail(self): diff --git a/lemur/plugins/lemur_adcs/plugin.py b/lemur/plugins/lemur_adcs/plugin.py index a69afc90..4b4eb20c 100644 --- a/lemur/plugins/lemur_adcs/plugin.py +++ b/lemur/plugins/lemur_adcs/plugin.py @@ -40,7 +40,10 @@ class ADCSIssuerPlugin(IssuerPlugin): adcs_user = current_app.config.get("ADCS_USER") adcs_pwd = current_app.config.get("ADCS_PWD") adcs_auth_method = current_app.config.get("ADCS_AUTH_METHOD") - adcs_template = current_app.config.get("ADCS_TEMPLATE") + # if there is a config variable ADCS_TEMPLATE_ take the value as Cert template + # else default to ADCS_TEMPLATE to be compatible with former versions + authority = issuer_options.get("authority").name.upper() + adcs_template = current_app.config.get("ADCS_TEMPLATE_{0}".format(authority), current_app.config.get("ADCS_TEMPLATE")) ca_server = Certsrv( adcs_server, adcs_user, adcs_pwd, auth_method=adcs_auth_method ) diff --git a/lemur/plugins/lemur_digicert/plugin.py b/lemur/plugins/lemur_digicert/plugin.py index e5c4b2ce..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 @@ -61,18 +62,16 @@ def signature_hash(signing_algorithm): def determine_validity_years(years): - """Given an end date determine how many years into the future that date is. - :param years: - :return: validity in years """ - default_years = current_app.config.get("DIGICERT_DEFAULT_VALIDITY", 1) - max_years = current_app.config.get("DIGICERT_MAX_VALIDITY", default_years) + Considering maximum allowed certificate validity period of 397 days, this method should not return + more than 1 year of validity. Thus changing it to always return 1. + Lemur will change this method in future to handle validity in months (determine_validity_months) + instead of years. This will allow flexibility to handle short-lived certificates. - if years > max_years: - return max_years - if years not in [1, 2, 3]: - return default_years - return years + :param years: + :return: 1 + """ + return 1 def determine_end_date(end_date): @@ -82,11 +81,11 @@ def determine_end_date(end_date): :param end_date: :return: validity_end """ - default_years = current_app.config.get("DIGICERT_DEFAULT_VALIDITY", 1) - max_validity_end = arrow.utcnow().shift(years=current_app.config.get("DIGICERT_MAX_VALIDITY", default_years)) + default_days = current_app.config.get("DIGICERT_DEFAULT_VALIDITY_DAYS", 397) + max_validity_end = arrow.utcnow().shift(days=current_app.config.get("DIGICERT_MAX_VALIDITY_DAYS", default_days)) if not end_date: - end_date = arrow.utcnow().shift(years=default_years) + end_date = arrow.utcnow().shift(days=default_days) if end_date > max_validity_end: end_date = max_validity_end @@ -131,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) @@ -156,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) @@ -181,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_digicert/tests/test_digicert.py b/lemur/plugins/lemur_digicert/tests/test_digicert.py index 8bfd1dcf..4abfcf54 100644 --- a/lemur/plugins/lemur_digicert/tests/test_digicert.py +++ b/lemur/plugins/lemur_digicert/tests/test_digicert.py @@ -14,8 +14,6 @@ def config_mock(*args): "DIGICERT_ORG_ID": 111111, "DIGICERT_PRIVATE": False, "DIGICERT_DEFAULT_SIGNING_ALGORITHM": "sha256", - "DIGICERT_DEFAULT_VALIDITY": 1, - "DIGICERT_MAX_VALIDITY": 2, "DIGICERT_CIS_PROFILE_NAMES": {"digicert": 'digicert'}, "DIGICERT_CIS_SIGNING_ALGORITHMS": {"digicert": 'digicert'}, } @@ -24,19 +22,18 @@ def config_mock(*args): @patch("lemur.plugins.lemur_digicert.plugin.current_app") def test_determine_validity_years(mock_current_app): - mock_current_app.config.get = Mock(return_value=2) assert plugin.determine_validity_years(1) == 1 - assert plugin.determine_validity_years(0) == 2 - assert plugin.determine_validity_years(3) == 2 + assert plugin.determine_validity_years(0) == 1 + assert plugin.determine_validity_years(3) == 1 @patch("lemur.plugins.lemur_digicert.plugin.current_app") def test_determine_end_date(mock_current_app): - mock_current_app.config.get = Mock(return_value=2) + mock_current_app.config.get = Mock(return_value=397) # 397 days validity with freeze_time(time_to_freeze=arrow.get(2016, 11, 3).datetime): - assert arrow.get(2018, 11, 3) == plugin.determine_end_date(0) - assert arrow.get(2018, 5, 7) == plugin.determine_end_date(arrow.get(2018, 5, 7)) - assert arrow.get(2018, 11, 3) == plugin.determine_end_date(arrow.get(2020, 5, 7)) + assert arrow.get(2017, 12, 5) == plugin.determine_end_date(0) # 397 days from (2016, 11, 3) + assert arrow.get(2017, 12, 5) == plugin.determine_end_date(arrow.get(2017, 12, 5)) + assert arrow.get(2017, 12, 5) == plugin.determine_end_date(arrow.get(2020, 5, 7)) @patch("lemur.plugins.lemur_digicert.plugin.current_app") @@ -52,7 +49,7 @@ def test_map_fields_with_validity_years(mock_current_app): "owner": "bob@example.com", "description": "test certificate", "extensions": {"sub_alt_names": {"names": [x509.DNSName(x) for x in names]}}, - "validity_years": 2 + "validity_years": 1 } expected = { "certificate": { @@ -62,7 +59,7 @@ def test_map_fields_with_validity_years(mock_current_app): "signature_hash": "sha256", }, "organization": {"id": 111111}, - "validity_years": 2, + "validity_years": 1, } assert expected == plugin.map_fields(options, CSR_STR) 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/authorities/authority/distinguishedName.tpl.html b/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html index 33b0ba4b..c6a7d312 100644 --- a/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html +++ b/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html @@ -46,8 +46,7 @@ Organizational Unit
- -

You must enter a organizational unit

+
diff --git a/lemur/static/app/angular/authorities/authority/options.tpl.html b/lemur/static/app/angular/authorities/authority/options.tpl.html index dbc4f40a..bf1ad70c 100644 --- a/lemur/static/app/angular/authorities/authority/options.tpl.html +++ b/lemur/static/app/angular/authorities/authority/options.tpl.html @@ -4,7 +4,7 @@ Signing Algorithm
- +
diff --git a/lemur/static/app/angular/certificates/certificate/certificate.js b/lemur/static/app/angular/certificates/certificate/certificate.js index 21f61f22..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', @@ -212,12 +243,18 @@ angular.module('lemur') }) .controller('CertificateCloneController', function ($scope, $uibModalInstance, CertificateApi, CertificateService, DestinationService, AuthorityService, AuthorityApi, PluginService, MomentService, WizardHandler, LemurRestangular, NotificationService, toaster, editId) { + $scope.certificate = LemurRestangular.restangularizeElement(null, {}, 'certificates'); CertificateApi.get(editId).then(function (certificate) { $scope.certificate = certificate; + // prepare the certificate for cloning $scope.certificate.name = ''; // we should prefer the generated name $scope.certificate.csr = null; // should not clone CSR in case other settings are changed in clone $scope.certificate.validityStart = null; $scope.certificate.validityEnd = null; + $scope.certificate.keyType = 'RSA2048'; // default algo to show during clone + $scope.certificate.description = 'Cloning from cert ID ' + editId; + $scope.certificate.replacedBy = []; // should not clone 'replaced by' info + $scope.certificate.removeReplaces(); // should not clone 'replacement cert' info CertificateService.getDefaults($scope.certificate); }); @@ -271,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 () { @@ -295,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/distinguishedName.tpl.html b/lemur/static/app/angular/certificates/certificate/distinguishedName.tpl.html index 19102b03..72f168a0 100644 --- a/lemur/static/app/angular/certificates/certificate/distinguishedName.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/distinguishedName.tpl.html @@ -62,9 +62,7 @@
-

You must - enter a organizational unit

+ class="form-control"/>
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 027add0f..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 - -
+
-
+
-
- -