Merge branch 'master' of github.com:Netflix/lemur into cname_01
This commit is contained in:
@ -6,6 +6,9 @@
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
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)
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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/<int:certificate_id>", endpoint="certificate"
|
||||
)
|
||||
api.add_resource(
|
||||
Certificates, "/certificates/<int:certificate_id>/update/notify", endpoint="certificateUpdateNotify"
|
||||
)
|
||||
api.add_resource(CertificatesStats, "/certificates/stats", endpoint="certificateStats")
|
||||
api.add_resource(
|
||||
CertificatesUpload, "/certificates/upload", endpoint="certificateUpload"
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
26
lemur/migrations/versions/434c29e40511_.py
Normal file
26
lemur/migrations/versions/434c29e40511_.py
Normal file
@ -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 ###
|
114
lemur/migrations/versions/c301c59688d2_.py
Normal file
114
lemur/migrations/versions/c301c59688d2_.py
Normal file
@ -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)
|
@ -8,6 +8,7 @@
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -32,13 +32,14 @@
|
||||
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
|
||||
.. moduleauthor:: Harm Weites <harm@weites.com>
|
||||
"""
|
||||
|
||||
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 <jschladen@netflix.com>"
|
||||
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))
|
||||
|
55
lemur/plugins/lemur_aws/sns.py
Normal file
55
lemur/plugins/lemur_aws/sns.py
Normal file
@ -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 <jschladen@netflix.com>
|
||||
"""
|
||||
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)
|
120
lemur/plugins/lemur_aws/tests/test_sns.py
Normal file
120
lemur/plugins/lemur_aws/tests/test_sns.py
Normal file
@ -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)
|
@ -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"],
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -83,12 +83,12 @@
|
||||
<td width="32px"></td>
|
||||
<td width="16px"></td>
|
||||
<td style="line-height:1.2">
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.name }}</span>
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
|
||||
<br>
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
|
||||
<br>{{ certificate.owner }}
|
||||
<br>{{ certificate.validityEnd | time }}
|
||||
<a href="https://{{ hostname }}/#/certificates/{{ certificate.name }}" target="_blank">Details</a>
|
||||
<br>{{ message.certificates.owner }}
|
||||
<br>{{ message.certificates.validityEnd | time }}
|
||||
<a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@ -110,12 +110,12 @@
|
||||
<td width="32px"></td>
|
||||
<td width="16px"></td>
|
||||
<td style="line-height:1.2">
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ certificate.replacedBy[0].name }}</span>
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.certificates.name }}</span>
|
||||
<br>
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
|
||||
<br>{{ certificate.replacedBy[0].owner }}
|
||||
<br>{{ certificate.replacedBy[0].validityEnd | time }}
|
||||
<a href="https://{{ hostname }}/#/certificates/{{ certificate.replacedBy[0].name }}" target="_blank">Details</a>
|
||||
<br>{{ message.certificates.owner }}
|
||||
<br>{{ message.certificates.validityEnd | time }}
|
||||
<a href="https://{{ hostname }}/#/certificates/{{ message.certificates.name }}" target="_blank">Details</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@ -133,7 +133,7 @@
|
||||
<table border="0" cellspacing="0" cellpadding="0"
|
||||
style="margin-top:48px;margin-bottom:48px">
|
||||
<tbody>
|
||||
{% for endpoint in certificate.endpoints %}
|
||||
{% for endpoint in message.certificates.endpoints %}
|
||||
<tr valign="middle">
|
||||
<td width="32px"></td>
|
||||
<td width="16px"></td>
|
||||
|
@ -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"]) == []
|
||||
|
5
lemur/plugins/lemur_entrust/__init__.py
Normal file
5
lemur/plugins/lemur_entrust/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Set the version information."""
|
||||
try:
|
||||
VERSION = __import__("pkg_resources").get_distribution(__name__).version
|
||||
except Exception as e:
|
||||
VERSION = "unknown"
|
266
lemur/plugins/lemur_entrust/plugin.py
Normal file
266
lemur/plugins/lemur_entrust/plugin.py
Normal file
@ -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_<upper(authority.name)>
|
||||
# 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)
|
1
lemur/plugins/lemur_entrust/tests/conftest.py
Normal file
1
lemur/plugins/lemur_entrust/tests/conftest.py
Normal file
@ -0,0 +1 @@
|
||||
from lemur.tests.conftest import * # noqa
|
62
lemur/plugins/lemur_entrust/tests/test_entrust.py
Normal file
62
lemur/plugins/lemur_entrust/tests/test_entrust.py
Normal file
@ -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)
|
@ -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}"
|
||||
)
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
|
@ -124,4 +124,8 @@ angular.module('lemur')
|
||||
opened: false
|
||||
};
|
||||
|
||||
$scope.populateSubjectEmail = function () {
|
||||
$scope.authority.email = $scope.authority.owner;
|
||||
};
|
||||
|
||||
});
|
||||
|
@ -26,8 +26,7 @@
|
||||
Location
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="location" ng-model="authority.location" placeholder="Location" class="form-control" required/>
|
||||
<p ng-show="dnForm.location.$invalid && !dnForm.location.$pristine" class="help-block">You must enter a location</p>
|
||||
<input name="location" ng-model="authority.location" placeholder="Location" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"
|
||||
@ -49,6 +48,15 @@
|
||||
<input name="organizationalUnit" ng-model="authority.organizationalUnit" placeholder="Organizational Unit" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"
|
||||
ng-class="{'has-error': dnForm.email.$invalid, 'has-success': !dnForm.$invalid&&dnForm.email.$dirty}">
|
||||
<label class="control-label col-sm-2">
|
||||
Email
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="email" name="email" ng-model="authority.email" placeholder="Email Address" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
Signing Algorithm
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" ng-model="authority.signingAlgorithm" ng-options="option for option in ['sha1WithRSA', 'sha256WithRSA']" ng-init="authority.signingAlgorithm = 'sha256WithRSA'"></select>
|
||||
<select class="form-control" ng-model="authority.signingAlgorithm" ng-options="option for option in ['sha1WithRSA', 'sha256WithRSA', 'sha256WithECDSA', 'SHA384withECDSA', 'SHA512withECDSA']" ng-init="authority.signingAlgorithm = 'sha256WithRSA'"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@ -20,8 +20,16 @@
|
||||
Key Type
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" ng-model="authority.keyType" ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1', 'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1',
|
||||
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1', 'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']" ng-init="authority.keyType = 'RSA2048'"></select>
|
||||
<select class="form-control" ng-model="authority.keyType"
|
||||
ng-options="option.value as option.name for option in [
|
||||
{'name': 'RSA-2048', 'value': 'RSA2048'},
|
||||
{'name': 'RSA-4096', 'value': 'RSA4096'},
|
||||
{'name': 'ECC-PRIME256V1', 'value': 'ECCPRIME256V1'},
|
||||
{'name': 'ECC-SECP384R1', 'value': 'ECCSECP384R1'},
|
||||
{'name': 'ECC-SECP521R1', 'value': 'ECCSECP521R1'}]"
|
||||
|
||||
ng-init="authority.keyType = 'RSA2048'">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="authority.sensitivity == 'high'" class="form-group">
|
||||
|
@ -21,7 +21,7 @@
|
||||
<div class="col-sm-10">
|
||||
<input type="email" name="owner" ng-model="authority.owner" placeholder="TeamDL@example.com"
|
||||
uib-tooltip="This is the authorities team distribution list or the main point of contact for this authority"
|
||||
class="form-control" required/>
|
||||
class="form-control" ng-change="populateSubjectEmail()" required/>
|
||||
<p ng-show="trackingForm.owner.$invalid && !trackingForm.owner.$pristine" class="help-block">You must
|
||||
enter an Certificate Authority owner</p>
|
||||
</div>
|
||||
|
@ -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',
|
||||
|
@ -38,9 +38,7 @@
|
||||
Location
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="location" ng-model="certificate.location" placeholder="Location" class="form-control" required/>
|
||||
<p ng-show="dnForm.location.$invalid && !dnForm.location.$pristine" class="help-block">You must enter a
|
||||
location</p>
|
||||
<input name="location" ng-model="certificate.location" placeholder="Location" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"
|
||||
|
@ -20,7 +20,7 @@
|
||||
name="certificate signing request"
|
||||
ng-model="certificate.csr"
|
||||
placeholder="PEM encoded string..." class="form-control"
|
||||
ng-pattern="/^-----BEGIN CERTIFICATE REQUEST-----/"></textarea>
|
||||
ng-pattern="/(^-----BEGIN CERTIFICATE REQUEST-----[\S\s]*-----END CERTIFICATE REQUEST-----)|(^-----BEGIN NEW CERTIFICATE REQUEST-----[\S\s]*-----END NEW CERTIFICATE REQUEST-----)/"></textarea>
|
||||
|
||||
<p ng-show="trackingForm.csr.$invalid && !trackingForm.csr.$pristine"
|
||||
class="help-block">Enter a valid certificate signing request.</p>
|
||||
@ -32,10 +32,12 @@
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" ng-model="certificate.keyType"
|
||||
ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME192V1', 'ECCPRIME256V1', 'ECCSECP192R1',
|
||||
'ECCSECP224R1', 'ECCSECP256R1', 'ECCSECP384R1', 'ECCSECP521R1', 'ECCSECP256K1',
|
||||
'ECCSECT163K1', 'ECCSECT233K1', 'ECCSECT283K1', 'ECCSECT409K1', 'ECCSECT571K1',
|
||||
'ECCSECT163R2', 'ECCSECT233R1', 'ECCSECT283R1', 'ECCSECT409R1', 'ECCSECT571R2']"
|
||||
ng-options="option.value as option.name for option in [
|
||||
{'name': 'RSA-2048', 'value': 'RSA2048'},
|
||||
{'name': 'RSA-4096', 'value': 'RSA4096'},
|
||||
{'name': 'ECC-PRIME256V1', 'value': 'ECCPRIME256V1'},
|
||||
{'name': 'ECC-SECP384R1', 'value': 'ECCSECP384R1'}]"
|
||||
|
||||
ng-init="certificate.keyType = 'RSA2048'"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -96,7 +96,7 @@
|
||||
Certificate Authority
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<ui-select class="input-md" ng-model="certificate.authority" theme="bootstrap" title="choose an authority">
|
||||
<ui-select class="input-md" ng-model="certificate.authority" theme="bootstrap" title="choose an authority" ng-change="clearDates()">
|
||||
<ui-select-match placeholder="select an authority...">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices class="form-control" repeat="authority in authorities"
|
||||
refresh="getAuthoritiesByName($select.search)"
|
||||
@ -133,22 +133,20 @@
|
||||
</div>
|
||||
<div class="form-group" ng-hide="certificate.authority.plugin.slug == 'acme-issuer'">
|
||||
<label class="control-label col-sm-2"
|
||||
uib-tooltip="If no date is selected Lemur attempts to issue a 1 year certificate">
|
||||
uib-tooltip="You can select custom date range; however, we recommend continuing with default validity.">
|
||||
Validity Range <span class="glyphicon glyphicon-question-sign"></span>
|
||||
</label>
|
||||
<div class="col-sm-2">
|
||||
<select ng-model="certificate.validityYears" class="form-control">
|
||||
<option value="">-</option>
|
||||
<option value="1">1 year</option>
|
||||
</select>
|
||||
<div class="col-sm-4">
|
||||
<div class="btn-group btn-group-toggle" data-toggle="buttons">
|
||||
<label class="btn btn-info" ng-model="certificate.validityType" uib-btn-radio="'defaultDays'" ng-click="clearDates()">
|
||||
Default ({{certificate.authority.defaultValidityDays}} days)</label>
|
||||
<label class="btn btn-info" ng-model="certificate.validityType" uib-btn-radio="'customDates'" ng-change="clearDates()">Custom</label>
|
||||
</div>
|
||||
</div>
|
||||
<span style="padding-top: 15px" class="text-center col-sm-1">
|
||||
<strong>or</strong>
|
||||
</span>
|
||||
<div class="col-sm-3">
|
||||
<div class="col-sm-3" ng-if="certificate.validityType==='customDates'">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control"
|
||||
uib-tooltip="yyyy/MM/dd"
|
||||
uib-tooltip="Start Date (yyyy/MM/dd)"
|
||||
uib-datepicker-popup="yyyy/MM/dd"
|
||||
ng-model="certificate.validityStart"
|
||||
ng-change="certificate.setValidityEndDateRange(certificate.validityStart)"
|
||||
@ -159,6 +157,7 @@
|
||||
min-date="certificate.authority.authorityCertificate.notBefore"
|
||||
alt-input-formats="altInputFormats"
|
||||
placeholder="Start Date"
|
||||
readonly="true"
|
||||
/>
|
||||
<span class="input-group-btn">
|
||||
<button type="button" class="btn btn-default" ng-click="open1()"><i
|
||||
@ -166,10 +165,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div class="col-sm-3" ng-if="certificate.validityType==='customDates'">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control"
|
||||
uib-tooltip="yyyy/MM/dd"
|
||||
uib-tooltip="End Date (yyyy/MM/dd)"
|
||||
uib-datepicker-popup="yyyy/MM/dd"
|
||||
ng-model="certificate.validityEnd"
|
||||
is-open="popup2.opened"
|
||||
@ -179,6 +178,7 @@
|
||||
min-date="certificate.authority.authorityCertificate.minValidityEnd"
|
||||
alt-input-formats="altInputFormats"
|
||||
placeholder="End Date"
|
||||
readonly="true"
|
||||
/>
|
||||
<span class="input-group-btn">
|
||||
<button type="button" class="btn btn-default" ng-click="open2()"><i
|
||||
@ -186,10 +186,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button uib-tooltip="Clear Validity" ng-click="clearDates()" class="btn btn-default"><i
|
||||
class="glyphicon glyphicon-remove"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="certificate.authority.plugin.slug == 'acme-issuer'">
|
||||
<label class="control-label col-sm-2">
|
||||
|
@ -167,17 +167,19 @@ angular.module('lemur')
|
||||
},
|
||||
setValidityEndDateRange: function (value) {
|
||||
// clear selected validity end date as we are about to calculate new range
|
||||
if(this.validityEnd) {
|
||||
this.validityEnd = '';
|
||||
}
|
||||
|
||||
this.validityEnd = '';
|
||||
|
||||
// Minimum end date will be same as selected start date
|
||||
this.authority.authorityCertificate.minValidityEnd = value;
|
||||
|
||||
// Move max end date by maxIssuanceDays
|
||||
let endDate = new Date(value);
|
||||
endDate.setDate(endDate.getDate() + this.authority.authorityCertificate.maxIssuanceDays);
|
||||
this.authority.authorityCertificate.maxValidityEnd = endDate;
|
||||
if(!this.authority.maxIssuanceDays) {
|
||||
this.authority.authorityCertificate.maxValidityEnd = this.authority.authorityCertificate.notAfter;
|
||||
} else {
|
||||
// Move max end date by maxIssuanceDays
|
||||
let endDate = new Date(value);
|
||||
endDate.setDate(endDate.getDate() + this.authority.maxIssuanceDays);
|
||||
this.authority.authorityCertificate.maxValidityEnd = endDate;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -195,7 +197,7 @@ angular.module('lemur')
|
||||
CertificateService.create = function (certificate) {
|
||||
certificate.attachSubAltName();
|
||||
certificate.attachCustom();
|
||||
if (certificate.validityYears === '') { // if a user de-selects validity years we ignore it
|
||||
if (certificate.validityYears === '') { // if a user de-selects validity years we ignore it - might not be needed anymore
|
||||
delete certificate.validityYears;
|
||||
}
|
||||
return CertificateApi.post(certificate);
|
||||
@ -281,9 +283,17 @@ angular.module('lemur')
|
||||
certificate.authority.authorityCertificate.minValidityEnd = defaults.authority.authorityCertificate.notBefore;
|
||||
certificate.authority.authorityCertificate.maxValidityEnd = defaults.authority.authorityCertificate.notAfter;
|
||||
|
||||
// pre-select validity type radio button to default days
|
||||
certificate.validityType = 'defaultDays';
|
||||
|
||||
if (certificate.dnsProviderId) {
|
||||
certificate.dnsProvider = {id: certificate.dnsProviderId};
|
||||
}
|
||||
|
||||
if(!certificate.keyType) {
|
||||
certificate.keyType = 'RSA2048'; // default algo to select during clone if backend did not return algo
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
@ -296,7 +306,7 @@ angular.module('lemur')
|
||||
};
|
||||
|
||||
CertificateService.updateNotify = function (certificate) {
|
||||
return certificate.put();
|
||||
return certificate.post();
|
||||
};
|
||||
|
||||
CertificateService.export = function (certificate) {
|
||||
|
@ -111,6 +111,8 @@
|
||||
<div class="list-group-item">
|
||||
<dt>Key Length</dt>
|
||||
<dd>{{ certificate.bits }}</dd>
|
||||
<dt>Key Type</dt>
|
||||
<dd>{{ certificate.keyType }}</dd>
|
||||
<dt>Signing Algorithm</dt>
|
||||
<dd>{{ certificate.signingAlgorithm }}</dd>
|
||||
</div>
|
||||
|
@ -147,17 +147,19 @@ angular.module('lemur')
|
||||
},
|
||||
setValidityEndDateRange: function (value) {
|
||||
// clear selected validity end date as we are about to calculate new range
|
||||
if(this.validityEnd) {
|
||||
this.validityEnd = '';
|
||||
}
|
||||
this.validityEnd = '';
|
||||
|
||||
// Minimum end date will be same as selected start date
|
||||
this.authority.authorityCertificate.minValidityEnd = value;
|
||||
|
||||
// Move max end date by maxIssuanceDays
|
||||
let endDate = new Date(value);
|
||||
endDate.setDate(endDate.getDate() + this.authority.authorityCertificate.maxIssuanceDays);
|
||||
this.authority.authorityCertificate.maxValidityEnd = endDate;
|
||||
if(!this.authority.maxIssuanceDays) {
|
||||
this.authority.authorityCertificate.maxValidityEnd = this.authority.authorityCertificate.notAfter;
|
||||
} else {
|
||||
// Move max end date by maxIssuanceDays
|
||||
let endDate = new Date(value);
|
||||
endDate.setDate(endDate.getDate() + this.authority.maxIssuanceDays);
|
||||
this.authority.authorityCertificate.maxValidityEnd = endDate;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,21 @@
|
||||
# This is just Python which means you can inherit and tweak settings
|
||||
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
_basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
# generate random secrets for unittest
|
||||
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)))
|
||||
|
||||
|
||||
THREADS_PER_PAGE = 8
|
||||
|
||||
# General
|
||||
@ -14,25 +26,27 @@ debug = False
|
||||
|
||||
TESTING = True
|
||||
|
||||
# this is the secret key used by flask session management
|
||||
SECRET_KEY = "I/dVhOZNSMZMqrFJa5tWli6VQccOGudKerq3eWPMSzQNmHHVhMAQfQ=="
|
||||
# this is the secret key used by flask session management (utf8 encoded)
|
||||
SECRET_KEY = get_random_secret(length=32).encode('utf8')
|
||||
|
||||
# You should consider storing these separately from your config
|
||||
|
||||
# You should consider storing these separately from your config (should be URL-safe)
|
||||
LEMUR_TOKEN_SECRET = "test"
|
||||
LEMUR_ENCRYPTION_KEYS = "o61sBLNBSGtAckngtNrfVNd8xy8Hp9LBGDstTbMbqCY="
|
||||
LEMUR_ENCRYPTION_KEYS = base64.urlsafe_b64encode(get_random_secret(length=32).encode('utf8'))
|
||||
|
||||
|
||||
# List of domain regular expressions that non-admin users can issue
|
||||
LEMUR_WHITELISTED_DOMAINS = [
|
||||
"^[a-zA-Z0-9-]+\.example\.com$",
|
||||
"^[a-zA-Z0-9-]+\.example\.org$",
|
||||
"^example\d+\.long\.com$",
|
||||
LEMUR_ALLOWED_DOMAINS = [
|
||||
r"^[a-zA-Z0-9-]+\.example\.com$",
|
||||
r"^[a-zA-Z0-9-]+\.example\.org$",
|
||||
r"^example\d+\.long\.com$",
|
||||
]
|
||||
|
||||
# Mail Server
|
||||
|
||||
# Lemur currently only supports SES for sending email, this address
|
||||
# needs to be verified
|
||||
LEMUR_EMAIL = ""
|
||||
LEMUR_EMAIL = "lemur@example.com"
|
||||
LEMUR_SECURITY_TEAM_EMAIL = ["security@example.com"]
|
||||
|
||||
LEMUR_HOSTNAME = "lemur.example.com"
|
||||
@ -52,7 +66,8 @@ LEMUR_ALLOW_WEEKEND_EXPIRATION = False
|
||||
|
||||
# Database
|
||||
|
||||
# modify this if you are not using a local database
|
||||
# modify this if you are not using a local database. Do not use any development or production DBs,
|
||||
# as Unit Tests drop the whole schema, recreate and again drop everything at the end
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
"SQLALCHEMY_DATABASE_URI", "postgresql://lemur:lemur@localhost:5432/lemur"
|
||||
)
|
||||
@ -84,8 +99,6 @@ DIGICERT_CIS_URL = "mock://www.digicert.com"
|
||||
DIGICERT_CIS_PROFILE_NAMES = {"sha2-rsa-ecc-root": "ssl_plus"}
|
||||
DIGICERT_CIS_API_KEY = "api-key"
|
||||
DIGICERT_CIS_ROOTS = {"root": "ROOT"}
|
||||
DIGICERT_CIS_INTERMEDIATES = {"inter": "INTERMEDIATE_CA_CERT"}
|
||||
|
||||
|
||||
VERISIGN_URL = "http://example.com"
|
||||
VERISIGN_PEM_PATH = "~/"
|
||||
@ -197,3 +210,41 @@ LDAP_REQUIRED_GROUP = "Lemur Access"
|
||||
LDAP_DEFAULT_ROLE = "role1"
|
||||
|
||||
ALLOW_CERT_DELETION = True
|
||||
|
||||
ENTRUST_API_CERT = "api-cert"
|
||||
ENTRUST_API_KEY = get_random_secret(32)
|
||||
ENTRUST_API_USER = "user"
|
||||
ENTRUST_API_PASS = get_random_secret(32)
|
||||
ENTRUST_URL = "https://api.entrust.net/enterprise/v2"
|
||||
ENTRUST_ROOT = """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC
|
||||
VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50
|
||||
cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs
|
||||
IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz
|
||||
dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy
|
||||
NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu
|
||||
dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt
|
||||
dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0
|
||||
aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj
|
||||
YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T
|
||||
RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN
|
||||
cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW
|
||||
wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1
|
||||
U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0
|
||||
jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP
|
||||
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN
|
||||
BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/
|
||||
jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ
|
||||
Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v
|
||||
1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R
|
||||
nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH
|
||||
VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g==
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
ENTRUST_NAME = "lemur"
|
||||
ENTRUST_EMAIL = "lemur@example.com"
|
||||
ENTRUST_PHONE = "123456"
|
||||
ENTRUST_ISSUING = ""
|
||||
ENTRUST_PRODUCT_ENTRUST = "ADVANTAGE_SSL"
|
||||
|
@ -34,6 +34,29 @@ def test_authority_input_schema(client, role, issuer_plugin, logged_in_user):
|
||||
assert not errors
|
||||
|
||||
|
||||
def test_authority_input_schema_ecc(client, role, issuer_plugin, logged_in_user):
|
||||
from lemur.authorities.schemas import AuthorityInputSchema
|
||||
|
||||
input_data = {
|
||||
"name": "Example Authority",
|
||||
"owner": "jim@example.com",
|
||||
"description": "An example authority.",
|
||||
"commonName": "An Example Authority",
|
||||
"plugin": {
|
||||
"slug": "test-issuer",
|
||||
"plugin_options": [{"name": "test", "value": "blah"}],
|
||||
},
|
||||
"type": "root",
|
||||
"signingAlgorithm": "sha256WithECDSA",
|
||||
"keyType": "ECCPRIME256V1",
|
||||
"sensitivity": "medium",
|
||||
}
|
||||
|
||||
data, errors = AuthorityInputSchema().load(input_data)
|
||||
|
||||
assert not errors
|
||||
|
||||
|
||||
def test_user_authority(session, client, authority, role, user, issuer_plugin):
|
||||
u = user["user"]
|
||||
u.roles.append(role)
|
||||
|
@ -9,7 +9,6 @@ from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from marshmallow import ValidationError
|
||||
from freezegun import freeze_time
|
||||
# from mock import patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from lemur.certificates.service import create_csr
|
||||
@ -154,7 +153,8 @@ def test_get_certificate_primitives(certificate):
|
||||
|
||||
with freeze_time(datetime.date(year=2016, month=10, day=30)):
|
||||
primitives = get_certificate_primitives(certificate)
|
||||
assert len(primitives) == 26
|
||||
assert len(primitives) == 25
|
||||
assert (primitives["key_type"] == "RSA2048")
|
||||
|
||||
|
||||
def test_certificate_output_schema(session, certificate, issuer_plugin):
|
||||
@ -170,16 +170,52 @@ def test_certificate_output_schema(session, certificate, issuer_plugin):
|
||||
) as wrapper:
|
||||
data, errors = CertificateOutputSchema().dump(certificate)
|
||||
assert data["issuer"] == "LemurTrustUnittestsClass1CA2018"
|
||||
assert data["distinguishedName"] == "L=Earth,ST=N/A,C=EE,OU=Karate Lessons,O=Daniel San & co,CN=san.example.org"
|
||||
# Authority does not have 'cab_compliant', thus subject details should not be returned
|
||||
assert "organization" not in data
|
||||
|
||||
assert wrapper.call_count == 1
|
||||
|
||||
|
||||
def test_certificate_output_schema_subject_details(session, certificate, issuer_plugin):
|
||||
from lemur.certificates.schemas import CertificateOutputSchema
|
||||
from lemur.authorities.service import update_options
|
||||
|
||||
# Mark authority as non-cab-compliant
|
||||
update_options(certificate.authority.id, '[{"name": "cab_compliant","value":false}]')
|
||||
|
||||
data, errors = CertificateOutputSchema().dump(certificate)
|
||||
assert not errors
|
||||
assert data["issuer"] == "LemurTrustUnittestsClass1CA2018"
|
||||
assert data["distinguishedName"] == "L=Earth,ST=N/A,C=EE,OU=Karate Lessons,O=Daniel San & co,CN=san.example.org"
|
||||
|
||||
# Original subject details should be returned because of cab_compliant option update above
|
||||
assert data["country"] == "EE"
|
||||
assert data["state"] == "N/A"
|
||||
assert data["location"] == "Earth"
|
||||
assert data["organization"] == "Daniel San & co"
|
||||
assert data["organizationalUnit"] == "Karate Lessons"
|
||||
|
||||
# Mark authority as cab-compliant
|
||||
update_options(certificate.authority.id, '[{"name": "cab_compliant","value":true}]')
|
||||
data, errors = CertificateOutputSchema().dump(certificate)
|
||||
assert not errors
|
||||
assert "country" not in data
|
||||
assert "state" not in data
|
||||
assert "location" not in data
|
||||
assert "organization" not in data
|
||||
assert "organizationalUnit" not in data
|
||||
|
||||
|
||||
def test_certificate_edit_schema(session):
|
||||
from lemur.certificates.schemas import CertificateEditInputSchema
|
||||
|
||||
input_data = {"owner": "bob@example.com"}
|
||||
data, errors = CertificateEditInputSchema().load(input_data)
|
||||
|
||||
assert not errors
|
||||
assert len(data["notifications"]) == 3
|
||||
assert data["roles"][0].name == input_data["owner"]
|
||||
|
||||
|
||||
def test_authority_key_identifier_schema():
|
||||
@ -253,17 +289,18 @@ def test_certificate_input_schema(client, authority):
|
||||
"validityStart": arrow.get(2018, 11, 9).isoformat(),
|
||||
"validityEnd": arrow.get(2019, 11, 9).isoformat(),
|
||||
"dnsProvider": None,
|
||||
"location": "A Place"
|
||||
}
|
||||
|
||||
data, errors = CertificateInputSchema().load(input_data)
|
||||
|
||||
assert not errors
|
||||
assert data["authority"].id == authority.id
|
||||
assert data["location"] == "A Place"
|
||||
|
||||
# make sure the defaults got set
|
||||
assert data["common_name"] == "test.example.com"
|
||||
assert data["country"] == "US"
|
||||
assert data["location"] == "Los Gatos"
|
||||
|
||||
assert len(data.keys()) == 19
|
||||
|
||||
@ -395,7 +432,7 @@ def test_certificate_cn_admin(client, authority, logged_in_admin):
|
||||
from lemur.certificates.schemas import CertificateInputSchema
|
||||
|
||||
input_data = {
|
||||
"commonName": "*.admin-overrides-whitelist.com",
|
||||
"commonName": "*.admin-overrides-allowlist.com",
|
||||
"owner": "jim@example.com",
|
||||
"authority": {"id": authority.id},
|
||||
"description": "testtestest",
|
||||
@ -456,7 +493,7 @@ def test_certificate_incative_authority(client, authority, session, logged_in_us
|
||||
|
||||
|
||||
def test_certificate_disallowed_names(client, authority, session, logged_in_user):
|
||||
"""The CN and SAN are disallowed by LEMUR_WHITELISTED_DOMAINS."""
|
||||
"""The CN and SAN are disallowed by LEMUR_ALLOWED_DOMAINS."""
|
||||
from lemur.certificates.schemas import CertificateInputSchema
|
||||
|
||||
input_data = {
|
||||
@ -479,10 +516,10 @@ def test_certificate_disallowed_names(client, authority, session, logged_in_user
|
||||
|
||||
data, errors = CertificateInputSchema().load(input_data)
|
||||
assert errors["common_name"][0].startswith(
|
||||
"Domain *.example.com does not match whitelisted domain patterns"
|
||||
"Domain *.example.com does not match allowed domain patterns"
|
||||
)
|
||||
assert errors["extensions"]["sub_alt_names"]["names"][0].startswith(
|
||||
"Domain evilhacker.org does not match whitelisted domain patterns"
|
||||
"Domain evilhacker.org does not match allowed domain patterns"
|
||||
)
|
||||
|
||||
|
||||
@ -669,7 +706,7 @@ def test_csr_empty_san(client):
|
||||
|
||||
|
||||
def test_csr_disallowed_cn(client, logged_in_user):
|
||||
"""Domain name CN is disallowed via LEMUR_WHITELISTED_DOMAINS."""
|
||||
"""Domain name CN is disallowed via LEMUR_ALLOWED_DOMAINS."""
|
||||
from lemur.common import validators
|
||||
|
||||
request, pkey = create_csr(
|
||||
@ -678,12 +715,12 @@ def test_csr_disallowed_cn(client, logged_in_user):
|
||||
with pytest.raises(ValidationError) as err:
|
||||
validators.csr(request)
|
||||
assert str(err.value).startswith(
|
||||
"Domain evilhacker.org does not match whitelisted domain patterns"
|
||||
"Domain evilhacker.org does not match allowed domain patterns"
|
||||
)
|
||||
|
||||
|
||||
def test_csr_disallowed_san(client, logged_in_user):
|
||||
"""SAN name is disallowed by LEMUR_WHITELISTED_DOMAINS."""
|
||||
"""SAN name is disallowed by LEMUR_ALLOWED_DOMAINS."""
|
||||
from lemur.common import validators
|
||||
|
||||
request, pkey = create_csr(
|
||||
@ -699,7 +736,7 @@ def test_csr_disallowed_san(client, logged_in_user):
|
||||
with pytest.raises(ValidationError) as err:
|
||||
validators.csr(request)
|
||||
assert str(err.value).startswith(
|
||||
"Domain evilhacker.org does not match whitelisted domain patterns"
|
||||
"Domain evilhacker.org does not match allowed domain patterns"
|
||||
)
|
||||
|
||||
|
||||
@ -754,11 +791,22 @@ def test_reissue_certificate(
|
||||
issuer_plugin, crypto_authority, certificate, logged_in_user
|
||||
):
|
||||
from lemur.certificates.service import reissue_certificate
|
||||
from lemur.authorities.service import update_options
|
||||
from lemur.tests.conf import LEMUR_DEFAULT_ORGANIZATION
|
||||
|
||||
# test-authority would return a mismatching private key, so use 'cryptography-issuer' plugin instead.
|
||||
certificate.authority = crypto_authority
|
||||
new_cert = reissue_certificate(certificate)
|
||||
assert new_cert
|
||||
assert new_cert.key_type == "RSA2048"
|
||||
assert new_cert.organization != certificate.organization
|
||||
# Check for default value since authority does not have cab_compliant option set
|
||||
assert new_cert.organization == LEMUR_DEFAULT_ORGANIZATION
|
||||
|
||||
# update cab_compliant option to false for crypto_authority to maintain subject details
|
||||
update_options(crypto_authority.id, '[{"name": "cab_compliant","value":false}]')
|
||||
new_cert = reissue_certificate(certificate)
|
||||
assert new_cert.organization == certificate.organization
|
||||
|
||||
|
||||
def test_create_csr():
|
||||
@ -915,24 +963,38 @@ def test_certificate_get_body(client):
|
||||
"CN=LemurTrust Unittests Class 1 CA 2018"
|
||||
)
|
||||
|
||||
# No authority details are provided in this test, no information about being cab_compliant is available.
|
||||
# Thus original subject details should be returned.
|
||||
assert response_body["country"] == "EE"
|
||||
assert response_body["state"] == "N/A"
|
||||
assert response_body["location"] == "Earth"
|
||||
assert response_body["organization"] == "LemurTrust Enterprises Ltd"
|
||||
assert response_body["organizationalUnit"] == "Unittesting Operations Center"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token,status",
|
||||
[
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_API_TOKEN, 405),
|
||||
("", 405),
|
||||
(VALID_USER_HEADER_TOKEN, 403),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 200),
|
||||
(VALID_ADMIN_API_TOKEN, 200),
|
||||
("", 401),
|
||||
],
|
||||
)
|
||||
def test_certificate_post(client, token, status):
|
||||
assert (
|
||||
client.post(
|
||||
api.url_for(Certificates, certificate_id=1), data={}, headers=token
|
||||
).status_code
|
||||
== status
|
||||
def test_certificate_post_update_notify(client, certificate, token, status):
|
||||
# negate the current notify flag and pass it to update POST call to flip the notify
|
||||
toggled_notify = not certificate.notify
|
||||
|
||||
response = client.post(
|
||||
api.url_for(Certificates, certificate_id=certificate.id),
|
||||
data=json.dumps({"notify": toggled_notify}),
|
||||
headers=token
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
if status == 200:
|
||||
assert response.json.get("notify") == toggled_notify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token,status",
|
||||
@ -961,6 +1023,9 @@ def test_certificate_put_with_data(client, certificate, issuer_plugin):
|
||||
headers=VALID_ADMIN_HEADER_TOKEN,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(certificate.notifications) == 3
|
||||
assert certificate.roles[0].name == "bob@example.com"
|
||||
assert certificate.notify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -1,11 +1,18 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import arrow
|
||||
import boto3
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from datetime import timedelta
|
||||
import arrow
|
||||
from moto import mock_ses
|
||||
|
||||
|
||||
@mock_ses
|
||||
def verify_sender_email():
|
||||
ses_client = boto3.client("ses", region_name="us-east-1")
|
||||
ses_client.verify_email_identity(EmailAddress="lemur@example.com")
|
||||
|
||||
|
||||
def test_needs_notification(app, certificate, notification):
|
||||
from lemur.notifications.messaging import needs_notification
|
||||
|
||||
@ -78,6 +85,7 @@ def test_get_eligible_certificates(app, certificate, notification):
|
||||
@mock_ses
|
||||
def test_send_expiration_notification(certificate, notification, notification_plugin):
|
||||
from lemur.notifications.messaging import send_expiration_notifications
|
||||
verify_sender_email()
|
||||
|
||||
certificate.notifications.append(notification)
|
||||
certificate.notifications[0].options = [
|
||||
@ -87,7 +95,9 @@ def test_send_expiration_notification(certificate, notification, notification_pl
|
||||
|
||||
delta = certificate.not_after - timedelta(days=10)
|
||||
with freeze_time(delta.datetime):
|
||||
assert send_expiration_notifications([]) == (2, 0)
|
||||
# this will only send owner and security emails (no additional recipients),
|
||||
# but it executes 3 successful send attempts
|
||||
assert send_expiration_notifications([]) == (3, 0)
|
||||
|
||||
|
||||
@mock_ses
|
||||
@ -104,5 +114,14 @@ def test_send_expiration_notification_with_no_notifications(
|
||||
@mock_ses
|
||||
def test_send_rotation_notification(notification_plugin, certificate):
|
||||
from lemur.notifications.messaging import send_rotation_notification
|
||||
verify_sender_email()
|
||||
|
||||
send_rotation_notification(certificate, notification_plugin=notification_plugin)
|
||||
assert send_rotation_notification(certificate)
|
||||
|
||||
|
||||
@mock_ses
|
||||
def test_send_pending_failure_notification(notification_plugin, async_issuer_plugin, pending_certificate):
|
||||
from lemur.notifications.messaging import send_pending_failure_notification
|
||||
verify_sender_email()
|
||||
|
||||
assert send_pending_failure_notification(pending_certificate)
|
||||
|
@ -55,6 +55,7 @@ def test_create_pending(pending_certificate, user, session):
|
||||
assert real_cert.notify == pending_certificate.notify
|
||||
assert real_cert.private_key == pending_certificate.private_key
|
||||
assert real_cert.external_id == "54321"
|
||||
assert real_cert.key_type == "RSA2048"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -2,15 +2,24 @@ import pytest
|
||||
|
||||
from lemur.tests.vectors import (
|
||||
SAN_CERT,
|
||||
SAN_CERT_STR,
|
||||
INTERMEDIATE_CERT,
|
||||
ROOTCA_CERT,
|
||||
EC_CERT_EXAMPLE,
|
||||
ECDSA_PRIME256V1_CERT,
|
||||
ECDSA_SECP384r1_CERT,
|
||||
ECDSA_SECP384r1_CERT_STR,
|
||||
DSA_CERT,
|
||||
CERT_CHAIN_PKCS7_PEM
|
||||
)
|
||||
|
||||
|
||||
def test_get_key_type_from_ec_curve():
|
||||
from lemur.common.utils import get_key_type_from_ec_curve
|
||||
|
||||
assert get_key_type_from_ec_curve("secp256r1") == "ECCPRIME256V1"
|
||||
|
||||
|
||||
def test_generate_private_key():
|
||||
from lemur.common.utils import generate_private_key
|
||||
|
||||
@ -100,3 +109,22 @@ def test_is_selfsigned(selfsigned_cert):
|
||||
# unsupported algorithm (DSA)
|
||||
with pytest.raises(Exception):
|
||||
is_selfsigned(DSA_CERT)
|
||||
|
||||
|
||||
def test_get_key_type_from_certificate():
|
||||
from lemur.common.utils import get_key_type_from_certificate
|
||||
assert (get_key_type_from_certificate(SAN_CERT_STR) == "RSA2048")
|
||||
assert (get_key_type_from_certificate(ECDSA_SECP384r1_CERT_STR) == "ECCSECP384R1")
|
||||
|
||||
|
||||
def test_convert_pkcs7_bytes_to_pem():
|
||||
from lemur.common.utils import convert_pkcs7_bytes_to_pem
|
||||
from lemur.common.utils import parse_certificate
|
||||
cert_chain = convert_pkcs7_bytes_to_pem(CERT_CHAIN_PKCS7_PEM)
|
||||
assert(len(cert_chain) == 3)
|
||||
|
||||
leaf = cert_chain[1]
|
||||
root = cert_chain[2]
|
||||
|
||||
assert(parse_certificate("\n".join(str(root).splitlines())) == ROOTCA_CERT)
|
||||
assert (parse_certificate("\n".join(str(leaf).splitlines())) == INTERMEDIATE_CERT)
|
||||
|
@ -512,3 +512,78 @@ BglghkgBZQMEAwIDMAAwLQIVANubSNMSLt8plN9ZV3cp4pe3lMYCAhQPLLE7rTgm
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
DSA_CERT = parse_certificate(DSA_CERT_STR)
|
||||
|
||||
|
||||
CERT_CHAIN_PKCS7_STR = """
|
||||
-----BEGIN PKCS7-----
|
||||
MIIMfwYJKoZIhvcNAQcCoIIMcDCCDGwCAQExADALBgkqhkiG9w0BBwGgggxSMIIE
|
||||
FjCCAv6gAwIBAgIQbIbX/Ap0Roqzf5HeN5akmzANBgkqhkiG9w0BAQsFADCBpDEq
|
||||
MCgGA1UEAwwhTGVtdXJUcnVzdCBVbml0dGVzdHMgUm9vdCBDQSAyMDE4MSMwIQYD
|
||||
VQQKDBpMZW11clRydXN0IEVudGVycHJpc2VzIEx0ZDEmMCQGA1UECwwdVW5pdHRl
|
||||
c3RpbmcgT3BlcmF0aW9ucyBDZW50ZXIxCzAJBgNVBAYTAkVFMQwwCgYDVQQIDANO
|
||||
L0ExDjAMBgNVBAcMBUVhcnRoMB4XDTE3MTIzMTIyMDAwMFoXDTQ3MTIzMTIyMDAw
|
||||
MFowgaQxKjAoBgNVBAMMIUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAx
|
||||
ODEjMCEGA1UECgwaTGVtdXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsM
|
||||
HVVuaXR0ZXN0aW5nIE9wZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoG
|
||||
A1UECAwDTi9BMQ4wDAYDVQQHDAVFYXJ0aDCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL8laXtLXyM64t5dz2B9q+4VvOsChefBi2PlGudqxDuRN3l0Kmcf
|
||||
un6x2Gng24pTlGdtmiTEWA0a2F8HRLv4YBWhuYleVeBPtf1fF1/SuYgkJOWT7S5q
|
||||
k/od/tUOLHS0Y067st3FydnFQTKpAuYveEkxleFrMS8hX8cuEgbER+8ybiXKn4Gs
|
||||
yM/om6lsTyBoaLp5yTAoQb4jAWDbiz1xcjPSkvH2lm7rLGtKoylCYwxRsMh2nZcR
|
||||
r1OXVhYHXwpYHVB/jVAjy7PAWQ316hi6mpPYbBV+yfn2GUfGuytqyoXLEsrM3iEE
|
||||
AkU0mJjQmYsCDM3r7ONHTM+UFEk47HCZJccCAwEAAaNCMEAwDwYDVR0TAQH/BAUw
|
||||
AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFL12SFeOTTDdGKsHKozeByG
|
||||
HY6nMA0GCSqGSIb3DQEBCwUAA4IBAQAJfe0/uAHobkxth38dqrSFmTo+D5/TMlRt
|
||||
3hdgjlah6sD2+/DObCyut/XhQWCgTNWyRi4xTKgLh5KSoeJ9EMkADGEgDkU2vjBg
|
||||
5FmGZsxg6bqjxehK+2HvASJoTH8r41xmTioav7a2i3wNhaNSntw2QRTQBQEDOIzH
|
||||
RpPDQ2quErjA8nSifE2xmAAr3g+FuookTTJuv37s2cS59zRYsg+WC3+TtPpRssvo
|
||||
bJ6Xe2D4cCVjUmsqtFEztMgdqgmlcWyGdUKeXdi7CMoeTb4uO+9qRQq46wYWn7K1
|
||||
z+W0Kp5yhnnPAoOioAP4vjASDx3z3RnLaZvMmcO7YdCIwhE5oGV0MIIEGjCCAwKg
|
||||
AwIBAgIRAJ96dbOdrkw/lSTGiwbaagwwDQYJKoZIhvcNAQELBQAwgaQxKjAoBgNV
|
||||
BAMMIUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAxODEjMCEGA1UECgwa
|
||||
TGVtdXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsMHVVuaXR0ZXN0aW5n
|
||||
IE9wZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoGA1UECAwDTi9BMQ4w
|
||||
DAYDVQQHDAVFYXJ0aDAeFw0xNzEyMzEyMjAwMDBaFw00NzEyMzEyMjAwMDBaMIGn
|
||||
MS0wKwYDVQQDDCRMZW11clRydXN0IFVuaXR0ZXN0cyBDbGFzcyAxIENBIDIwMTgx
|
||||
IzAhBgNVBAoMGkxlbXVyVHJ1c3QgRW50ZXJwcmlzZXMgTHRkMSYwJAYDVQQLDB1V
|
||||
bml0dGVzdGluZyBPcGVyYXRpb25zIENlbnRlcjELMAkGA1UEBhMCRUUxDDAKBgNV
|
||||
BAgMA04vQTEOMAwGA1UEBwwFRWFydGgwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
|
||||
ggEKAoIBAQDR+qNdfNsLhGvgw3IgCQNakL2B9dpQtkVnvAXhdRZqJETm/tHLkGvO
|
||||
NWTXAwGdoiKv6+0j3I5InUsW+wzUPewcfj+PLNu4mFMq8jH/gPhTElKiAztPRdm8
|
||||
QKchvrqiaU6uEbia8ClM6uPpIi8StxE1aJRYL03p0WeMJjJPrsl6eSSdpR4qL69G
|
||||
Td1n5je9OuWAcn5utXXnt/jO4vNeFRjlGp/0n3JmTDd9w4vtAyY9UrdGgo37eBmi
|
||||
6mXt5J9i//NenhaiOVU81RqxZM2Jt1kkg2WSjcqcIQfBEWp9StG46VmHLaL+9/v2
|
||||
XAV3tL1VilJGj6PoFMb4gY5MXthfGSiXAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
|
||||
Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQstpQr0iMBVfv0lODIsMgT9+9o
|
||||
ezANBgkqhkiG9w0BAQsFAAOCAQEASYQbv1Qwb5zES6Gb5LEhrAcH81ZB2uIpKd3K
|
||||
i6AS4fLJVymMGkUs0RZjt39Ep4qX1zf0hn82Yh9YwRalrkgu+tzKrp0JgegNe6+g
|
||||
yFRrJC0SIGA4zc3M02m/n4tdaouU2lp6jhmWruL3g25ZkgbQ8LO2zjpSMtblR2eu
|
||||
vR2+bI7TepklyG71qx5y6/N8x5PT+hnTlleiZeE/ji9D96MZlpWB4kBihekWmxup
|
||||
tED22z/tpQtac+hPBNgt8z1uFVEYN2rKEcCE7V6Qk7icS+M4Vb7M3D8kLyWDubs9
|
||||
Yy3l0EWjOXQXxEhTaKEm4gSuY/j+Y35bBVkA2Fcyuq7msiTgrzCCBBYwggL+oAMC
|
||||
AQICEGyG1/wKdEaKs3+R3jeWpJswDQYJKoZIhvcNAQELBQAwgaQxKjAoBgNVBAMM
|
||||
IUxlbXVyVHJ1c3QgVW5pdHRlc3RzIFJvb3QgQ0EgMjAxODEjMCEGA1UECgwaTGVt
|
||||
dXJUcnVzdCBFbnRlcnByaXNlcyBMdGQxJjAkBgNVBAsMHVVuaXR0ZXN0aW5nIE9w
|
||||
ZXJhdGlvbnMgQ2VudGVyMQswCQYDVQQGEwJFRTEMMAoGA1UECAwDTi9BMQ4wDAYD
|
||||
VQQHDAVFYXJ0aDAeFw0xNzEyMzEyMjAwMDBaFw00NzEyMzEyMjAwMDBaMIGkMSow
|
||||
KAYDVQQDDCFMZW11clRydXN0IFVuaXR0ZXN0cyBSb290IENBIDIwMTgxIzAhBgNV
|
||||
BAoMGkxlbXVyVHJ1c3QgRW50ZXJwcmlzZXMgTHRkMSYwJAYDVQQLDB1Vbml0dGVz
|
||||
dGluZyBPcGVyYXRpb25zIENlbnRlcjELMAkGA1UEBhMCRUUxDDAKBgNVBAgMA04v
|
||||
QTEOMAwGA1UEBwwFRWFydGgwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
|
||||
AQC/JWl7S18jOuLeXc9gfavuFbzrAoXnwYtj5RrnasQ7kTd5dCpnH7p+sdhp4NuK
|
||||
U5RnbZokxFgNGthfB0S7+GAVobmJXlXgT7X9Xxdf0rmIJCTlk+0uapP6Hf7VDix0
|
||||
tGNOu7LdxcnZxUEyqQLmL3hJMZXhazEvIV/HLhIGxEfvMm4lyp+BrMjP6JupbE8g
|
||||
aGi6eckwKEG+IwFg24s9cXIz0pLx9pZu6yxrSqMpQmMMUbDIdp2XEa9Tl1YWB18K
|
||||
WB1Qf41QI8uzwFkN9eoYupqT2GwVfsn59hlHxrsrasqFyxLKzN4hBAJFNJiY0JmL
|
||||
AgzN6+zjR0zPlBRJOOxwmSXHAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD
|
||||
VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRS9dkhXjk0w3RirByqM3gchh2OpzANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEACX3tP7gB6G5MbYd/Haq0hZk6Pg+f0zJUbd4XYI5WoerA
|
||||
9vvwzmwsrrf14UFgoEzVskYuMUyoC4eSkqHifRDJAAxhIA5FNr4wYORZhmbMYOm6
|
||||
o8XoSvth7wEiaEx/K+NcZk4qGr+2tot8DYWjUp7cNkEU0AUBAziMx0aTw0NqrhK4
|
||||
wPJ0onxNsZgAK94PhbqKJE0ybr9+7NnEufc0WLIPlgt/k7T6UbLL6Gyel3tg+HAl
|
||||
Y1JrKrRRM7TIHaoJpXFshnVCnl3YuwjKHk2+LjvvakUKuOsGFp+ytc/ltCqecoZ5
|
||||
zwKDoqAD+L4wEg8d890Zy2mbzJnDu2HQiMIROaBldKEAMQA=
|
||||
-----END PKCS7-----
|
||||
"""
|
||||
|
||||
CERT_CHAIN_PKCS7_PEM = CERT_CHAIN_PKCS7_STR.encode('utf-8')
|
||||
|
Reference in New Issue
Block a user