diff --git a/.travis.yml b/.travis.yml index f1abf3f3..129d774b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: python -dist: xenial +dist: bionic node_js: - "6.2.0" @@ -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/docker/src/lemur.conf.py b/docker/src/lemur.conf.py index 3cc51792..4bcaeef9 100644 --- a/docker/src/lemur.conf.py +++ b/docker/src/lemur.conf.py @@ -1,4 +1,7 @@ import os +import random +import string +import base64 from ast import literal_eval _basedir = os.path.abspath(os.path.dirname(__file__)) @@ -6,12 +9,22 @@ _basedir = os.path.abspath(os.path.dirname(__file__)) CORS = os.environ.get("CORS") == "True" debug = os.environ.get("DEBUG") == "True" -SECRET_KEY = repr(os.environ.get('SECRET_KEY','Hrs8kCDNPuT9vtshsSWzlrYW+d+PrAXvg/HwbRE6M3vzSJTTrA/ZEw==')) -LEMUR_TOKEN_SECRET = repr(os.environ.get('LEMUR_TOKEN_SECRET','YVKT6nNHnWRWk28Lra1OPxMvHTqg1ZXvAcO7bkVNSbrEuDQPABM0VQ==')) -LEMUR_ENCRYPTION_KEYS = repr(os.environ.get('LEMUR_ENCRYPTION_KEYS','Ls-qg9j3EMFHyGB_NL0GcQLI6622n9pSyGM_Pu0GdCo=')) +def get_random_secret(length): + secret_key = ''.join(random.choice(string.ascii_uppercase) for x in range(round(length / 4))) + secret_key = secret_key + ''.join(random.choice("~!@#$%^&*()_+") for x in range(round(length / 4))) + secret_key = secret_key + ''.join(random.choice(string.ascii_lowercase) for x in range(round(length / 4))) + return secret_key + ''.join(random.choice(string.digits) for x in range(round(length / 4))) -LEMUR_WHITELISTED_DOMAINS = [] + +SECRET_KEY = repr(os.environ.get('SECRET_KEY', get_random_secret(32).encode('utf8'))) + +LEMUR_TOKEN_SECRET = repr(os.environ.get('LEMUR_TOKEN_SECRET', + base64.b64encode(get_random_secret(32).encode('utf8')))) +LEMUR_ENCRYPTION_KEYS = repr(os.environ.get('LEMUR_ENCRYPTION_KEYS', + base64.b64encode(get_random_secret(32).encode('utf8')))) + +LEMUR_ALLOWED_DOMAINS = [] LEMUR_EMAIL = '' LEMUR_SECURITY_TEAM_EMAIL = [] diff --git a/docs/administration.rst b/docs/administration.rst index 80d88feb..d695bb45 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -100,7 +100,7 @@ Specifying the `SQLALCHEMY_MAX_OVERFLOW` to 0 will enforce limit to not create c Specifies whether to allow certificates created by Lemur to expire on weekends. Default is True. -.. data:: LEMUR_WHITELISTED_DOMAINS +.. data:: LEMUR_ALLOWED_DOMAINS :noindex: List of regular expressions for domain restrictions; if the list is not empty, normal users can only issue @@ -155,32 +155,28 @@ 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. + Use this config to override the limit of 397 days of validity for certificates issued by CA/Browser compliant authorities. + The authorities with cab_compliant option set to true will use this config. The example below overrides the default validity + of 397 days and sets it to 365 days. :: 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 CA/Browser Forum compliant 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. The example below 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 @@ -273,7 +269,7 @@ Certificates marked as inactive will **not** be notified of upcoming expiration. silence the expiration. If a certificate is active and is expiring the above will be notified according to the `LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS` or 30, 15, 2 days before expiration if no intervals are set. -Lemur supports sending certification expiration notifications through SES and SMTP. +Lemur supports sending certificate expiration notifications through SES and SMTP. .. data:: LEMUR_EMAIL_SENDER @@ -327,6 +323,54 @@ Lemur supports sending certification expiration notifications through SES and SM LEMUR_SECURITY_TEAM_EMAIL_INTERVALS = [15, 2] +Celery Options +--------------- +To make use of automated tasks within lemur (e.g. syncing source/destinations, or reissuing ACME certificates), you +need to configure celery. See :ref:`Periodic Tasks ` for more in depth documentation. + +.. data:: CELERY_RESULT_BACKEND + :noindex: + + The url to your redis backend (needs to be in the format `redis://:/`) + +.. data:: CELERY_BROKER_URL + :noindex: + + The url to your redis broker (needs to be in the format `redis://:/`) + +.. data:: CELERY_IMPORTS + :noindex: + + The module that celery needs to import, in our case thats `lemur.common.celery` + +.. data:: CELERY_TIMEZONE + :noindex: + + The timezone for celery to work with + + +.. data:: CELERYBEAT_SCHEDULE + :noindex: + + This defines the schedule, with which the celery beat makes the worker run the specified tasks. + +Since the celery module, relies on the RedisHandler, the following options also need to be set. + +.. data:: REDIS_HOST + :noindex: + + Hostname of your redis instance + +.. data:: REDIS_PORT + :noindex: + + Port on which redis is running (default: 6379) + +.. data:: REDIS_DB + :noindex: + + Which redis database to be used, by default redis offers databases 0-15 (default: 0) + Authentication Options ---------------------- Lemur currently supports Basic Authentication, LDAP Authentication, Ping OAuth2, and Google out of the box. Additional flows can be added relatively easily. @@ -666,13 +710,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: @@ -685,6 +736,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 ~~~~~~~~~~~~~~~~~~~~~~ @@ -1067,6 +1180,23 @@ The following configuration properties are required to use the PowerDNS ACME Plu File/Dir path to CA Bundle: Verifies the TLS certificate was issued by a Certificate Authority in the provided CA bundle. +ACME Plugin +~~~~~~~~~~~~ + +The following configration properties are optional for the ACME plugin to use. They allow reusing an existing ACME +account. See :ref:`Using a pre-existing ACME account ` for more details. + + +.. data:: ACME_PRIVATE_KEY + :noindex: + + This is the private key, the account was registered with (in JWK format) + +.. data:: ACME_REGR + :noindex: + + This is the registration for the ACME account, the most important part is the uri attribute (in JSON) + .. _CommandLineInterface: Command Line Interface @@ -1320,7 +1450,7 @@ Slack Adds support for slack notifications. -AWS +AWS (Source) ---- :Authors: @@ -1333,7 +1463,7 @@ AWS Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment. -AWS +AWS (Destination) ---- :Authors: @@ -1346,6 +1476,19 @@ AWS Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment. +AWS (SNS Notification) +----- + +:Authors: + Jasmine Schladen +:Type: + Notification +:Description: + Adds support for SNS notifications. SNS notifications (like other notification plugins) are currently only supported + for certificate expiration. Configuration requires a region, account number, and SNS topic name; these elements + are then combined to build the topic ARN. Lemur must have access to publish messages to the specified SNS topic. + + Kubernetes ---------- diff --git a/docs/developer/plugins/index.rst b/docs/developer/plugins/index.rst index 8af5e1c8..c2a8c48a 100644 --- a/docs/developer/plugins/index.rst +++ b/docs/developer/plugins/index.rst @@ -215,18 +215,21 @@ Notification ------------ Lemur includes the ability to create Email notifications by **default**. These notifications -currently come in the form of expiration notices. Lemur periodically checks certifications expiration dates and +currently come in the form of expiration and rotation notices. Lemur periodically checks certificate expiration dates and determines if a given certificate is eligible for notification. There are currently only two parameters used to determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number of days the current date (UTC) is from that expiration date. -There are currently two objects that available for notification plugins the first is `NotficationPlugin`. This is the base object for -any notification within Lemur. Currently the only support notification type is an certificate expiration notification. If you +Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable. +Notifications sent to a certificate owner and security team (`LEMUR_SECURITY_TEAM_EMAIL`) can currently only be sent via email. + +There are currently two objects that are available for notification plugins. The first is `NotificationPlugin`, which is the base object for +any notification within Lemur. Currently the only supported notification type is a certificate expiration notification. If you are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on. You would also then need to build additional code to trigger the new notification type. -The second is `ExpirationNotificationPlugin`, this object inherits from `NotificationPlugin` object. -You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, HipChat, Jira, etc.). It adds default options that are required by +The second is `ExpirationNotificationPlugin`, which inherits from the `NotificationPlugin` object. +You will most likely want to base your plugin on this object if you want to add new channels for expiration notices (HipChat, Jira, etc.). It adds default options that are required by all expiration notifications (interval, unit). This interface expects for the child to define the following function:: def send(self, notification_type, message, targets, options, **kwargs): diff --git a/docs/production/index.rst b/docs/production/index.rst index 67e97dae..c6f561ca 100644 --- a/docs/production/index.rst +++ b/docs/production/index.rst @@ -49,9 +49,11 @@ The amount of effort you wish to expend ensuring that Lemur has good entropy to If you wish to generate more entropy for your system we would suggest you take a look at the following resources: -- `WES-entropy-client `_ +- `WES-entropy-client `_ - `haveged `_ +The original *WES-entropy-client* repository by WhitewoodCrypto was removed, the link now points to a fork of it. + For additional information about OpenSSL entropy issues: - `Managing and Understanding Entropy Usage `_ @@ -313,6 +315,7 @@ It will start a shell from which you can start/stop/restart the service. You can read all errors that might occur from /tmp/lemur.log. +.. _PeriodicTasks: Periodic Tasks ============== @@ -386,10 +389,17 @@ To enable celery support, you must also have configuration values that tell Cele Here are the Celery configuration variables that should be set:: CELERY_RESULT_BACKEND = 'redis://your_redis_url:6379' - CELERY_BROKER_URL = 'redis://your_redis_url:6379' + CELERY_BROKER_URL = 'redis://your_redis_url:6379/0' CELERY_IMPORTS = ('lemur.common.celery') CELERY_TIMEZONE = 'UTC' + REDIS_HOST="your_redis_url" + REDIS_PORT=6379 + REDIS_DB=0 + +Out of the box, every Redis instance supports 16 databases. The default database (`REDIS_DB`) is set to 0, however, you can use any of the databases from 0-15. Via `redis.conf` more databases can be supported. +In the `redis://` url, the database number can be added with a slash after the port. (defaults to 0, if omitted) + Do not forget to import crontab module in your configuration file:: from celery.task.schedules import crontab @@ -501,3 +511,47 @@ The following must be added to the config file to activate the pinning (the pinn KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg== -----END CERTIFICATE----- """ + + +.. _AcmeAccountReuse: + +LetsEncrypt: Using a pre-existing ACME account +----------------------------------------------- + +Let's Encrypt allows reusing an existing ACME account, to create and especially revoke certificates. The current +implementation in the acme plugin, only allows for a single account for all ACME authorities, which might be an issue, +when you try to use Let's Encrypt together with another certificate authority that uses the ACME protocol. + +To use an existing account, you need to configure the `ACME_PRIVATE_KEY` and `ACME_REGR` variables in the lemur +configuration. + +`ACME_PRIVATE_KEY` needs to be in the JWK format:: + + { + "kty": "RSA", + "n": "yr1qBwHizA7ME_iV32bY10ILp.....", + "e": "AQAB", + "d": "llBlYhil3I.....", + "p": "-5LW2Lewogo.........", + "q": "zk6dHqHfHksd.........", + "dp": "qfe9fFIu3mu.......", + "dq": "cXFO-loeOyU.......", + "qi": "AfK1sh0_8sLTb..........." + } + + +Using `python-jwt` converting an existing private key in PEM format is quite easy:: + + import python_jwt as jwt, jwcrypto.jwk as jwk + + priv_key = jwk.JWK.from_pem(b"""-----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY-----""") + + print(priv_key.export()) + +`ACME_REGR` needs to be a valid JSON with a `body` and a `uri` attribute, similar to this:: + + {"body": {}, "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/"} + +The URI can be retrieved from the ACME create account endpoint when creating a new account, using the existing key. \ No newline at end of file diff --git a/lemur/authorities/models.py b/lemur/authorities/models.py index ccd1fab8..f042f773 100644 --- a/lemur/authorities/models.py +++ b/lemur/authorities/models.py @@ -6,6 +6,9 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ +import json + +from flask import current_app from sqlalchemy.orm import relationship from sqlalchemy import ( Column, @@ -80,5 +83,33 @@ class Authority(db.Model): def plugin(self): return plugins.get(self.plugin_name) + @property + def is_cab_compliant(self): + """ + Parse the options to find whether authority is CAB Forum Compliant, + i.e., adhering to the CA/Browser Forum Baseline Requirements. + Returns None if option is not available + """ + if not self.options: + return None + + for option in json.loads(self.options): + if "name" in option and option["name"] == 'cab_compliant': + return option["value"] + + return None + + @property + def max_issuance_days(self): + if self.is_cab_compliant: + return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397) + + @property + def default_validity_days(self): + if self.is_cab_compliant: + return current_app.config.get("PUBLIC_CA_MAX_VALIDITY_DAYS", 397) + + return current_app.config.get("DEFAULT_VALIDITY_DAYS", 365) # 1 year default + def __repr__(self): return "Authority(name={name})".format(name=self.name) diff --git a/lemur/authorities/schemas.py b/lemur/authorities/schemas.py index 0700c15b..555ba931 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): @@ -42,13 +43,13 @@ class AuthorityInputSchema(LemurInputSchema): organization = fields.String( missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION") ) - location = fields.String( - missing=lambda: current_app.config.get("LEMUR_DEFAULT_LOCATION") - ) + location = fields.String() country = fields.String( missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY") ) state = fields.String(missing=lambda: current_app.config.get("LEMUR_DEFAULT_STATE")) + # Creating a String field instead of Email to allow empty value + email = fields.String() plugin = fields.Nested(PluginInputSchema) @@ -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,7 +111,6 @@ class RootAuthorityCertificateOutputSchema(LemurOutputSchema): cn = fields.String() not_after = fields.DateTime() not_before = fields.DateTime() - max_issuance_days = fields.Integer() owner = fields.Email() status = fields.Boolean() user = fields.Nested(UserNestedOutputSchema) @@ -124,6 +125,8 @@ class AuthorityOutputSchema(LemurOutputSchema): active = fields.Boolean() options = fields.Dict() roles = fields.List(fields.Nested(AssociatedRoleSchema)) + max_issuance_days = fields.Integer() + default_validity_days = fields.Integer() authority_certificate = fields.Nested(RootAuthorityCertificateOutputSchema) @@ -135,7 +138,10 @@ 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=["not_after", "not_before"]) + is_cab_compliant = fields.Boolean() + max_issuance_days = fields.Integer() + default_validity_days = fields.Integer() authority_update_schema = AuthorityUpdateSchema() diff --git a/lemur/authorities/service.py b/lemur/authorities/service.py index c70c6fc5..0913e629 100644 --- a/lemur/authorities/service.py +++ b/lemur/authorities/service.py @@ -39,6 +39,22 @@ def update(authority_id, description, owner, active, roles): return database.update(authority) +def update_options(authority_id, options): + """ + Update an authority with new options. + + :param authority_id: + :param options: the new options to be saved into the authority + :return: + """ + + authority = get(authority_id) + + authority.options = options + + return database.update(authority) + + def mint(**kwargs): """ Creates the authority based on the plugin provided. diff --git a/lemur/certificates/cli.py b/lemur/certificates/cli.py index b883dee0..f23948be 100644 --- a/lemur/certificates/cli.py +++ b/lemur/certificates/cli.py @@ -735,3 +735,45 @@ def automatically_enable_autorotate(): }) cert.rotation = True database.update(cert) + + +@manager.command +def deactivate_entrust_certificates(): + """ + Attempt to deactivate test certificates issued by Entrust + """ + + log_data = { + "function": f"{__name__}.{sys._getframe().f_code.co_name}", + "message": "Deactivating Entrust certificates" + } + + certificates = get_all_valid_certs(['entrust-issuer']) + entrust_plugin = plugins.get('entrust-issuer') + for cert in certificates: + try: + response = entrust_plugin.deactivate_certificate(cert) + if response == 200: + cert.status = "revoked" + else: + cert.status = "unknown" + + log_data["valid"] = cert.status + log_data["certificate_name"] = cert.name + log_data["certificate_id"] = cert.id + metrics.send( + "certificate_deactivate", + "counter", + 1, + metric_tags={"status": log_data["valid"], + "certificate_name": log_data["certificate_name"], + "certificate_id": log_data["certificate_id"]}, + ) + current_app.logger.info(log_data) + + database.update(cert) + + except Exception as e: + current_app.logger.info(log_data) + sentry.captureException() + current_app.logger.exception(e) diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index 5f6c4ba9..f6562b3f 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -9,7 +9,6 @@ from datetime import timedelta import arrow from cryptography import x509 -from cryptography.hazmat.primitives.asymmetric import rsa from flask import current_app from idna.core import InvalidCodepoint from sqlalchemy import ( @@ -153,6 +152,7 @@ class Certificate(db.Model): Integer, ForeignKey("authorities.id", ondelete="CASCADE") ) rotation_policy_id = Column(Integer, ForeignKey("rotation_policies.id")) + key_type = Column(String(128)) notifications = relationship( "Notification", @@ -235,6 +235,7 @@ class Certificate(db.Model): self.replaces = kwargs.get("replaces", []) self.rotation = kwargs.get("rotation") self.rotation_policy = kwargs.get("rotation_policy") + self.key_type = kwargs.get("key_type") self.signing_algorithm = defaults.signing_algorithm(cert) self.bits = defaults.bitstrength(cert) self.external_id = kwargs.get("external_id") @@ -296,12 +297,17 @@ class Certificate(db.Model): def distinguished_name(self): return self.parsed_cert.subject.rfc4514_string() + """ + # Commenting this property as key_type is now added as a column. This code can be removed in future. @property def key_type(self): if isinstance(self.parsed_cert.public_key(), rsa.RSAPublicKey): 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,14 +317,6 @@ 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) - - return current_app.config.get("DEFAULT_MAX_VALIDITY_DAYS", 1095) # 3 years default - @property def subject(self): return self.parsed_cert.subject diff --git a/lemur/certificates/schemas.py b/lemur/certificates/schemas.py index 42e444bc..3dc864e7 100644 --- a/lemur/certificates/schemas.py +++ b/lemur/certificates/schemas.py @@ -8,7 +8,7 @@ from flask import current_app from flask_restful import inputs from flask_restful.reqparse import RequestParser -from marshmallow import fields, validate, validates_schema, post_load, pre_load +from marshmallow import fields, validate, validates_schema, post_load, pre_load, post_dump from marshmallow.exceptions import ValidationError from lemur.authorities.schemas import AuthorityNestedOutputSchema @@ -23,6 +23,7 @@ from lemur.domains.schemas import DomainNestedOutputSchema from lemur.notifications import service as notification_service from lemur.notifications.schemas import NotificationNestedOutputSchema from lemur.policies.schemas import RotationPolicyNestedOutputSchema +from lemur.roles import service as roles_service from lemur.roles.schemas import RoleNestedOutputSchema from lemur.schemas import ( AssociatedAuthoritySchema, @@ -107,9 +108,7 @@ class CertificateInputSchema(CertificateCreationSchema): organization = fields.String( missing=lambda: current_app.config.get("LEMUR_DEFAULT_ORGANIZATION") ) - location = fields.String( - missing=lambda: current_app.config.get("LEMUR_DEFAULT_LOCATION") - ) + location = fields.String() country = fields.String( missing=lambda: current_app.config.get("LEMUR_DEFAULT_COUNTRY") ) @@ -148,6 +147,21 @@ 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 + + # This code will be exercised for certificate import (without CSR) + if data.get("key_type") is None: + if data.get("body"): + data["key_type"] = utils.get_key_type_from_certificate(data["body"]) + else: + data["key_type"] = "RSA2048" # default value + return missing.convert_validity_years(data) @@ -171,25 +185,52 @@ class CertificateEditInputSchema(CertificateSchema): data["replaces"] = data[ "replacements" ] # TODO remove when field is deprecated + + if data.get("owner"): + # Check if role already exists. This avoids adding duplicate role. + if data.get("roles") and any(r.get("name") == data["owner"] for r in data["roles"]): + return data + + # Add required role + owner_role = roles_service.get_or_create( + data["owner"], + description=f"Auto generated role based on owner: {data['owner']}" + ) + + # Put role info in correct format using RoleNestedOutputSchema + owner_role_dict = RoleNestedOutputSchema().dump(owner_role).data + if data.get("roles"): + data["roles"].append(owner_role_dict) + else: + data["roles"] = [owner_role_dict] + return data @post_load def enforce_notifications(self, data): """ - Ensures that when an owner changes, default notifications are added for the new owner. - Old owner notifications are retained unless explicitly removed. + Add default notification for current owner if none exist. + This ensures that the default notifications are added in the event of owner change. + Old owner notifications are retained unless explicitly removed later in the code path. :param data: :return: """ - if data["owner"]: + if data.get("owner"): notification_name = "DEFAULT_{0}".format( data["owner"].split("@")[0].upper() ) + + # Even if one default role exists, return + # This allows a User to remove unwanted default notification for current owner + if any(n.label.startswith(notification_name) for n in data["notifications"]): + return data + data[ "notifications" ] += notification_service.create_default_expiration_notifications( notification_name, [data["owner"]] ) + return data @@ -270,6 +311,7 @@ class CertificateOutputSchema(LemurOutputSchema): serial = fields.String() serial_hex = Hex(attribute="serial") signing_algorithm = fields.String() + key_type = fields.String(allow_none=True) status = fields.String() user = fields.Nested(UserNestedOutputSchema) @@ -290,6 +332,31 @@ class CertificateOutputSchema(LemurOutputSchema): ) rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema) + country = fields.String() + location = fields.String() + state = fields.String() + organization = fields.String() + organizational_unit = fields.String() + + @post_dump + def handle_subject_details(self, data): + subject_details = ["country", "state", "location", "organization", "organizational_unit"] + + # Remove subject details if authority is CA/Browser Forum compliant. The code will use default set of values in that case. + # If CA/Browser Forum compliance of an authority is unknown (None), it is safe to fallback to default values. Thus below + # condition checks for 'not False' ==> 'True or None' + if data.get("authority"): + is_cab_compliant = data.get("authority").get("isCabCompliant") + + if is_cab_compliant is not False: + for field in subject_details: + data.pop(field, None) + + # Removing subject fields if None, else it complains in de-serialization + for field in subject_details: + if field in data and data[field] is None: + data.pop(field) + class CertificateShortOutputSchema(LemurOutputSchema): id = fields.Integer() @@ -310,6 +377,7 @@ class CertificateUploadInputSchema(CertificateCreationSchema): body = fields.String(required=True) chain = fields.String(missing=None, allow_none=True) csr = fields.String(required=False, allow_none=True, validate=validators.csr) + key_type = fields.String() destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True) notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True) @@ -357,6 +425,16 @@ class CertificateUploadInputSchema(CertificateCreationSchema): # Throws ValidationError validators.verify_cert_chain([cert] + chain) + @pre_load + def load_data(self, data): + if data.get("body"): + try: + data["key_type"] = utils.get_key_type_from_certificate(data["body"]) + except ValueError: + raise ValidationError( + "Public certificate presented is not valid.", field_names=["body"] + ) + class CertificateExportInputSchema(LemurInputSchema): plugin = fields.Nested(PluginInputSchema) diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index df73487d..683104cf 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -105,7 +105,7 @@ def get_all_certs(): def get_all_valid_certs(authority_plugin_name): """ - Retrieves all valid (not expired) certificates within Lemur, for the given authority plugin names + Retrieves all valid (not expired & not revoked) certificates within Lemur, for the given authority plugin names ignored if no authority_plugin_name provided. Note that depending on the DB size retrieving all certificates might an expensive operation @@ -116,11 +116,12 @@ def get_all_valid_certs(authority_plugin_name): return ( Certificate.query.outerjoin(Authority, Authority.id == Certificate.authority_id).filter( Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter( - Authority.plugin_name.in_(authority_plugin_name)).all() + Authority.plugin_name.in_(authority_plugin_name)).filter(Certificate.revoked.is_(False)).all() ) else: return ( - Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).all() + Certificate.query.filter(Certificate.not_after > arrow.now().format("YYYY-MM-DD")).filter( + Certificate.revoked.is_(False)).all() ) @@ -256,17 +257,29 @@ def update(cert_id, **kwargs): return database.update(cert) -def create_certificate_roles(**kwargs): - # create an role for the owner and assign it - owner_role = role_service.get_by_name(kwargs["owner"]) +def cleanup_owner_roles_notification(owner_name, kwargs): + kwargs["roles"] = [r for r in kwargs["roles"] if r.name != owner_name] + notification_prefix = f"DEFAULT_{owner_name.split('@')[0].upper()}" + kwargs["notifications"] = [n for n in kwargs["notifications"] if not n.label.startswith(notification_prefix)] - if not owner_role: - owner_role = role_service.create( - kwargs["owner"], - description="Auto generated role based on owner: {0}".format( - kwargs["owner"] - ), - ) + +def update_notify(cert, notify_flag): + """ + Toggle notification value which is a boolean + :param notify_flag: new notify value + :param cert: Certificate object to be updated + :return: + """ + cert.notify = notify_flag + return database.update(cert) + + +def create_certificate_roles(**kwargs): + # create a role for the owner and assign it + owner_role = role_service.get_or_create( + kwargs["owner"], + description=f"Auto generated role based on owner: {kwargs['owner']}" + ) # ensure that the authority's owner is also associated with the certificate if kwargs.get("authority"): @@ -347,7 +360,12 @@ def create(**kwargs): try: cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs) except Exception: - current_app.logger.error("Exception minting certificate", exc_info=True) + log_data = { + "message": "Exception minting certificate", + "issuer": kwargs["authority"].name, + "cn": kwargs["common_name"], + } + current_app.logger.error(log_data, exc_info=True) sentry.captureException() raise kwargs["body"] = cert_body @@ -542,20 +560,21 @@ def query_common_name(common_name, args): :return: """ owner = args.pop("owner") - if not owner: - owner = "%" - # only not expired certificates current_time = arrow.utcnow() - result = ( - Certificate.query.filter(Certificate.cn.ilike(common_name)) - .filter(Certificate.owner.ilike(owner)) - .filter(Certificate.not_after >= current_time.format("YYYY-MM-DD")) - .all() - ) + query = Certificate.query.filter(Certificate.not_after >= current_time.format("YYYY-MM-DD"))\ + .filter(not_(Certificate.revoked))\ + .filter(not_(Certificate.replaced.any())) # ignore rotated certificates to avoid duplicates - return result + if owner: + query = query.filter(Certificate.owner.ilike(owner)) + + if common_name != "%": + # if common_name is a wildcard ('%'), no need to include it in the query + query = query.filter(Certificate.cn.ilike(common_name)) + + return query.all() def create_csr(**csr_config): 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/certificates/views.py b/lemur/certificates/views.py index 51f7f615..18746636 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -884,10 +884,118 @@ class Certificates(AuthenticatedResource): 400, ) + # if owner is changed, remove all notifications and roles associated with old owner + if cert.owner != data["owner"]: + service.cleanup_owner_roles_notification(cert.owner, data) + cert = service.update(certificate_id, **data) log_service.create(g.current_user, "update_cert", certificate=cert) return cert + @validate_schema(certificate_edit_input_schema, certificate_output_schema) + def post(self, certificate_id, data=None): + """ + .. http:post:: /certificates/1/update/notify + + Update certificate notification + + **Example request**: + + .. sourcecode:: http + + POST /certificates/1/update/notify HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "notify": false + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "status": null, + "cn": "*.test.example.net", + "chain": "", + "authority": { + "active": true, + "owner": "secure@example.com", + "id": 1, + "description": "verisign test authority", + "name": "verisign" + }, + "owner": "joe@example.com", + "serial": "82311058732025924142789179368889309156", + "id": 2288, + "issuer": "SymantecCorporation", + "dateCreated": "2016-06-03T06:09:42.133769+00:00", + "notBefore": "2016-06-03T00:00:00+00:00", + "notAfter": "2018-01-12T23:59:59+00:00", + "destinations": [], + "bits": 2048, + "body": "-----BEGIN CERTIFICATE-----...", + "description": null, + "deleted": null, + "notify": false, + "notifications": [{ + "id": 1 + }] + "signingAlgorithm": "sha256", + "user": { + "username": "jane", + "active": true, + "email": "jane@example.com", + "id": 2 + }, + "active": true, + "domains": [{ + "sensitive": false, + "id": 1090, + "name": "*.test.example.net" + }], + "replaces": [], + "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", + "roles": [{ + "id": 464, + "description": "This is a google group based role created by Lemur", + "name": "joe@example.com" + }], + "rotation": true, + "rotationPolicy": {"name": "default"}, + "san": null + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + + """ + cert = service.get(certificate_id) + + if not cert: + return dict(message="Cannot find specified certificate"), 404 + + # allow creators + if g.current_user != cert.user: + owner_role = role_service.get_by_name(cert.owner) + permission = CertificatePermission(owner_role, [x.name for x in cert.roles]) + + if not permission.can(): + return ( + dict(message="You are not authorized to update this certificate"), + 403, + ) + + cert = service.update_notify(cert, data.get("notify")) + log_service.create(g.current_user, "update_cert", certificate=cert) + return cert + def delete(self, certificate_id, data=None): """ .. http:delete:: /certificates/1 @@ -1354,6 +1462,9 @@ api.add_resource( api.add_resource( Certificates, "/certificates/", endpoint="certificate" ) +api.add_resource( + Certificates, "/certificates//update/notify", endpoint="certificateUpdateNotify" +) api.add_resource(CertificatesStats, "/certificates/stats", endpoint="certificateStats") api.add_resource( CertificatesUpload, "/certificates/upload", endpoint="certificateUpload" diff --git a/lemur/common/celery.py b/lemur/common/celery.py index a490b13b..f9d58bd9 100644 --- a/lemur/common/celery.py +++ b/lemur/common/celery.py @@ -759,7 +759,7 @@ def check_revoked(): log_data = { "function": function, - "message": "check if any certificates are revoked revoked", + "message": "check if any valid certificate is revoked", "task_id": task_id, } @@ -842,3 +842,39 @@ def enable_autorotate_for_certs_attached_to_endpoint(): cli_certificate.automatically_enable_autorotate() metrics.send(f"{function}.success", "counter", 1) return log_data + + +@celery.task(soft_time_limit=3600) +def deactivate_entrust_test_certificates(): + """ + This celery task attempts to deactivate all not yet deactivated Entrust certificates, and should only run in TEST + :return: + """ + function = f"{__name__}.{sys._getframe().f_code.co_name}" + task_id = None + if celery.current_task: + task_id = celery.current_task.request.id + + log_data = { + "function": function, + "message": "deactivate entrust certificates", + "task_id": task_id, + } + + if task_id and is_task_active(function, task_id, None): + log_data["message"] = "Skipping task: Task is already active" + current_app.logger.debug(log_data) + return + + current_app.logger.debug(log_data) + try: + cli_certificate.deactivate_entrust_certificates() + except SoftTimeLimitExceeded: + log_data["message"] = "Time limit exceeded." + current_app.logger.error(log_data) + sentry.captureException() + metrics.send("celery.timeout", "counter", 1, metric_tags={"function": function}) + return + + metrics.send(f"{function}.success", "counter", 1) + return log_data diff --git a/lemur/common/defaults.py b/lemur/common/defaults.py index b9c88e49..d7b37292 100644 --- a/lemur/common/defaults.py +++ b/lemur/common/defaults.py @@ -95,9 +95,11 @@ def organization(cert): :return: """ try: - return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[ - 0 - ].value.strip() + o = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME) + if not o: + return None + + return o[0].value.strip() except Exception as e: sentry.captureException() current_app.logger.error("Unable to get organization! {0}".format(e)) @@ -110,9 +112,11 @@ def organizational_unit(cert): :return: """ try: - return cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME)[ - 0 - ].value.strip() + ou = cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATIONAL_UNIT_NAME) + if not ou: + return None + + return ou[0].value.strip() except Exception as e: sentry.captureException() current_app.logger.error("Unable to get organizational unit! {0}".format(e)) @@ -125,9 +129,11 @@ def country(cert): :return: """ try: - return cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME)[ - 0 - ].value.strip() + c = cert.subject.get_attributes_for_oid(x509.OID_COUNTRY_NAME) + if not c: + return None + + return c[0].value.strip() except Exception as e: sentry.captureException() current_app.logger.error("Unable to get country! {0}".format(e)) @@ -140,9 +146,11 @@ def state(cert): :return: """ try: - return cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME)[ - 0 - ].value.strip() + s = cert.subject.get_attributes_for_oid(x509.OID_STATE_OR_PROVINCE_NAME) + if not s: + return None + + return s[0].value.strip() except Exception as e: sentry.captureException() current_app.logger.error("Unable to get state! {0}".format(e)) @@ -155,9 +163,11 @@ def location(cert): :return: """ try: - return cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME)[ - 0 - ].value.strip() + loc = cert.subject.get_attributes_for_oid(x509.OID_LOCALITY_NAME) + if not loc: + return None + + return loc[0].value.strip() except Exception as e: sentry.captureException() current_app.logger.error("Unable to get location! {0}".format(e)) diff --git a/lemur/common/utils.py b/lemur/common/utils.py index c33722b2..19b256e8 100644 --- a/lemur/common/utils.py +++ b/lemur/common/utils.py @@ -9,6 +9,7 @@ import random import re import string +import pem import sqlalchemy from cryptography import x509 @@ -16,7 +17,7 @@ from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding -from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives.serialization import load_pem_private_key, Encoding, pkcs7 from flask_restful.reqparse import RequestParser from sqlalchemy import and_, func @@ -71,6 +72,23 @@ def parse_private_key(private_key): ) +def get_key_type_from_certificate(body): + """ + + Helper function to determine key type by pasrding given PEM certificate + + :param body: PEM string + :return: Key type string + """ + parsed_cert = parse_certificate(body) + if isinstance(parsed_cert.public_key(), rsa.RSAPublicKey): + return "RSA{key_size}".format( + key_size=parsed_cert.public_key().key_size + ) + elif isinstance(parsed_cert.public_key(), ec.EllipticCurvePublicKey): + return get_key_type_from_ec_curve(parsed_cert.public_key().curve.name) + + def split_pem(data): """ Split a string of several PEM payloads to a list of strings. @@ -114,6 +132,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 +179,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(), @@ -307,3 +358,19 @@ def find_matching_certificates_by_hash(cert, matching_certs): ): matching.append(c) return matching + + +def convert_pkcs7_bytes_to_pem(certs_pkcs7): + """ + Given a list of certificates in pkcs7 encoding (bytes), covert them into a list of PEM encoded files + :raises ValueError or ValidationError + :param certs_pkcs7: + :return: list of certs in PEM format + """ + + certificates = pkcs7.load_pem_pkcs7_certificates(certs_pkcs7) + certificates_pem = [] + for cert in certificates: + certificates_pem.append(pem.parse(cert.public_bytes(encoding=Encoding.PEM))[0]) + + return certificates_pem diff --git a/lemur/common/validators.py b/lemur/common/validators.py index e1dfe3c1..e004a971 100644 --- a/lemur/common/validators.py +++ b/lemur/common/validators.py @@ -22,7 +22,7 @@ def common_name(value): def sensitive_domain(domain): """ - Checks if user has the admin role, the domain does not match sensitive domains and whitelisted domain patterns. + Checks if user has the admin role, the domain does not match sensitive domains and allowed domain patterns. :param domain: domain name (str) :return: """ @@ -30,10 +30,10 @@ def sensitive_domain(domain): # User has permission, no need to check anything return - whitelist = current_app.config.get("LEMUR_WHITELISTED_DOMAINS", []) - if whitelist and not any(re.match(pattern, domain) for pattern in whitelist): + allowlist = current_app.config.get("LEMUR_ALLOWED_DOMAINS", []) + if allowlist and not any(re.match(pattern, domain) for pattern in allowlist): raise ValidationError( - "Domain {0} does not match whitelisted domain patterns. " + "Domain {0} does not match allowed domain patterns. " "Contact an administrator to issue the certificate.".format(domain) ) diff --git a/lemur/dns_providers/models.py b/lemur/dns_providers/models.py index eb8cdff9..7ad51308 100644 --- a/lemur/dns_providers/models.py +++ b/lemur/dns_providers/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, text, Text +from sqlalchemy import Column, Integer, String, text from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.orm import relationship from sqlalchemy_utils import ArrowType @@ -12,7 +12,7 @@ class DnsProvider(db.Model): __tablename__ = "dns_providers" id = Column(Integer(), primary_key=True) name = Column(String(length=256), unique=True, nullable=True) - description = Column(Text(), nullable=True) + description = Column(String(length=1024), nullable=True) provider_type = Column(String(length=256), nullable=True) credentials = Column(Vault, nullable=True) api_endpoint = Column(String(length=256), nullable=True) diff --git a/lemur/dns_providers/schemas.py b/lemur/dns_providers/schemas.py index 05b6471d..af9377b3 100644 --- a/lemur/dns_providers/schemas.py +++ b/lemur/dns_providers/schemas.py @@ -8,7 +8,7 @@ class DnsProvidersNestedOutputSchema(LemurOutputSchema): __envelope__ = False id = fields.Integer() name = fields.String() - providerType = fields.String() + provider_type = fields.String() description = fields.String() credentials = fields.String() api_endpoint = fields.String() diff --git a/lemur/manage.py b/lemur/manage.py index 2fbbe893..e53f8bd6 100755 --- a/lemur/manage.py +++ b/lemur/manage.py @@ -95,7 +95,7 @@ LEMUR_TOKEN_SECRET = '{secret_token}' LEMUR_ENCRYPTION_KEYS = '{encryption_key}' # List of domain regular expressions that non-admin users can issue -LEMUR_WHITELISTED_DOMAINS = [] +LEMUR_ALLOWED_DOMAINS = [] # Mail Server diff --git a/lemur/migrations/env.py b/lemur/migrations/env.py index 008a9952..91fa5fcb 100644 --- a/lemur/migrations/env.py +++ b/lemur/migrations/env.py @@ -20,8 +20,9 @@ fileConfig(config.config_file_name) # target_metadata = mymodel.Base.metadata from flask import current_app +db_url_escaped = current_app.config.get('SQLALCHEMY_DATABASE_URI').replace('%', '%%') config.set_main_option( - "sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") + "sqlalchemy.url", db_url_escaped ) target_metadata = current_app.extensions["migrate"].db.metadata @@ -67,7 +68,8 @@ def run_migrations_online(): context.configure( connection=connection, target_metadata=target_metadata, - **current_app.extensions["migrate"].configure_args + **current_app.extensions["migrate"].configure_args, + compare_type=True ) try: diff --git a/lemur/migrations/versions/434c29e40511_.py b/lemur/migrations/versions/434c29e40511_.py new file mode 100644 index 00000000..677be1d9 --- /dev/null +++ b/lemur/migrations/versions/434c29e40511_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: 434c29e40511 +Revises: 8323a5ea723a +Create Date: 2020-09-11 17:24:51.344585 + +""" + +# revision identifiers, used by Alembic. +revision = '434c29e40511' +down_revision = '8323a5ea723a' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('certificates', sa.Column('key_type', sa.String(length=128), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('certificates', 'key_type') + # ### end Alembic commands ### diff --git a/lemur/migrations/versions/c301c59688d2_.py b/lemur/migrations/versions/c301c59688d2_.py new file mode 100644 index 00000000..669c934f --- /dev/null +++ b/lemur/migrations/versions/c301c59688d2_.py @@ -0,0 +1,114 @@ +""" + +This database upgrade updates the key_type information for either +still valid or expired certificates in the last 30 days. For RSA +keys, the algorithm is determined based on the key length. For +the rest of the keys, the certificate body is parsed to determine +the exact key_type information. + +Each individual DB change is explicitly committed, and the respective +log is added to a file named db_upgrade.log in the current working +directory. Any error encountered while parsing a certificate will +also be logged along with the certificate ID. If faced with any issue +while running this upgrade, there is no harm in re-running the upgrade. +Each run processes only rows for which key_type information is not yet +determined. + +A successful complete run will end up updating the Alembic Version to +the new Revision ID c301c59688d2. Currently, Lemur supports only RSA +and ECC certificates. This could be a long-running job depending upon +the number of DB entries it may process. + +Revision ID: c301c59688d2 +Revises: 434c29e40511 +Create Date: 2020-09-21 14:28:50.757998 + +""" + +# revision identifiers, used by Alembic. +revision = 'c301c59688d2' +down_revision = '434c29e40511' + +from alembic import op +from sqlalchemy.sql import text +from lemur.common import utils +import time +import datetime + +log_file = open('db_upgrade.log', 'a') + + +def upgrade(): + log_file.write("\n*** Starting new run(%s) ***\n" % datetime.datetime.now()) + start_time = time.time() + + # Update RSA keys using the key length information + update_key_type_rsa(1024) + update_key_type_rsa(2048) + update_key_type_rsa(4096) + + # Process remaining certificates. Though below method does not make any assumptions, most of the remaining ones should be ECC certs. + update_key_type() + + log_file.write("--- Total %s seconds ---\n" % (time.time() - start_time)) + log_file.close() + + +def downgrade(): + # Change key type column back to null + # Going back 32 days instead of 31 to make sure no certificates are skipped + stmt = text( + "update certificates set key_type=null where not_after > CURRENT_DATE - 32" + ) + op.execute(stmt) + + +""" + Helper methods performing updates for RSA and rest of the keys +""" + + +def update_key_type_rsa(bits): + log_file.write("Processing certificate with key type RSA %s\n" % bits) + + stmt = text( + f"update certificates set key_type='RSA{bits}' where bits={bits} and not_after > CURRENT_DATE - 31 and key_type is null" + ) + log_file.write("Query: %s\n" % stmt) + + start_time = time.time() + op.execute(stmt) + commit() + + log_file.write("--- %s seconds ---\n" % (time.time() - start_time)) + + +def update_key_type(): + conn = op.get_bind() + start_time = time.time() + + # Loop through all certificates that are valid today or expired in the last 30 days. + for cert_id, body in conn.execute( + text( + "select id, body from certificates where not_after > CURRENT_DATE - 31 and key_type is null") + ): + try: + cert_key_type = utils.get_key_type_from_certificate(body) + except ValueError as e: + log_file.write("Error in processing certificate - ID: %s Error: %s \n" % (cert_id, str(e))) + else: + log_file.write("Processing certificate - ID: %s key_type: %s\n" % (cert_id, cert_key_type)) + stmt = text( + "update certificates set key_type=:key_type where id=:id" + ) + stmt = stmt.bindparams(key_type=cert_key_type, id=cert_id) + op.execute(stmt) + + commit() + + log_file.write("--- %s seconds ---\n" % (time.time() - start_time)) + + +def commit(): + stmt = text("commit") + op.execute(stmt) diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 82db7b6e..3928689e 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -8,6 +8,7 @@ .. moduleauthor:: Kevin Glisson """ +import sys from collections import defaultdict from datetime import timedelta from itertools import groupby @@ -29,7 +30,7 @@ from lemur.plugins.utils import get_plugin_option def get_certificates(exclude=None): """ - Finds all certificates that are eligible for notifications. + Finds all certificates that are eligible for expiration notifications. :param exclude: :return: """ @@ -41,6 +42,7 @@ def get_certificates(exclude=None): .filter(Certificate.not_after <= max) .filter(Certificate.notify == True) .filter(Certificate.expired == False) + .filter(Certificate.revoked == False) ) # noqa exclude_conditions = [] @@ -61,7 +63,8 @@ def get_certificates(exclude=None): def get_eligible_certificates(exclude=None): """ - Finds all certificates that are eligible for certificate expiration. + Finds all certificates that are eligible for certificate expiration notification. + Returns the set of all eligible certificates, grouped by owner, with a list of applicable notifications. :param exclude: :return: """ @@ -86,21 +89,31 @@ def get_eligible_certificates(exclude=None): return certificates -def send_notification(event_type, data, targets, notification): +def send_plugin_notification(event_type, data, recipients, notification): """ Executes the plugin and handles failure. :param event_type: :param data: - :param targets: + :param recipients: :param notification: :return: """ + function = f"{__name__}.{sys._getframe().f_code.co_name}" + log_data = { + "function": function, + "message": f"Sending expiration notification for to recipients {recipients}", + "notification_type": "expiration", + "certificate_targets": recipients, + } status = FAILURE_METRIC_STATUS try: - notification.plugin.send(event_type, data, targets, notification.options) + current_app.logger.debug(log_data) + notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: + log_data["message"] = f"Unable to send {event_type} notification to recipients {recipients}" + current_app.logger.error(log_data, exc_info=True) sentry.captureException() metrics.send( @@ -140,36 +153,27 @@ def send_expiration_notifications(exclude): notification_data.append(cert_data) security_data.append(cert_data) - if send_notification( - "expiration", notification_data, [owner], notification + if send_default_notification( + "expiration", notification_data, [owner], notification.options ): success += 1 else: failure += 1 - notification_recipient = get_plugin_option( - "recipients", notification.options - ) - if notification_recipient: - notification_recipient = notification_recipient.split(",") - # removing owner and security_email from notification_recipient - notification_recipient = [i for i in notification_recipient if i not in security_email and i != owner] + recipients = notification.plugin.filter_recipients(notification.options, security_email + [owner]) - if ( - notification_recipient + if send_plugin_notification( + "expiration", + notification_data, + recipients, + notification, ): - if send_notification( - "expiration", - notification_data, - notification_recipient, - notification, - ): - success += 1 - else: - failure += 1 + success += 1 + else: + failure += 1 - if send_notification( - "expiration", security_data, security_email, notification + if send_default_notification( + "expiration", security_data, security_email, notification.options ): success += 1 else: @@ -178,107 +182,86 @@ def send_expiration_notifications(exclude): return success, failure -def send_rotation_notification(certificate, notification_plugin=None): +def send_default_notification(notification_type, data, targets, notification_options=None): """ - Sends a report to certificate owners when their certificate has been - rotated. + Sends a report to the specified target via the default notification plugin. Applicable for any notification_type. + At present, "default" means email, as the other notification plugins do not support dynamically configured targets. - :param certificate: - :param notification_plugin: + :param notification_type: + :param data: + :param targets: + :param notification_options: :return: """ + function = f"{__name__}.{sys._getframe().f_code.co_name}" + log_data = { + "function": function, + "message": f"Sending notification for certificate data {data}", + "notification_type": notification_type, + } status = FAILURE_METRIC_STATUS - if not notification_plugin: - notification_plugin = plugins.get( - current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN") - ) - - data = certificate_notification_output_schema.dump(certificate).data + notification_plugin = plugins.get( + current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification") + ) try: - notification_plugin.send("rotation", data, [data["owner"]]) + current_app.logger.debug(log_data) + # we need the notification.options here because the email templates utilize the interval/unit info + notification_plugin.send(notification_type, data, targets, notification_options) status = SUCCESS_METRIC_STATUS except Exception as e: - current_app.logger.error( - "Unable to send notification to {}.".format(data["owner"]), exc_info=True - ) + log_data["message"] = f"Unable to send {notification_type} notification for certificate data {data} " \ + f"to target {targets}" + current_app.logger.error(log_data, exc_info=True) sentry.captureException() metrics.send( "notification", "counter", 1, - metric_tags={"status": status, "event_type": "rotation"}, + metric_tags={"status": status, "event_type": notification_type}, ) if status == SUCCESS_METRIC_STATUS: return True +def send_rotation_notification(certificate): + data = certificate_notification_output_schema.dump(certificate).data + return send_default_notification("rotation", data, [data["owner"]]) + + def send_pending_failure_notification( - pending_cert, notify_owner=True, notify_security=True, notification_plugin=None + pending_cert, notify_owner=True, notify_security=True ): """ Sends a report to certificate owners when their pending certificate failed to be created. :param pending_cert: - :param notification_plugin: + :param notify_owner: + :param notify_security: :return: """ - status = FAILURE_METRIC_STATUS - - if not notification_plugin: - notification_plugin = plugins.get( - current_app.config.get( - "LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification" - ) - ) data = pending_certificate_output_schema.dump(pending_cert).data data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL") + notify_owner_success = False if notify_owner: - try: - notification_plugin.send("failed", data, [data["owner"]], pending_cert) - status = SUCCESS_METRIC_STATUS - except Exception as e: - current_app.logger.error( - "Unable to send pending failure notification to {}.".format( - data["owner"] - ), - exc_info=True, - ) - sentry.captureException() + notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert) + notify_security_success = False if notify_security: - try: - notification_plugin.send( - "failed", data, data["security_email"], pending_cert - ) - status = SUCCESS_METRIC_STATUS - except Exception as e: - current_app.logger.error( - "Unable to send pending failure notification to " - "{}.".format(data["security_email"]), - exc_info=True, - ) - sentry.captureException() + notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert) - metrics.send( - "notification", - "counter", - 1, - metric_tags={"status": status, "event_type": "rotation"}, - ) - - if status == SUCCESS_METRIC_STATUS: - return True + return notify_owner_success or notify_security_success def needs_notification(certificate): """ - Determine if notifications for a given certificate should - currently be sent + Determine if notifications for a given certificate should currently be sent. + For each notification configured for the cert, verifies it is active, properly configured, + and that the configured expiration period is currently met. :param certificate: :return: @@ -290,7 +273,7 @@ def needs_notification(certificate): for notification in certificate.notifications: if not notification.active or not notification.options: - return + continue interval = get_plugin_option("interval", notification.options) unit = get_plugin_option("unit", notification.options) @@ -306,9 +289,8 @@ def needs_notification(certificate): else: raise Exception( - "Invalid base unit for expiration interval: {0}".format(unit) + f"Invalid base unit for expiration interval: {unit}" ) - if days == interval: notifications.append(notification) return notifications diff --git a/lemur/plugins/bases/notification.py b/lemur/plugins/bases/notification.py index 730f68be..0da0dad2 100644 --- a/lemur/plugins/bases/notification.py +++ b/lemur/plugins/bases/notification.py @@ -20,6 +20,15 @@ class NotificationPlugin(Plugin): def send(self, notification_type, message, targets, options, **kwargs): raise NotImplementedError + def filter_recipients(self, options, excluded_recipients): + """ + Given a set of options (which should include configured recipient info), filters out recipients that + we do NOT want to notify. + + For any notification types where recipients can't be dynamically modified, this returns an empty list. + """ + return [] + class ExpirationNotificationPlugin(NotificationPlugin): """ @@ -50,5 +59,5 @@ class ExpirationNotificationPlugin(NotificationPlugin): def options(self): return self.default_options + self.additional_options - def send(self, notification_type, message, targets, options, **kwargs): + def send(self, notification_type, message, excluded_targets, options, **kwargs): raise NotImplementedError diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py index 9177d6e8..2a09dbdf 100644 --- a/lemur/plugins/lemur_acme/plugin.py +++ b/lemur/plugins/lemur_acme/plugin.py @@ -32,6 +32,7 @@ from lemur.extensions import metrics, sentry from lemur.plugins import lemur_acme as acme from lemur.plugins.bases import IssuerPlugin from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns +from lemur.authorities import service as authorities_service from retrying import retry @@ -240,6 +241,7 @@ class AcmeHandler(object): existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR")) if existing_key and existing_regr: + current_app.logger.debug("Reusing existing ACME account") # Reuse the same account for each certificate issuance key = jose.JWK.json_loads(existing_key) regr = messages.RegistrationResource.json_loads(existing_regr) @@ -253,6 +255,7 @@ class AcmeHandler(object): # Create an account for each certificate issuance key = jose.JWKRSA(key=generate_private_key("RSA2048")) + current_app.logger.debug("Creating a new ACME account") current_app.logger.debug( "Connecting with directory at {0}".format(directory_url) ) @@ -262,6 +265,27 @@ class AcmeHandler(object): registration = client.new_account_and_tos( messages.NewRegistration.from_data(email=email) ) + + # if store_account is checked, add the private_key and registration resources to the options + if options['store_account']: + new_options = json.loads(authority.options) + # the key returned by fields_to_partial_json is missing the key type, so we add it manually + key_dict = key.fields_to_partial_json() + key_dict["kty"] = "RSA" + acme_private_key = { + "name": "acme_private_key", + "value": json.dumps(key_dict) + } + new_options.append(acme_private_key) + + acme_regr = { + "name": "acme_regr", + "value": json.dumps({"body": {}, "uri": registration.uri}) + } + new_options.append(acme_regr) + + authorities_service.update_options(authority.id, options=json.dumps(new_options)) + current_app.logger.debug("Connected: {0}".format(registration.uri)) return client, registration @@ -467,6 +491,13 @@ class ACMEIssuerPlugin(IssuerPlugin): "validation": "/^-----BEGIN CERTIFICATE-----/", "helpMessage": "Certificate to use", }, + { + "name": "store_account", + "type": "bool", + "required": False, + "helpMessage": "Disable to create a new account for each ACME request", + "default": False, + } ] def __init__(self, *args, **kwargs): diff --git a/lemur/plugins/lemur_acme/tests/test_acme.py b/lemur/plugins/lemur_acme/tests/test_acme.py index 8320a2de..ab246563 100644 --- a/lemur/plugins/lemur_acme/tests/test_acme.py +++ b/lemur/plugins/lemur_acme/tests/test_acme.py @@ -1,8 +1,10 @@ import unittest from unittest.mock import patch, Mock +import josepy as jose from cryptography.x509 import DNSName from lemur.plugins.lemur_acme import plugin +from lemur.common.utils import generate_private_key from mock import MagicMock @@ -165,11 +167,65 @@ class TestAcme(unittest.TestCase): with self.assertRaises(Exception): self.acme.setup_acme_client(mock_authority) + @patch("lemur.plugins.lemur_acme.plugin.jose.JWK.json_loads") @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") @patch("lemur.plugins.lemur_acme.plugin.current_app") - def test_setup_acme_client_success(self, mock_current_app, mock_acme): + def test_setup_acme_client_success_load_account_from_authority(self, mock_current_app, mock_acme, mock_key_json_load): mock_authority = Mock() - mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]' + mock_authority.id = 2 + mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \ + '{"name": "store_account", "value": true},' \ + '{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, ' \ + '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]' + mock_client = Mock() + mock_acme.return_value = mock_client + mock_current_app.config = {} + + mock_key_json_load.return_value = jose.JWKRSA(key=generate_private_key("RSA2048")) + + result_client, result_registration = self.acme.setup_acme_client(mock_authority) + + mock_acme.new_account_and_tos.assert_not_called() + assert result_client + assert not result_registration + + @patch("lemur.plugins.lemur_acme.plugin.jose.JWKRSA.fields_to_partial_json") + @patch("lemur.plugins.lemur_acme.plugin.authorities_service") + @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") + @patch("lemur.plugins.lemur_acme.plugin.current_app") + def test_setup_acme_client_success_store_new_account(self, mock_current_app, mock_acme, mock_authorities_service, + mock_key_generation): + mock_authority = Mock() + mock_authority.id = 2 + mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \ + '{"name": "store_account", "value": true}]' + mock_client = Mock() + mock_registration = Mock() + mock_registration.uri = "http://test.com" + mock_client.register = mock_registration + mock_client.agree_to_tos = Mock(return_value=True) + mock_client.new_account_and_tos.return_value = mock_registration + mock_acme.return_value = mock_client + mock_current_app.config = {} + + mock_key_generation.return_value = {"n": "PwIOkViO"} + + mock_authorities_service.update_options = Mock(return_value=True) + + self.acme.setup_acme_client(mock_authority) + + mock_authorities_service.update_options.assert_called_with(2, options='[{"name": "mock_name", "value": "mock_value"}, ' + '{"name": "store_account", "value": true}, ' + '{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, ' + '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]') + + @patch("lemur.plugins.lemur_acme.plugin.authorities_service") + @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2") + @patch("lemur.plugins.lemur_acme.plugin.current_app") + def test_setup_acme_client_success(self, mock_current_app, mock_acme, mock_authorities_service): + mock_authority = Mock() + mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \ + '{"name": "store_account", "value": false}]' mock_client = Mock() mock_registration = Mock() mock_registration.uri = "http://test.com" @@ -178,6 +234,7 @@ class TestAcme(unittest.TestCase): mock_acme.return_value = mock_client mock_current_app.config = {} result_client, result_registration = self.acme.setup_acme_client(mock_authority) + mock_authorities_service.update_options.assert_not_called() assert result_client assert result_registration diff --git a/lemur/plugins/lemur_aws/plugin.py b/lemur/plugins/lemur_aws/plugin.py index 8692348a..1be641b0 100644 --- a/lemur/plugins/lemur_aws/plugin.py +++ b/lemur/plugins/lemur_aws/plugin.py @@ -32,13 +32,14 @@ .. moduleauthor:: Mikhail Khodorovskiy .. moduleauthor:: Harm Weites """ + from acme.errors import ClientError from flask import current_app -from lemur.extensions import sentry, metrics -from lemur.plugins import lemur_aws as aws +from lemur.extensions import sentry, metrics +from lemur.plugins import lemur_aws as aws, ExpirationNotificationPlugin from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin -from lemur.plugins.lemur_aws import iam, s3, elb, ec2 +from lemur.plugins.lemur_aws import iam, s3, elb, ec2, sns def get_region_from_dns(dns): @@ -406,3 +407,51 @@ class S3DestinationPlugin(ExportDestinationPlugin): self.get_option("encrypt", options), account_number=self.get_option("accountNumber", options), ) + + +class SNSNotificationPlugin(ExpirationNotificationPlugin): + title = "AWS SNS" + slug = "aws-sns" + description = "Sends notifications to AWS SNS" + version = aws.VERSION + + author = "Jasmine Schladen " + author_url = "https://github.com/Netflix/lemur" + + additional_options = [ + { + "name": "accountNumber", + "type": "str", + "required": True, + "validation": "[0-9]{12}", + "helpMessage": "A valid AWS account number with permission to access the SNS topic", + }, + { + "name": "region", + "type": "str", + "required": True, + "validation": "[0-9a-z\\-]{1,25}", + "helpMessage": "Region in which the SNS topic is located, e.g. \"us-east-1\"", + }, + { + "name": "topicName", + "type": "str", + "required": True, + # base topic name is 1-256 characters (alphanumeric plus underscore and hyphen) + "validation": "^[a-zA-Z0-9_\\-]{1,256}$", + "helpMessage": "The name of the topic to use for expiration notifications", + } + ] + + def send(self, notification_type, message, excluded_targets, options, **kwargs): + """ + While we receive a `targets` parameter here, it is unused, as the SNS topic is pre-configured in the + plugin configuration, and can't reasonably be changed dynamically. + """ + + topic_arn = f"arn:aws:sns:{self.get_option('region', options)}:" \ + f"{self.get_option('accountNumber', options)}:" \ + f"{self.get_option('topicName', options)}" + + current_app.logger.info(f"Publishing {notification_type} notification to topic {topic_arn}") + sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options)) diff --git a/lemur/plugins/lemur_aws/sns.py b/lemur/plugins/lemur_aws/sns.py new file mode 100644 index 00000000..c98bbc0c --- /dev/null +++ b/lemur/plugins/lemur_aws/sns.py @@ -0,0 +1,55 @@ +""" +.. module: lemur.plugins.lemur_aws.sts + :platform: Unix + :copyright: (c) 2020 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Jasmine Schladen +""" +import json + +import arrow +import boto3 +from flask import current_app + + +def publish(topic_arn, certificates, notification_type, **kwargs): + sns_client = boto3.client("sns", **kwargs) + message_ids = {} + for certificate in certificates: + message_ids[certificate["name"]] = publish_single(sns_client, topic_arn, certificate, notification_type) + + return message_ids + + +def publish_single(sns_client, topic_arn, certificate, notification_type): + response = sns_client.publish( + TopicArn=topic_arn, + Message=format_message(certificate, notification_type), + ) + + response_code = response["ResponseMetadata"]["HTTPStatusCode"] + if response_code != 200: + raise Exception(f"Failed to publish {notification_type} notification to SNS topic {topic_arn}. " + f"SNS response: {response_code} {response}") + + current_app.logger.info(f"AWS SNS message published to topic [{topic_arn}] with message ID {response['MessageId']}") + current_app.logger.debug(f"AWS SNS message published to topic [{topic_arn}]: [{response}]") + + return response["MessageId"] + + +def create_certificate_url(name): + return "https://{hostname}/#/certificates/{name}".format( + hostname=current_app.config.get("LEMUR_HOSTNAME"), name=name + ) + + +def format_message(certificate, notification_type): + json_message = { + "notification_type": notification_type, + "certificate_name": certificate["name"], + "expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-ddTHH:mm:ss"), # 2047-12-T22:00:00 + "endpoints_detected": len(certificate["endpoints"]), + "details": create_certificate_url(certificate["name"]) + } + return json.dumps(json_message) diff --git a/lemur/plugins/lemur_aws/tests/test_sns.py b/lemur/plugins/lemur_aws/tests/test_sns.py new file mode 100644 index 00000000..ce05c33c --- /dev/null +++ b/lemur/plugins/lemur_aws/tests/test_sns.py @@ -0,0 +1,120 @@ +import json +from datetime import timedelta + +import arrow +import boto3 +from moto import mock_sns, mock_sqs, mock_ses + +from lemur.certificates.schemas import certificate_notification_output_schema +from lemur.plugins.lemur_aws.sns import format_message +from lemur.plugins.lemur_aws.sns import publish +from lemur.tests.factories import NotificationFactory, CertificateFactory +from lemur.tests.test_messaging import verify_sender_email + + +@mock_sns() +def test_format(certificate, endpoint): + data = [certificate_notification_output_schema.dump(certificate).data] + + for certificate in data: + expected_message = { + "notification_type": "expiration", + "certificate_name": certificate["name"], + "expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-ddTHH:mm:ss"), + "endpoints_detected": 0, + "details": "https://lemur.example.com/#/certificates/{name}".format(name=certificate["name"]) + } + assert expected_message == json.loads(format_message(certificate, "expiration")) + + +@mock_sns() +@mock_sqs() +def create_and_subscribe_to_topic(): + sns_client = boto3.client("sns", region_name="us-east-1") + topic_arn = sns_client.create_topic(Name='lemursnstest')["TopicArn"] + + sqs_client = boto3.client("sqs", region_name="us-east-1") + queue = sqs_client.create_queue(QueueName="lemursnstestqueue") + queue_url = queue["QueueUrl"] + queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url)["Attributes"]["QueueArn"] + sns_client.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn) + + return [topic_arn, sqs_client, queue_url] + + +@mock_sns() +@mock_sqs() +def test_publish(certificate, endpoint): + data = [certificate_notification_output_schema.dump(certificate).data] + + topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic() + + message_ids = publish(topic_arn, data, "expiration", region_name="us-east-1") + assert len(message_ids) == len(data) + received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"] + + for certificate in data: + expected_message_id = message_ids[certificate["name"]] + actual_message = next( + (m for m in received_messages if json.loads(m["Body"])["MessageId"] == expected_message_id), None) + assert json.loads(actual_message["Body"])["Message"] == format_message(certificate, "expiration") + + +def get_options(): + return [ + {"name": "interval", "value": 10}, + {"name": "unit", "value": "days"}, + {"name": "region", "value": "us-east-1"}, + {"name": "accountNumber", "value": "123456789012"}, + {"name": "topicName", "value": "lemursnstest"}, + ] + + +@mock_sns() +@mock_sqs() +@mock_ses() # because email notifications are also sent +def test_send_expiration_notification(): + from lemur.notifications.messaging import send_expiration_notifications + + verify_sender_email() # emails are sent to owner and security; SNS only used for configured notification + topic_arn, sqs_client, queue_url = create_and_subscribe_to_topic() + + notification = NotificationFactory(plugin_name="aws-sns") + notification.options = get_options() + + now = arrow.utcnow() + in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future + + certificate = CertificateFactory() + certificate.not_after = in_ten_days + certificate.notifications.append(notification) + + assert send_expiration_notifications([]) == (3, 0) # owner, SNS, and security + + received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"] + assert len(received_messages) == 1 + expected_message = format_message(certificate_notification_output_schema.dump(certificate).data, "expiration") + actual_message = json.loads(received_messages[0]["Body"])["Message"] + assert actual_message == expected_message + + +# Currently disabled as the SNS plugin doesn't support this type of notification +# def test_send_rotation_notification(endpoint, source_plugin): +# from lemur.notifications.messaging import send_rotation_notification +# from lemur.deployment.service import rotate_certificate +# +# notification = NotificationFactory(plugin_name="aws-sns") +# notification.options = get_options() +# +# new_certificate = CertificateFactory() +# rotate_certificate(endpoint, new_certificate) +# assert endpoint.certificate == new_certificate +# +# assert send_rotation_notification(new_certificate) + + +# Currently disabled as the SNS plugin doesn't support this type of notification +# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin): +# from lemur.notifications.messaging import send_pending_failure_notification +# +# assert send_pending_failure_notification(pending_certificate) diff --git a/lemur/plugins/lemur_digicert/plugin.py b/lemur/plugins/lemur_digicert/plugin.py index fd8c4e2d..ec3a0792 100644 --- a/lemur/plugins/lemur_digicert/plugin.py +++ b/lemur/plugins/lemur_digicert/plugin.py @@ -18,9 +18,10 @@ import json import arrow import pem import requests +import sys from cryptography import x509 -from flask import current_app -from lemur.common.utils import validate_conf +from flask import current_app, g +from lemur.common.utils import validate_conf, convert_pkcs7_bytes_to_pem from lemur.extensions import metrics from lemur.plugins import lemur_digicert as digicert from lemur.plugins.bases import IssuerPlugin, SourcePlugin @@ -36,7 +37,13 @@ def log_status_code(r, *args, **kwargs): :param kwargs: :return: """ + log_data = { + "reason": (r.reason if r.reason else ""), + "status_code": r.status_code, + "url": (r.url if r.url else ""), + } metrics.send("digicert_status_code_{}".format(r.status_code), "counter", 1) + current_app.logger.info(log_data) def signature_hash(signing_algorithm): @@ -129,6 +136,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 +164,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) @@ -164,11 +177,10 @@ def map_cis_fields(options, csr): "csr": csr, "signature_hash": signature_hash(options.get("signing_algorithm")), "validity": { - "valid_to": validity_end.format("YYYY-MM-DDTHH:MM") + "Z" + "valid_to": validity_end.format("YYYY-MM-DDTHH:mm:ss") + "Z" }, "organization": { "name": options["organization"], - "units": [options["organizational_unit"]], }, } # possibility to default to a SIGNING_ALGORITHM for a given profile @@ -179,6 +191,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. @@ -186,7 +210,7 @@ def handle_response(response): :return: """ if response.status_code > 399: - raise Exception(response.json()["errors"][0]["message"]) + raise Exception("DigiCert rejected request with the error:" + response.json()["errors"][0]["message"]) return response.json() @@ -197,10 +221,17 @@ def handle_cis_response(response): :param response: :return: """ - if response.status_code > 399: - raise Exception(response.text) + if response.status_code == 404: + raise Exception("DigiCert: order not in issued state") + elif response.status_code == 406: + raise Exception("DigiCert: wrong header request format") + elif response.status_code > 399: + raise Exception("DigiCert rejected request with the error:" + response.text) - return response.json() + if response.url.endswith("download"): + return response.content + else: + return response.json() @retry(stop_max_attempt_number=10, wait_fixed=10000) @@ -216,15 +247,16 @@ def get_certificate_id(session, base_url, order_id): @retry(stop_max_attempt_number=10, wait_fixed=10000) def get_cis_certificate(session, base_url, order_id): - """Retrieve certificate order id from Digicert API.""" - certificate_url = "{0}/platform/cis/certificate/{1}".format(base_url, order_id) - session.headers.update({"Accept": "application/x-pem-file"}) + """Retrieve certificate order id from Digicert API, including the chain""" + certificate_url = "{0}/platform/cis/certificate/{1}/download".format(base_url, order_id) + session.headers.update({"Accept": "application/x-pkcs7-certificates"}) response = session.get(certificate_url) + response_content = handle_cis_response(response) - if response.status_code == 404: - raise Exception("Order not in issued state.") - - return response.content + cert_chain_pem = convert_pkcs7_bytes_to_pem(response_content) + if len(cert_chain_pem) < 3: + raise Exception("Missing the certificate chain") + return cert_chain_pem class DigiCertSourcePlugin(SourcePlugin): @@ -428,7 +460,6 @@ class DigiCertCISSourcePlugin(SourcePlugin): "DIGICERT_CIS_API_KEY", "DIGICERT_CIS_URL", "DIGICERT_CIS_ROOTS", - "DIGICERT_CIS_INTERMEDIATES", "DIGICERT_CIS_PROFILE_NAMES", ] validate_conf(current_app, required_vars) @@ -503,7 +534,6 @@ class DigiCertCISIssuerPlugin(IssuerPlugin): "DIGICERT_CIS_API_KEY", "DIGICERT_CIS_URL", "DIGICERT_CIS_ROOTS", - "DIGICERT_CIS_INTERMEDIATES", "DIGICERT_CIS_PROFILE_NAMES", ] @@ -533,22 +563,15 @@ class DigiCertCISIssuerPlugin(IssuerPlugin): data = handle_cis_response(response) # retrieve certificate - certificate_pem = get_cis_certificate(self.session, base_url, data["id"]) + certificate_chain_pem = get_cis_certificate(self.session, base_url, data["id"]) self.session.headers.pop("Accept") - end_entity = pem.parse(certificate_pem)[0] + end_entity = certificate_chain_pem[0] + intermediate = certificate_chain_pem[1] - if "ECC" in issuer_options["key_type"]: - return ( - "\n".join(str(end_entity).splitlines()), - current_app.config.get("DIGICERT_ECC_CIS_INTERMEDIATES", {}).get(issuer_options['authority'].name), - data["id"], - ) - - # By default return RSA return ( "\n".join(str(end_entity).splitlines()), - current_app.config.get("DIGICERT_CIS_INTERMEDIATES", {}).get(issuer_options['authority'].name), + "\n".join(str(intermediate).splitlines()), data["id"], ) diff --git a/lemur/plugins/lemur_digicert/tests/test_digicert.py b/lemur/plugins/lemur_digicert/tests/test_digicert.py index 4abfcf54..fd07ea2b 100644 --- a/lemur/plugins/lemur_digicert/tests/test_digicert.py +++ b/lemur/plugins/lemur_digicert/tests/test_digicert.py @@ -121,9 +121,9 @@ def test_map_cis_fields_with_validity_years(mock_current_app, authority): "csr": CSR_STR, "additional_dns_names": names, "signature_hash": "sha256", - "organization": {"name": "Example, Inc.", "units": ["Example Org"]}, + "organization": {"name": "Example, Inc."}, "validity": { - "valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:MM") + "Z" + "valid_to": arrow.get(2018, 11, 3).format("YYYY-MM-DDTHH:mm:ss") + "Z" }, "profile_name": None, } @@ -157,9 +157,9 @@ def test_map_cis_fields_with_validity_end_and_start(mock_current_app, app, autho "csr": CSR_STR, "additional_dns_names": names, "signature_hash": "sha256", - "organization": {"name": "Example, Inc.", "units": ["Example Org"]}, + "organization": {"name": "Example, Inc."}, "validity": { - "valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:MM") + "Z" + "valid_to": arrow.get(2017, 5, 7).format("YYYY-MM-DDTHH:mm:ss") + "Z" }, "profile_name": None, } diff --git a/lemur/plugins/lemur_email/plugin.py b/lemur/plugins/lemur_email/plugin.py index 241aa1b0..5b9c188e 100644 --- a/lemur/plugins/lemur_email/plugin.py +++ b/lemur/plugins/lemur_email/plugin.py @@ -17,16 +17,19 @@ from lemur.plugins.bases import ExpirationNotificationPlugin from lemur.plugins import lemur_email as email from lemur.plugins.lemur_email.templates.config import env +from lemur.plugins.utils import get_plugin_option -def render_html(template_name, message): +def render_html(template_name, options, certificates): """ Renders the html for our email notification. :param template_name: - :param message: + :param options: + :param certificates: :return: """ + message = {"options": options, "certificates": certificates} template = env.get_template("{}.html".format(template_name)) return template.render( dict(message=message, hostname=current_app.config.get("LEMUR_HOSTNAME")) @@ -100,8 +103,7 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin): subject = "Lemur: {0} Notification".format(notification_type.capitalize()) - data = {"options": options, "certificates": message} - body = render_html(notification_type, data) + body = render_html(notification_type, options, message) s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower() @@ -110,3 +112,13 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin): elif s_type == "smtp": send_via_smtp(subject, body, targets) + + @staticmethod + def filter_recipients(options, excluded_recipients, **kwargs): + notification_recipients = get_plugin_option("recipients", options) + if notification_recipients: + notification_recipients = notification_recipients.split(",") + # removing owner and security_email from notification_recipient + notification_recipients = [i for i in notification_recipients if i not in excluded_recipients] + + return notification_recipients diff --git a/lemur/plugins/lemur_email/templates/rotation.html b/lemur/plugins/lemur_email/templates/rotation.html index 521eb327..9ce7ff33 100644 --- a/lemur/plugins/lemur_email/templates/rotation.html +++ b/lemur/plugins/lemur_email/templates/rotation.html @@ -83,12 +83,12 @@ - {{ certificate.name }} + {{ message.certificates.name }}
-
{{ certificate.owner }} -
{{ certificate.validityEnd | time }} - Details +
{{ message.certificates.owner }} +
{{ message.certificates.validityEnd | time }} + Details
@@ -110,12 +110,12 @@ - {{ certificate.replacedBy[0].name }} + {{ message.certificates.name }}
-
{{ certificate.replacedBy[0].owner }} -
{{ certificate.replacedBy[0].validityEnd | time }} - Details +
{{ message.certificates.owner }} +
{{ message.certificates.validityEnd | time }} + Details
@@ -133,7 +133,7 @@ - {% for endpoint in certificate.endpoints %} + {% for endpoint in message.certificates.endpoints %} diff --git a/lemur/plugins/lemur_email/tests/test_email.py b/lemur/plugins/lemur_email/tests/test_email.py index 43168cab..fd4dc575 100644 --- a/lemur/plugins/lemur_email/tests/test_email.py +++ b/lemur/plugins/lemur_email/tests/test_email.py @@ -1,36 +1,90 @@ import os -from lemur.plugins.lemur_email.templates.config import env +from datetime import timedelta +import arrow +from moto import mock_ses + +from lemur.certificates.schemas import certificate_notification_output_schema +from lemur.plugins.lemur_email.plugin import render_html from lemur.tests.factories import CertificateFactory +from lemur.tests.test_messaging import verify_sender_email dir_path = os.path.dirname(os.path.realpath(__file__)) -def test_render(certificate, endpoint): - from lemur.certificates.schemas import certificate_notification_output_schema +def get_options(): + return [ + {"name": "interval", "value": 10}, + {"name": "unit", "value": "days"}, + {"name": "recipients", "value": "person1@example.com,person2@example.com"}, + ] + + +def test_render_expiration(certificate, endpoint): new_cert = CertificateFactory() new_cert.replaces.append(certificate) - data = { - "certificates": [certificate_notification_output_schema.dump(certificate).data], - "options": [ - {"name": "interval", "value": 10}, - {"name": "unit", "value": "days"}, - ], - } + assert render_html("expiration", get_options(), [certificate_notification_output_schema.dump(certificate).data]) - template = env.get_template("{}.html".format("expiration")) - - body = template.render(dict(message=data, hostname="lemur.test.example.com")) - - template = env.get_template("{}.html".format("rotation")) +def test_render_rotation(certificate, endpoint): certificate.endpoints.append(endpoint) - body = template.render( - dict( - certificate=certificate_notification_output_schema.dump(certificate).data, - hostname="lemur.test.example.com", - ) - ) + assert render_html("rotation", get_options(), certificate_notification_output_schema.dump(certificate).data) + + +def test_render_rotation_failure(pending_certificate): + assert render_html("failed", get_options(), certificate_notification_output_schema.dump(pending_certificate).data) + + +@mock_ses +def test_send_expiration_notification(): + from lemur.notifications.messaging import send_expiration_notifications + from lemur.tests.factories import CertificateFactory + from lemur.tests.factories import NotificationFactory + + now = arrow.utcnow() + in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future + certificate = CertificateFactory() + notification = NotificationFactory(plugin_name="email-notification") + + certificate.not_after = in_ten_days + certificate.notifications.append(notification) + certificate.notifications[0].options = get_options() + + verify_sender_email() + assert send_expiration_notifications([]) == (3, 0) # owner, recipients (only counted as 1), and security + + +@mock_ses +def test_send_rotation_notification(endpoint, source_plugin): + from lemur.notifications.messaging import send_rotation_notification + from lemur.deployment.service import rotate_certificate + + new_certificate = CertificateFactory() + rotate_certificate(endpoint, new_certificate) + assert endpoint.certificate == new_certificate + + verify_sender_email() + assert send_rotation_notification(new_certificate) + + +@mock_ses +def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin): + from lemur.notifications.messaging import send_pending_failure_notification + + verify_sender_email() + assert send_pending_failure_notification(pending_certificate) + + +def test_filter_recipients(certificate, endpoint): + from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin + + options = [{"name": "recipients", "value": "security@example.com,bob@example.com,joe@example.com"}] + assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@example.com", "bob@example.com", + "joe@example.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com"]) == ["bob@example.com", + "joe@example.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com", "bob@example.com", + "joe@example.com"]) == [] 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..ffb5765d --- /dev/null +++ b/lemur/plugins/lemur_entrust/plugin.py @@ -0,0 +1,266 @@ + +import arrow +import requests +import json +import sys +from flask import current_app + +from lemur.plugins import lemur_entrust as entrust +from lemur.plugins.bases import IssuerPlugin, SourcePlugin +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: + """ + log_data = { + "reason": (r.reason if r.reason else ""), + "status_code": r.status_code, + "url": (r.url if r.url else ""), + } + metrics.send(f"entrust_status_code_{r.status_code}", "counter", 1) + current_app.logger.info(log_data) + + +def determine_end_date(end_date): + """ + Determine appropriate end date + :param end_date: + :return: validity_end as string + """ + # ENTRUST only allows 13 months of max certificate duration + max_validity_end = arrow.utcnow().shift(years=1, months=+1) + + if not end_date: + end_date = max_validity_end + elif end_date > max_validity_end: + end_date = max_validity_end + return end_date.format('YYYY-MM-DD') + + +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() + # STANDARD_SSL (cn=domain, san=www.domain), + # ADVANTAGE_SSL (cn=domain, san=[www.domain, one_more_option]), + # WILDCARD_SSL (unlimited sans, and wildcard) + product_type = current_app.config.get(f"ENTRUST_PRODUCT_{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, + # "keyType": "RSA", Entrust complaining about this parameter + "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 ValueError: + # catch an empty jason object here + d = {'response': 'No detailed message'} + s = my_response.status_code + if s > 399: + raise Exception(f"ENTRUST error: {msg.get(s, s)}\n{d['errors']}") + + log_data = { + "function": f"{__name__}.{sys._getframe().f_code.co_name}", + "message": "Response", + "status": s, + "response": d + } + current_app.logger.info(log_data) + if d == {'response': 'No detailed message'}: + # status if no data + return s + else: + # return data from the response + 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", + ] + 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: + """ + log_data = { + "function": f"{__name__}.{sys._getframe().f_code.co_name}", + "message": "Requesting options", + "options": issuer_options + } + current_app.logger.info(log_data) + + 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(f"Error for POST {e}") + + response_dict = handle_response(response) + external_id = response_dict['trackingId'] + cert = response_dict['endEntityCert'] + if len(response_dict['chainCerts']) < 2: + # certificate signed by CA directly, no ICA included ini the chain + chain = None + else: + chain = response_dict['chainCerts'][1] + + log_data["message"] = "Received Chain" + log_data["options"] = f"chain: {chain}" + current_app.logger.info(log_data) + + return cert, chain, external_id + + def revoke_certificate(self, certificate, comments): + """Revoke an Entrust certificate.""" + base_url = current_app.config.get("ENTRUST_URL") + + # make certificate revoke request + revoke_url = f"{base_url}/certificates/{certificate.external_id}/revocations" + if not comments or comments == '': + comments = "revoked via API" + data = { + "crlReason": "superseded", # enum (keyCompromise, affiliationChanged, superseded, cessationOfOperation) + "revocationComment": comments + } + response = self.session.post(revoke_url, json=data) + metrics.send("entrust_revoke_certificate", "counter", 1) + return handle_response(response) + + def deactivate_certificate(self, certificate): + """Deactivates an Entrust certificate.""" + base_url = current_app.config.get("ENTRUST_URL") + deactivate_url = f"{base_url}/certificates/{certificate.external_id}/deactivations" + response = self.session.post(deactivate_url) + metrics.send("entrust_deactivate_certificate", "counter", 1) + return 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(f"Creating Auth: {options} {entrust_issuing}") + # body, chain, role + 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 collection 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/plugins/lemur_entrust/tests/conftest.py b/lemur/plugins/lemur_entrust/tests/conftest.py new file mode 100644 index 00000000..0e1cd89f --- /dev/null +++ b/lemur/plugins/lemur_entrust/tests/conftest.py @@ -0,0 +1 @@ +from lemur.tests.conftest import * # noqa diff --git a/lemur/plugins/lemur_entrust/tests/test_entrust.py b/lemur/plugins/lemur_entrust/tests/test_entrust.py new file mode 100644 index 00000000..354e204e --- /dev/null +++ b/lemur/plugins/lemur_entrust/tests/test_entrust.py @@ -0,0 +1,62 @@ +from unittest.mock import patch, Mock + +import arrow +from cryptography import x509 +from lemur.plugins.lemur_entrust import plugin +from freezegun import freeze_time + + +def config_mock(*args): + values = { + "ENTRUST_API_CERT": "-----BEGIN CERTIFICATE-----abc-----END CERTIFICATE-----", + "ENTRUST_API_KEY": False, + "ENTRUST_API_USER": "test", + "ENTRUST_API_PASS": "password", + "ENTRUST_URL": "http", + "ENTRUST_ROOT": None, + "ENTRUST_NAME": "test", + "ENTRUST_EMAIL": "test@lemur.net", + "ENTRUST_PHONE": "0123456", + "ENTRUST_PRODUCT_ENTRUST": "ADVANTAGE_SSL" + } + return values[args[0]] + + +@patch("lemur.plugins.lemur_digicert.plugin.current_app") +def test_determine_end_date(mock_current_app): + with freeze_time(time_to_freeze=arrow.get(2016, 11, 3).datetime): + assert arrow.get(2017, 12, 3).format('YYYY-MM-DD') == plugin.determine_end_date(0) # 1 year + 1 month + assert arrow.get(2017, 3, 5).format('YYYY-MM-DD') == plugin.determine_end_date(arrow.get(2017, 3, 5)) + assert arrow.get(2017, 12, 3).format('YYYY-MM-DD') == plugin.determine_end_date(arrow.get(2020, 5, 7)) + + +@patch("lemur.plugins.lemur_entrust.plugin.current_app") +def test_process_options(mock_current_app, authority): + mock_current_app.config.get = Mock(side_effect=config_mock) + plugin.determine_end_date = Mock(return_value=arrow.get(2017, 11, 5).format('YYYY-MM-DD')) + authority.name = "Entrust" + names = [u"one.example.com", u"two.example.com", u"three.example.com"] + options = { + "common_name": "example.com", + "owner": "bob@example.com", + "description": "test certificate", + "extensions": {"sub_alt_names": {"names": [x509.DNSName(x) for x in names]}}, + "organization": "Example, Inc.", + "organizational_unit": "Example Org", + "validity_end": arrow.utcnow().shift(years=1, months=+1), + "authority": authority, + } + + expected = { + "signingAlg": "SHA-2", + "eku": "SERVER_AND_CLIENT_AUTH", + "certType": "ADVANTAGE_SSL", + "certExpiryDate": arrow.get(2017, 11, 5).format('YYYY-MM-DD'), + "tracking": { + "requesterName": mock_current_app.config.get("ENTRUST_NAME"), + "requesterEmail": mock_current_app.config.get("ENTRUST_EMAIL"), + "requesterPhone": mock_current_app.config.get("ENTRUST_PHONE") + } + } + + assert expected == plugin.process_options(options) diff --git a/lemur/plugins/lemur_slack/plugin.py b/lemur/plugins/lemur_slack/plugin.py index 7569d295..70d97aa5 100644 --- a/lemur/plugins/lemur_slack/plugin.py +++ b/lemur/plugins/lemur_slack/plugin.py @@ -58,26 +58,19 @@ def create_rotation_attachments(certificate): "title": certificate["name"], "title_link": create_certificate_url(certificate["name"]), "fields": [ + {"title": "Owner", "value": certificate["owner"], "short": True}, { - {"title": "Owner", "value": certificate["owner"], "short": True}, - { - "title": "Expires", - "value": arrow.get(certificate["validityEnd"]).format( - "dddd, MMMM D, YYYY" - ), - "short": True, - }, - { - "title": "Replaced By", - "value": len(certificate["replaced"][0]["name"]), - "short": True, - }, - { - "title": "Endpoints Rotated", - "value": len(certificate["endpoints"]), - "short": True, - }, - } + "title": "Expires", + "value": arrow.get(certificate["validityEnd"]).format( + "dddd, MMMM D, YYYY" + ), + "short": True, + }, + { + "title": "Endpoints Rotated", + "value": len(certificate["endpoints"]), + "short": True, + }, ], } @@ -119,6 +112,9 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): """ A typical check can be performed using the notify command: `lemur notify` + + While we receive a `targets` parameter here, it is unused, as Slack webhooks do not allow + dynamic re-targeting of messages. The webhook itself specifies a channel. """ attachments = None if notification_type == "expiration": @@ -131,7 +127,7 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): raise Exception("Unable to create message attachments") body = { - "text": "Lemur {0} Notification".format(notification_type.capitalize()), + "text": f"Lemur {notification_type.capitalize()} Notification", "attachments": attachments, "channel": self.get_option("recipients", options), "username": self.get_option("username", options), @@ -140,8 +136,8 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): r = requests.post(self.get_option("webhook", options), json.dumps(body)) if r.status_code not in [200]: - raise Exception("Failed to send message") + raise Exception(f"Failed to send message. Slack response: {r.status_code} {body}") - current_app.logger.error( - "Slack response: {0} Message Body: {1}".format(r.status_code, body) + current_app.logger.info( + f"Slack response: {r.status_code} Message Body: {body}" ) diff --git a/lemur/plugins/lemur_slack/tests/test_slack.py b/lemur/plugins/lemur_slack/tests/test_slack.py index 86add25f..2161b28b 100644 --- a/lemur/plugins/lemur_slack/tests/test_slack.py +++ b/lemur/plugins/lemur_slack/tests/test_slack.py @@ -1,3 +1,12 @@ +from datetime import timedelta + +import arrow +from moto import mock_ses + +from lemur.tests.factories import NotificationFactory, CertificateFactory +from lemur.tests.test_messaging import verify_sender_email + + def test_formatting(certificate): from lemur.plugins.lemur_slack.plugin import create_expiration_attachments from lemur.certificates.schemas import certificate_notification_output_schema @@ -21,3 +30,52 @@ def test_formatting(certificate): } assert attachment == create_expiration_attachments(data)[0] + + +def get_options(): + return [ + {"name": "interval", "value": 10}, + {"name": "unit", "value": "days"}, + {"name": "webhook", "value": "https://slack.com/api/api.test"}, + ] + + +@mock_ses() # because email notifications are also sent +def test_send_expiration_notification(): + from lemur.notifications.messaging import send_expiration_notifications + + verify_sender_email() # emails are sent to owner and security; Slack only used for configured notification + + notification = NotificationFactory(plugin_name="slack-notification") + notification.options = get_options() + + now = arrow.utcnow() + in_ten_days = now + timedelta(days=10, hours=1) # a bit more than 10 days since we'll check in the future + + certificate = CertificateFactory() + certificate.not_after = in_ten_days + certificate.notifications.append(notification) + + assert send_expiration_notifications([]) == (3, 0) # owner, Slack, and security + + +# Currently disabled as the Slack plugin doesn't support this type of notification +# def test_send_rotation_notification(endpoint, source_plugin): +# from lemur.notifications.messaging import send_rotation_notification +# from lemur.deployment.service import rotate_certificate +# +# notification = NotificationFactory(plugin_name="slack-notification") +# notification.options = get_options() +# +# new_certificate = CertificateFactory() +# rotate_certificate(endpoint, new_certificate) +# assert endpoint.certificate == new_certificate +# +# assert send_rotation_notification(new_certificate, notification_plugin=notification.plugin) + + +# Currently disabled as the Slack plugin doesn't support this type of notification +# def test_send_pending_failure_notification(user, pending_certificate, async_issuer_plugin): +# from lemur.notifications.messaging import send_pending_failure_notification +# +# assert send_pending_failure_notification(pending_certificate, notification_plugin=plugins.get("slack-notification")) diff --git a/lemur/roles/service.py b/lemur/roles/service.py index 51597d6e..fa4c9c97 100644 --- a/lemur/roles/service.py +++ b/lemur/roles/service.py @@ -128,3 +128,11 @@ def render(args): query = database.filter(query, Role, terms) return database.sort_and_page(query, Role, args) + + +def get_or_create(role_name, description): + role = get_by_name(role_name) + if not role: + role = create(name=role_name, description=description) + + return role diff --git a/lemur/static/app/angular/authorities/authority/authority.js b/lemur/static/app/angular/authorities/authority/authority.js index 9863bf4d..a449cff5 100644 --- a/lemur/static/app/angular/authorities/authority/authority.js +++ b/lemur/static/app/angular/authorities/authority/authority.js @@ -124,4 +124,8 @@ angular.module('lemur') opened: false }; + $scope.populateSubjectEmail = function () { + $scope.authority.email = $scope.authority.owner; + }; + }); diff --git a/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html b/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html index c6a7d312..e94f856e 100644 --- a/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html +++ b/lemur/static/app/angular/authorities/authority/distinguishedName.tpl.html @@ -26,8 +26,7 @@ Location
- -

You must enter a location

+
+
+ +
+ +
+
diff --git a/lemur/static/app/angular/authorities/authority/options.tpl.html b/lemur/static/app/angular/authorities/authority/options.tpl.html index dbc4f40a..adf8eacc 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
- +
@@ -20,8 +20,16 @@ Key Type
- +
diff --git a/lemur/static/app/angular/authorities/authority/tracking.tpl.html b/lemur/static/app/angular/authorities/authority/tracking.tpl.html index 72d7e3d5..a561745f 100644 --- a/lemur/static/app/angular/authorities/authority/tracking.tpl.html +++ b/lemur/static/app/angular/authorities/authority/tracking.tpl.html @@ -21,7 +21,7 @@
+ class="form-control" ng-change="populateSubjectEmail()" required/>

You must enter an Certificate Authority owner

diff --git a/lemur/static/app/angular/certificates/certificate/certificate.js b/lemur/static/app/angular/certificates/certificate/certificate.js index 155658e6..41e04d55 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.defaultValidityDays); + certificate.validityStart = startDate; + certificate.validityEnd = endDate; + } + $scope.templates = [ { 'name': 'Client Certificate', @@ -220,10 +251,10 @@ angular.module('lemur') $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); }); @@ -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.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 72f168a0..bc08c786 100644 --- a/lemur/static/app/angular/certificates/certificate/distinguishedName.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/distinguishedName.tpl.html @@ -38,9 +38,7 @@ Location
- -

You must enter a - location

+
+ 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.

@@ -32,10 +32,12 @@
diff --git a/lemur/static/app/angular/certificates/certificate/tracking.tpl.html b/lemur/static/app/angular/certificates/certificate/tracking.tpl.html index 07d6b0f4..c50d40ba 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 - -
+
-
+
-
- -