483 lines
17 KiB
Python
483 lines
17 KiB
Python
from __future__ import unicode_literals
|
|
|
|
import re
|
|
import json
|
|
import datetime
|
|
from moto.core import BaseBackend, BaseModel
|
|
from moto.ec2 import ec2_backends
|
|
|
|
from .utils import make_arn_for_certificate
|
|
|
|
import cryptography.x509
|
|
import cryptography.hazmat.primitives.asymmetric.rsa
|
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
from moto.core import ACCOUNT_ID as DEFAULT_ACCOUNT_ID
|
|
|
|
|
|
GOOGLE_ROOT_CA = b"""-----BEGIN CERTIFICATE-----
|
|
MIIEKDCCAxCgAwIBAgIQAQAhJYiw+lmnd+8Fe2Yn3zANBgkqhkiG9w0BAQsFADBC
|
|
MQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMS
|
|
R2VvVHJ1c3QgR2xvYmFsIENBMB4XDTE3MDUyMjExMzIzN1oXDTE4MTIzMTIzNTk1
|
|
OVowSTELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMT
|
|
HEdvb2dsZSBJbnRlcm5ldCBBdXRob3JpdHkgRzIwggEiMA0GCSqGSIb3DQEBAQUA
|
|
A4IBDwAwggEKAoIBAQCcKgR3XNhQkToGo4Lg2FBIvIk/8RlwGohGfuCPxfGJziHu
|
|
Wv5hDbcyRImgdAtTT1WkzoJile7rWV/G4QWAEsRelD+8W0g49FP3JOb7kekVxM/0
|
|
Uw30SvyfVN59vqBrb4fA0FAfKDADQNoIc1Fsf/86PKc3Bo69SxEE630k3ub5/DFx
|
|
+5TVYPMuSq9C0svqxGoassxT3RVLix/IGWEfzZ2oPmMrhDVpZYTIGcVGIvhTlb7j
|
|
gEoQxirsupcgEcc5mRAEoPBhepUljE5SdeK27QjKFPzOImqzTs9GA5eXA37Asd57
|
|
r0Uzz7o+cbfe9CUlwg01iZ2d+w4ReYkeN8WvjnJpAgMBAAGjggERMIIBDTAfBgNV
|
|
HSMEGDAWgBTAephojYn7qwVkDBF9qn1luMrMTjAdBgNVHQ4EFgQUSt0GFhu89mi1
|
|
dvWBtrtiGrpagS8wDgYDVR0PAQH/BAQDAgEGMC4GCCsGAQUFBwEBBCIwIDAeBggr
|
|
BgEFBQcwAYYSaHR0cDovL2cuc3ltY2QuY29tMBIGA1UdEwEB/wQIMAYBAf8CAQAw
|
|
NQYDVR0fBC4wLDAqoCigJoYkaHR0cDovL2cuc3ltY2IuY29tL2NybHMvZ3RnbG9i
|
|
YWwuY3JsMCEGA1UdIAQaMBgwDAYKKwYBBAHWeQIFATAIBgZngQwBAgIwHQYDVR0l
|
|
BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IBAQDKSeWs
|
|
12Rkd1u+cfrP9B4jx5ppY1Rf60zWGSgjZGaOHMeHgGRfBIsmr5jfCnC8vBk97nsz
|
|
qX+99AXUcLsFJnnqmseYuQcZZTTMPOk/xQH6bwx+23pwXEz+LQDwyr4tjrSogPsB
|
|
E4jLnD/lu3fKOmc2887VJwJyQ6C9bgLxRwVxPgFZ6RGeGvOED4Cmong1L7bHon8X
|
|
fOGLVq7uZ4hRJzBgpWJSwzfVO+qFKgE4h6LPcK2kesnE58rF2rwjMvL+GMJ74N87
|
|
L9TQEOaWTPtEtyFkDbkAlDASJodYmDkFOA/MgkgMCkdm7r+0X8T/cKjhf4t5K7hl
|
|
MqO5tzHpCvX2HzLc
|
|
-----END CERTIFICATE-----"""
|
|
# Added google root CA as AWS returns chain you gave it + root CA (provided or not)
|
|
# so for now a cheap response is just give any old root CA
|
|
|
|
|
|
def datetime_to_epoch(date):
|
|
# As only Py3 has datetime.timestamp()
|
|
return int((date - datetime.datetime(1970, 1, 1)).total_seconds())
|
|
|
|
|
|
class AWSError(Exception):
|
|
TYPE = None
|
|
STATUS = 400
|
|
|
|
def __init__(self, message):
|
|
self.message = message
|
|
|
|
def response(self):
|
|
resp = {"__type": self.TYPE, "message": self.message}
|
|
return json.dumps(resp), dict(status=self.STATUS)
|
|
|
|
|
|
class AWSValidationException(AWSError):
|
|
TYPE = "ValidationException"
|
|
|
|
|
|
class AWSResourceNotFoundException(AWSError):
|
|
TYPE = "ResourceNotFoundException"
|
|
|
|
|
|
class CertBundle(BaseModel):
|
|
def __init__(
|
|
self,
|
|
certificate,
|
|
private_key,
|
|
chain=None,
|
|
region="us-east-1",
|
|
arn=None,
|
|
cert_type="IMPORTED",
|
|
cert_status="ISSUED",
|
|
):
|
|
self.created_at = datetime.datetime.now()
|
|
self.cert = certificate
|
|
self._cert = None
|
|
self.common_name = None
|
|
self.key = private_key
|
|
self._key = None
|
|
self.chain = chain
|
|
self.tags = {}
|
|
self._chain = None
|
|
self.type = cert_type # Should really be an enum
|
|
self.status = cert_status # Should really be an enum
|
|
|
|
# AWS always returns your chain + root CA
|
|
if self.chain is None:
|
|
self.chain = GOOGLE_ROOT_CA
|
|
else:
|
|
self.chain += b"\n" + GOOGLE_ROOT_CA
|
|
|
|
# Takes care of PEM checking
|
|
self.validate_pk()
|
|
self.validate_certificate()
|
|
if chain is not None:
|
|
self.validate_chain()
|
|
|
|
# TODO check cert is valid, or if self-signed then a chain is provided, otherwise
|
|
# raise AWSValidationException('Provided certificate is not a valid self signed. Please provide either a valid self-signed certificate or certificate chain.')
|
|
|
|
# Used for when one wants to overwrite an arn
|
|
if arn is None:
|
|
self.arn = make_arn_for_certificate(DEFAULT_ACCOUNT_ID, region)
|
|
else:
|
|
self.arn = arn
|
|
|
|
@classmethod
|
|
def generate_cert(cls, domain_name, region, sans=None):
|
|
if sans is None:
|
|
sans = set()
|
|
else:
|
|
sans = set(sans)
|
|
|
|
sans.add(domain_name)
|
|
sans = [cryptography.x509.DNSName(item) for item in sans]
|
|
|
|
key = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key(
|
|
public_exponent=65537, key_size=2048, backend=default_backend()
|
|
)
|
|
subject = cryptography.x509.Name(
|
|
[
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.COUNTRY_NAME, "US"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.STATE_OR_PROVINCE_NAME, "CA"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.LOCALITY_NAME, "San Francisco"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.ORGANIZATION_NAME, "My Company"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.COMMON_NAME, domain_name
|
|
),
|
|
]
|
|
)
|
|
issuer = cryptography.x509.Name(
|
|
[ # C = US, O = Amazon, OU = Server CA 1B, CN = Amazon
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.COUNTRY_NAME, "US"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.ORGANIZATION_NAME, "Amazon"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.ORGANIZATIONAL_UNIT_NAME, "Server CA 1B"
|
|
),
|
|
cryptography.x509.NameAttribute(
|
|
cryptography.x509.NameOID.COMMON_NAME, "Amazon"
|
|
),
|
|
]
|
|
)
|
|
cert = (
|
|
cryptography.x509.CertificateBuilder()
|
|
.subject_name(subject)
|
|
.issuer_name(issuer)
|
|
.public_key(key.public_key())
|
|
.serial_number(cryptography.x509.random_serial_number())
|
|
.not_valid_before(datetime.datetime.utcnow())
|
|
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365))
|
|
.add_extension(
|
|
cryptography.x509.SubjectAlternativeName(sans), critical=False
|
|
)
|
|
.sign(key, hashes.SHA512(), default_backend())
|
|
)
|
|
|
|
cert_armored = cert.public_bytes(serialization.Encoding.PEM)
|
|
private_key = key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
)
|
|
|
|
return cls(
|
|
cert_armored,
|
|
private_key,
|
|
cert_type="AMAZON_ISSUED",
|
|
cert_status="PENDING_VALIDATION",
|
|
region=region,
|
|
)
|
|
|
|
def validate_pk(self):
|
|
try:
|
|
self._key = serialization.load_pem_private_key(
|
|
self.key, password=None, backend=default_backend()
|
|
)
|
|
|
|
if self._key.key_size > 2048:
|
|
AWSValidationException(
|
|
"The private key length is not supported. Only 1024-bit and 2048-bit are allowed."
|
|
)
|
|
|
|
except Exception as err:
|
|
if isinstance(err, AWSValidationException):
|
|
raise
|
|
raise AWSValidationException(
|
|
"The private key is not PEM-encoded or is not valid."
|
|
)
|
|
|
|
def validate_certificate(self):
|
|
try:
|
|
self._cert = cryptography.x509.load_pem_x509_certificate(
|
|
self.cert, default_backend()
|
|
)
|
|
|
|
now = datetime.datetime.utcnow()
|
|
if self._cert.not_valid_after < now:
|
|
raise AWSValidationException(
|
|
"The certificate has expired, is not valid."
|
|
)
|
|
|
|
if self._cert.not_valid_before > now:
|
|
raise AWSValidationException(
|
|
"The certificate is not in effect yet, is not valid."
|
|
)
|
|
|
|
# Extracting some common fields for ease of use
|
|
# Have to search through cert.subject for OIDs
|
|
self.common_name = self._cert.subject.get_attributes_for_oid(
|
|
cryptography.x509.OID_COMMON_NAME
|
|
)[0].value
|
|
|
|
except Exception as err:
|
|
if isinstance(err, AWSValidationException):
|
|
raise
|
|
raise AWSValidationException(
|
|
"The certificate is not PEM-encoded or is not valid."
|
|
)
|
|
|
|
def validate_chain(self):
|
|
try:
|
|
self._chain = []
|
|
|
|
for cert_armored in self.chain.split(b"-\n-"):
|
|
# Would leave encoded but Py2 does not have raw binary strings
|
|
cert_armored = cert_armored.decode()
|
|
|
|
# Fix missing -'s on split
|
|
cert_armored = re.sub(r"^----B", "-----B", cert_armored)
|
|
cert_armored = re.sub(r"E----$", "E-----", cert_armored)
|
|
cert = cryptography.x509.load_pem_x509_certificate(
|
|
cert_armored.encode(), default_backend()
|
|
)
|
|
self._chain.append(cert)
|
|
|
|
now = datetime.datetime.now()
|
|
if self._cert.not_valid_after < now:
|
|
raise AWSValidationException(
|
|
"The certificate chain has expired, is not valid."
|
|
)
|
|
|
|
if self._cert.not_valid_before > now:
|
|
raise AWSValidationException(
|
|
"The certificate chain is not in effect yet, is not valid."
|
|
)
|
|
|
|
except Exception as err:
|
|
if isinstance(err, AWSValidationException):
|
|
raise
|
|
raise AWSValidationException(
|
|
"The certificate is not PEM-encoded or is not valid."
|
|
)
|
|
|
|
def check(self):
|
|
# Basically, if the certificate is pending, and then checked again after 1 min
|
|
# It will appear as if its been validated
|
|
if (
|
|
self.type == "AMAZON_ISSUED"
|
|
and self.status == "PENDING_VALIDATION"
|
|
and (datetime.datetime.now() - self.created_at).total_seconds() > 60
|
|
): # 1min
|
|
self.status = "ISSUED"
|
|
|
|
def describe(self):
|
|
# 'RenewalSummary': {}, # Only when cert is amazon issued
|
|
if self._key.key_size == 1024:
|
|
key_algo = "RSA_1024"
|
|
elif self._key.key_size == 2048:
|
|
key_algo = "RSA_2048"
|
|
else:
|
|
key_algo = "EC_prime256v1"
|
|
|
|
# Look for SANs
|
|
san_obj = self._cert.extensions.get_extension_for_oid(
|
|
cryptography.x509.OID_SUBJECT_ALTERNATIVE_NAME
|
|
)
|
|
sans = []
|
|
if san_obj is not None:
|
|
sans = [item.value for item in san_obj.value]
|
|
|
|
result = {
|
|
"Certificate": {
|
|
"CertificateArn": self.arn,
|
|
"DomainName": self.common_name,
|
|
"InUseBy": [],
|
|
"Issuer": self._cert.issuer.get_attributes_for_oid(
|
|
cryptography.x509.OID_COMMON_NAME
|
|
)[0].value,
|
|
"KeyAlgorithm": key_algo,
|
|
"NotAfter": datetime_to_epoch(self._cert.not_valid_after),
|
|
"NotBefore": datetime_to_epoch(self._cert.not_valid_before),
|
|
"Serial": self._cert.serial_number,
|
|
"SignatureAlgorithm": self._cert.signature_algorithm_oid._name.upper().replace(
|
|
"ENCRYPTION", ""
|
|
),
|
|
"Status": self.status, # One of PENDING_VALIDATION, ISSUED, INACTIVE, EXPIRED, VALIDATION_TIMED_OUT, REVOKED, FAILED.
|
|
"Subject": "CN={0}".format(self.common_name),
|
|
"SubjectAlternativeNames": sans,
|
|
"Type": self.type, # One of IMPORTED, AMAZON_ISSUED
|
|
}
|
|
}
|
|
|
|
if self.type == "IMPORTED":
|
|
result["Certificate"]["ImportedAt"] = datetime_to_epoch(self.created_at)
|
|
else:
|
|
result["Certificate"]["CreatedAt"] = datetime_to_epoch(self.created_at)
|
|
result["Certificate"]["IssuedAt"] = datetime_to_epoch(self.created_at)
|
|
|
|
return result
|
|
|
|
def __str__(self):
|
|
return self.arn
|
|
|
|
def __repr__(self):
|
|
return "<Certificate>"
|
|
|
|
|
|
class AWSCertificateManagerBackend(BaseBackend):
|
|
def __init__(self, region):
|
|
super(AWSCertificateManagerBackend, self).__init__()
|
|
self.region = region
|
|
self._certificates = {}
|
|
self._idempotency_tokens = {}
|
|
|
|
def reset(self):
|
|
region = self.region
|
|
self.__dict__ = {}
|
|
self.__init__(region)
|
|
|
|
@staticmethod
|
|
def _arn_not_found(arn):
|
|
msg = "Certificate with arn {0} not found in account {1}".format(
|
|
arn, DEFAULT_ACCOUNT_ID
|
|
)
|
|
return AWSResourceNotFoundException(msg)
|
|
|
|
def _get_arn_from_idempotency_token(self, token):
|
|
"""
|
|
If token doesnt exist, return None, later it will be
|
|
set with an expiry and arn.
|
|
|
|
If token expiry has passed, delete entry and return None
|
|
|
|
Else return ARN
|
|
|
|
:param token: String token
|
|
:return: None or ARN
|
|
"""
|
|
now = datetime.datetime.now()
|
|
if token in self._idempotency_tokens:
|
|
if self._idempotency_tokens[token]["expires"] < now:
|
|
# Token has expired, new request
|
|
del self._idempotency_tokens[token]
|
|
return None
|
|
else:
|
|
return self._idempotency_tokens[token]["arn"]
|
|
|
|
return None
|
|
|
|
def _set_idempotency_token_arn(self, token, arn):
|
|
self._idempotency_tokens[token] = {
|
|
"arn": arn,
|
|
"expires": datetime.datetime.now() + datetime.timedelta(hours=1),
|
|
}
|
|
|
|
def import_cert(self, certificate, private_key, chain=None, arn=None):
|
|
if arn is not None:
|
|
if arn not in self._certificates:
|
|
raise self._arn_not_found(arn)
|
|
else:
|
|
# Will reuse provided ARN
|
|
bundle = CertBundle(
|
|
certificate, private_key, chain=chain, region=region, arn=arn
|
|
)
|
|
else:
|
|
# Will generate a random ARN
|
|
bundle = CertBundle(certificate, private_key, chain=chain, region=region)
|
|
|
|
self._certificates[bundle.arn] = bundle
|
|
|
|
return bundle.arn
|
|
|
|
def get_certificates_list(self, statuses):
|
|
"""
|
|
Get list of certificates
|
|
|
|
:return: List of certificates
|
|
:rtype: list of CertBundle
|
|
"""
|
|
for arn in self._certificates.keys():
|
|
cert = self.get_certificate(arn)
|
|
if not statuses or cert.status in statuses:
|
|
yield cert
|
|
|
|
def get_certificate(self, arn):
|
|
if arn not in self._certificates:
|
|
raise self._arn_not_found(arn)
|
|
|
|
cert_bundle = self._certificates[arn]
|
|
cert_bundle.check()
|
|
return cert_bundle
|
|
|
|
def delete_certificate(self, arn):
|
|
if arn not in self._certificates:
|
|
raise self._arn_not_found(arn)
|
|
|
|
del self._certificates[arn]
|
|
|
|
def request_certificate(
|
|
self,
|
|
domain_name,
|
|
domain_validation_options,
|
|
idempotency_token,
|
|
subject_alt_names,
|
|
):
|
|
if idempotency_token is not None:
|
|
arn = self._get_arn_from_idempotency_token(idempotency_token)
|
|
if arn is not None:
|
|
return arn
|
|
|
|
cert = CertBundle.generate_cert(
|
|
domain_name, region=self.region, sans=subject_alt_names
|
|
)
|
|
if idempotency_token is not None:
|
|
self._set_idempotency_token_arn(idempotency_token, cert.arn)
|
|
self._certificates[cert.arn] = cert
|
|
|
|
return cert.arn
|
|
|
|
def add_tags_to_certificate(self, arn, tags):
|
|
# get_cert does arn check
|
|
cert_bundle = self.get_certificate(arn)
|
|
|
|
for tag in tags:
|
|
key = tag["Key"]
|
|
value = tag.get("Value", None)
|
|
cert_bundle.tags[key] = value
|
|
|
|
def remove_tags_from_certificate(self, arn, tags):
|
|
# get_cert does arn check
|
|
cert_bundle = self.get_certificate(arn)
|
|
|
|
for tag in tags:
|
|
key = tag["Key"]
|
|
value = tag.get("Value", None)
|
|
|
|
try:
|
|
# If value isnt provided, just delete key
|
|
if value is None:
|
|
del cert_bundle.tags[key]
|
|
# If value is provided, only delete if it matches what already exists
|
|
elif cert_bundle.tags[key] == value:
|
|
del cert_bundle.tags[key]
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
acm_backends = {}
|
|
for region, ec2_backend in ec2_backends.items():
|
|
acm_backends[region] = AWSCertificateManagerBackend(region)
|