diff --git a/README.md b/README.md index 39dc49fea..1e4dd4176 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ It gets even better! Moto isn't just for Python code and it isn't just for S3. L |------------------------------------------------------------------------------| | Service Name | Decorator | Development Status | |------------------------------------------------------------------------------| +| ACM | @mock_acm | all endpoints done | +|------------------------------------------------------------------------------| | API Gateway | @mock_apigateway | core endpoints done | |------------------------------------------------------------------------------| | Autoscaling | @mock_autoscaling| core endpoints done | diff --git a/moto/__init__.py b/moto/__init__.py index 871aab881..a832def53 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -5,6 +5,7 @@ import logging __title__ = 'moto' __version__ = '1.0.1' +from .acm import mock_acm # flake8: noqa from .apigateway import mock_apigateway, mock_apigateway_deprecated # flake8: noqa from .autoscaling import mock_autoscaling, mock_autoscaling_deprecated # flake8: noqa from .awslambda import mock_lambda, mock_lambda_deprecated # flake8: noqa diff --git a/moto/acm/__init__.py b/moto/acm/__init__.py new file mode 100644 index 000000000..6cd8a4aa5 --- /dev/null +++ b/moto/acm/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals +from .models import acm_backends +from ..core.models import base_decorator + +acm_backend = acm_backends['us-east-1'] +mock_acm = base_decorator(acm_backends) diff --git a/moto/acm/models.py b/moto/acm/models.py new file mode 100644 index 000000000..de26529a4 --- /dev/null +++ b/moto/acm/models.py @@ -0,0 +1,395 @@ +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 + + +DEFAULT_ACCOUNT_ID = 123456789012 +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, 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, u"US"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.STATE_OR_PROVINCE_NAME, u"CA"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.LOCALITY_NAME, u"San Francisco"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.ORGANIZATION_NAME, u"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, u"US"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.ORGANIZATION_NAME, u"Amazon"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.ORGANIZATIONAL_UNIT_NAME, u"Server CA 1B"), + cryptography.x509.NameAttribute(cryptography.x509.NameOID.COMMON_NAME, u"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') + + 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.now() + 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, + '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 '' + + +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): + """ + Get list of certificates + + :return: List of certificates + :rtype: list of CertBundle + """ + for arn in self._certificates.keys(): + yield self.get_certificate(arn) + + 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, 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) diff --git a/moto/acm/responses.py b/moto/acm/responses.py new file mode 100644 index 000000000..7bf12bbb8 --- /dev/null +++ b/moto/acm/responses.py @@ -0,0 +1,224 @@ +from __future__ import unicode_literals +import json +import base64 + +from moto.core.responses import BaseResponse +from .models import acm_backends, AWSError, AWSValidationException + + +class AWSCertificateManagerResponse(BaseResponse): + + @property + def acm_backend(self): + """ + ACM Backend + + :return: ACM Backend object + :rtype: moto.acm.models.AWSCertificateManagerBackend + """ + return acm_backends[self.region] + + @property + def request_params(self): + try: + return json.loads(self.body) + except ValueError: + return {} + + def _get_param(self, param, default=None): + return self.request_params.get(param, default) + + def add_tags_to_certificate(self): + arn = self._get_param('CertificateArn') + tags = self._get_param('Tags') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + self.acm_backend.add_tags_to_certificate(arn, tags) + except AWSError as err: + return err.response() + + return '' + + def delete_certificate(self): + arn = self._get_param('CertificateArn') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + self.acm_backend.delete_certificate(arn) + except AWSError as err: + return err.response() + + return '' + + def describe_certificate(self): + arn = self._get_param('CertificateArn') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + cert_bundle = self.acm_backend.get_certificate(arn) + except AWSError as err: + return err.response() + + return json.dumps(cert_bundle.describe()) + + def get_certificate(self): + arn = self._get_param('CertificateArn') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + cert_bundle = self.acm_backend.get_certificate(arn) + except AWSError as err: + return err.response() + + result = { + 'Certificate': cert_bundle.cert.decode(), + 'CertificateChain': cert_bundle.chain.decode() + } + return json.dumps(result) + + def import_certificate(self): + """ + Returns errors on: + Certificate, PrivateKey or Chain not being properly formatted + Arn not existing if its provided + PrivateKey size > 2048 + Certificate expired or is not yet in effect + + Does not return errors on: + Checking Certificate is legit, or a selfsigned chain is provided + + :return: str(JSON) for response + """ + certificate = self._get_param('Certificate') + private_key = self._get_param('PrivateKey') + chain = self._get_param('CertificateChain') # Optional + current_arn = self._get_param('CertificateArn') # Optional + + # Simple parameter decoding. Rather do it here as its a data transport decision not part of the + # actual data + try: + certificate = base64.standard_b64decode(certificate) + except: + return AWSValidationException('The certificate is not PEM-encoded or is not valid.').response() + try: + private_key = base64.standard_b64decode(private_key) + except: + return AWSValidationException('The private key is not PEM-encoded or is not valid.').response() + if chain is not None: + try: + chain = base64.standard_b64decode(chain) + except: + return AWSValidationException('The certificate chain is not PEM-encoded or is not valid.').response() + + try: + arn = self.acm_backend.import_cert(certificate, private_key, chain=chain, arn=current_arn) + except AWSError as err: + return err.response() + + return json.dumps({'CertificateArn': arn}) + + def list_certificates(self): + certs = [] + + for cert_bundle in self.acm_backend.get_certificates_list(): + certs.append({ + 'CertificateArn': cert_bundle.arn, + 'DomainName': cert_bundle.common_name + }) + + result = {'CertificateSummaryList': certs} + return json.dumps(result) + + def list_tags_for_certificate(self): + arn = self._get_param('CertificateArn') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return {'__type': 'MissingParameter', 'message': msg}, dict(status=400) + + try: + cert_bundle = self.acm_backend.get_certificate(arn) + except AWSError as err: + return err.response() + + result = {'Tags': []} + # Tag "objects" can not contain the Value part + for key, value in cert_bundle.tags.items(): + tag_dict = {'Key': key} + if value is not None: + tag_dict['Value'] = value + result['Tags'].append(tag_dict) + + return json.dumps(result) + + def remove_tags_from_certificate(self): + arn = self._get_param('CertificateArn') + tags = self._get_param('Tags') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + self.acm_backend.remove_tags_from_certificate(arn, tags) + except AWSError as err: + return err.response() + + return '' + + def request_certificate(self): + domain_name = self._get_param('DomainName') + domain_validation_options = self._get_param('DomainValidationOptions') # is ignored atm + idempotency_token = self._get_param('IdempotencyToken') + subject_alt_names = self._get_param('SubjectAlternativeNames') + + if len(subject_alt_names) > 10: + # There is initial AWS limit of 10 + msg = 'An ACM limit has been exceeded. Need to request SAN limit to be raised' + return json.dumps({'__type': 'LimitExceededException', 'message': msg}), dict(status=400) + + try: + arn = self.acm_backend.request_certificate(domain_name, domain_validation_options, idempotency_token, subject_alt_names) + except AWSError as err: + return err.response() + + return json.dumps({'CertificateArn': arn}) + + def resend_validation_email(self): + arn = self._get_param('CertificateArn') + domain = self._get_param('Domain') + # ValidationDomain not used yet. + # Contains domain which is equal to or a subset of Domain + # that AWS will send validation emails to + # https://docs.aws.amazon.com/acm/latest/APIReference/API_ResendValidationEmail.html + # validation_domain = self._get_param('ValidationDomain') + + if arn is None: + msg = 'A required parameter for the specified action is not supplied.' + return json.dumps({'__type': 'MissingParameter', 'message': msg}), dict(status=400) + + try: + cert_bundle = self.acm_backend.get_certificate(arn) + + if cert_bundle.common_name != domain: + msg = 'Parameter Domain does not match certificate domain' + _type = 'InvalidDomainValidationOptionsException' + return json.dumps({'__type': _type, 'message': msg}), dict(status=400) + + except AWSError as err: + return err.response() + + return '' diff --git a/moto/acm/urls.py b/moto/acm/urls.py new file mode 100644 index 000000000..20acbb3f4 --- /dev/null +++ b/moto/acm/urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from .responses import AWSCertificateManagerResponse + +url_bases = [ + "https?://acm.(.+).amazonaws.com", +] + +url_paths = { + '{0}/$': AWSCertificateManagerResponse.dispatch, +} diff --git a/moto/acm/utils.py b/moto/acm/utils.py new file mode 100644 index 000000000..b3c441454 --- /dev/null +++ b/moto/acm/utils.py @@ -0,0 +1,7 @@ +import uuid + + +def make_arn_for_certificate(account_id, region_name): + # Example + # arn:aws:acm:eu-west-2:764371465172:certificate/c4b738b8-56fe-4b3a-b841-1c047654780b + return "arn:aws:acm:{0}:{1}:certificate/{2}".format(region_name, account_id, uuid.uuid4()) diff --git a/moto/backends.py b/moto/backends.py index 743b15801..da9d1821d 100644 --- a/moto/backends.py +++ b/moto/backends.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from moto.acm import acm_backends from moto.apigateway import apigateway_backends from moto.autoscaling import autoscaling_backends from moto.awslambda import lambda_backends @@ -34,6 +35,7 @@ from moto.sts import sts_backends from moto.xray import xray_backends BACKENDS = { + 'acm': acm_backends, 'apigateway': apigateway_backends, 'autoscaling': autoscaling_backends, 'cloudformation': cloudformation_backends, diff --git a/setup.py b/setup.py index 4b552de3e..a0f2ee098 100755 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ install_requires = [ "boto>=2.36.0", "boto3>=1.2.1", "cookies", + "cryptography>=2.0.0", "requests>=2.5", "xmltodict", "dicttoxml", diff --git a/tests/test_acm/resources/README.md b/tests/test_acm/resources/README.md new file mode 100644 index 000000000..fa39f2d01 --- /dev/null +++ b/tests/test_acm/resources/README.md @@ -0,0 +1,40 @@ +# Simple CA and server cert generation + +Commands: +``` +openssl genrsa -out ca.key 4096 +openssl req -x509 -new -nodes -key ca.key -sha512 -days 3650 -out ca.pem +openssl genrsa -out star_moto_com.key 2048 +openssl req -new -key star_moto_com.key -out star_moto_com.csr +openssl x509 -req -in star_moto_com.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out star_moto_com.pem -days 3650 -sha512 +``` + +Also appended GeoTrust cert to the ca.pem + +``` +-----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----- +``` diff --git a/tests/test_acm/resources/ca.key b/tests/test_acm/resources/ca.key new file mode 100644 index 000000000..dc3110483 --- /dev/null +++ b/tests/test_acm/resources/ca.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- diff --git a/tests/test_acm/resources/ca.pem b/tests/test_acm/resources/ca.pem new file mode 100644 index 000000000..29c4b6d28 --- /dev/null +++ b/tests/test_acm/resources/ca.pem @@ -0,0 +1,58 @@ +-----BEGIN CERTIFICATE----- +MIIFmTCCA4GgAwIBAgIJAJEFhPHteB99MA0GCSqGSIb3DQEBDQUAMGMxCzAJBgNV +BAYTAkdCMRIwEAYDVQQIDAlCZXJrc2hpcmUxDzANBgNVBAcMBlNsb3VnaDETMBEG +A1UECgwKTW90b1NlcnZlcjELMAkGA1UECwwCUUExDTALBgNVBAMMBE1vdG8wHhcN +MTcwOTIxMjA1MTM0WhcNMjcwOTE5MjA1MTM0WjBjMQswCQYDVQQGEwJHQjESMBAG +A1UECAwJQmVya3NoaXJlMQ8wDQYDVQQHDAZTbG91Z2gxEzARBgNVBAoMCk1vdG9T +ZXJ2ZXIxCzAJBgNVBAsMAlFBMQ0wCwYDVQQDDARNb3RvMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAo8Yi3CUvZ/AEHjGz2o8HUX70xtWqmOE85uoyQHQA +dGZHvu4S0QPMAmr49t14PyNWo4+6BAYwgatHIMuGCPWMnmgyHQq6/Had5awhviF6 +MwnAGaSsl4+YaVaCQLpPkaKVKtG16dLj49Q24uwy1d/XQnyKZpNWr5FrAayUizij +fnUrLpJcUtGnRGlSlfOxkvSiQ1tVFdVTjVQRkOGzJE2xbtVbJUu/RqyVgnKkIIl6 +SL4wJS+KI5oDDLNuGgTm2ajpcMJw6fn5XYpV0eGgNxTLE+ao4x8+FGnWOvdBi3Ei +Hf6W6w6Mv36bW8m4KAB7XH2f5DdkhmsICqhakfXlbJD2CYEg5MUUIoCSHGm6AL8Y +PYEOlG5l9Sbv3ydzVn6oGKxlBN3m3LJQezT1KcfHKxBS9uDPjSHjVGjpocxZeejA +0qtHwiKZbpkOS4GXMb/A0z+DtlN8R0m5XIOYbwNTiurNswH/XQ5dzH424KZ9drc9 +GTqqQNRBhGaX3pSrnd4qKDk+WV2H9y/WaZrxAQS2IXDeiqtjZoF8c/n+k7BnELcz ++cQ9hKjwxKfge+Rx4YCCm1KVRk2w4ZojlmhCeexWOmNONRibRFAUgXxk5HUv74bE +U5CSfr/zCX9kgpxpe8WTj/MbNZtDLO/XewPSk1lnFhmyfMyiY0EbA0iqjlvzhUVr +3IsCAwEAAaNQME4wHQYDVR0OBBYEFLVMZNPKo5ZWUjU/bH8lRPyWBOYRMB8GA1Ud +IwQYMBaAFLVMZNPKo5ZWUjU/bH8lRPyWBOYRMAwGA1UdEwQFMAMBAf8wDQYJKoZI +hvcNAQENBQADggIBAC5EmWeJIRkRZ47hm+Q6QLyiRvilrBYobwJsCEsUnfYut0+v +bX+1L39hvkFPK9gx1bta38ZeVVc2uwkC59FgVFyWwQG8FpFo5Urbxp1ErRwXBcbs +cjG/GubMYJ0aNUYRbV4phlIh1nXby4vqRGAGukvdzix5UO3HrnT/T/mOzdXtvZ0H +KjB7z+CT5m6fqB+vbnOnY8kJNvzl1oz22NAvGqNM32MA/7oFg9bfpLAuaHwsXxXj +5J2GfN82DaVvFvwJ1RYcvC1UsTm6b69YLrnMvimZ+kH4a9HNz7JZPEBrGg87EclN +QecwL0RvAYq2AN+u5bPJSa4eel3wnimfgaKqiVEebx6IcBeoCu4HEfz46AJ/mCoT +5Y+41t0RhpfawJWz4v7QuEf7lf7d0lvk27VmGWmAjQv3MrDIVpyPmSG73o5b9zos +i2aGClD2kn+YPY8/XoDUc8qFNhTxk/ey7xuUjwViKNDyprApT5yBTs7PazDN+JbK +/lLQJh2V1qq8utiCZhLGhZL353pCf56MNAB2MbVk5yyP+FhJ058ouQHerszeESTI +uuaSFKYdgOX9BHdEhCDebF3e9K3+6MeOgnfY12jzhX6dygQDcUAuIamLo5hEptBl +XD1cVBrMdxKLjxUVaYAWw2n8HBt97oMzrHhmr5JE4yIU2MYf2B5c0aewRrnG +-----END CERTIFICATE----- +-----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----- + diff --git a/tests/test_acm/resources/ca.srl b/tests/test_acm/resources/ca.srl new file mode 100644 index 000000000..ba4789240 --- /dev/null +++ b/tests/test_acm/resources/ca.srl @@ -0,0 +1 @@ +DF5D91CC8A8FBAA0 diff --git a/tests/test_acm/resources/star_moto_com-bad.pem b/tests/test_acm/resources/star_moto_com-bad.pem new file mode 100644 index 000000000..e79c7d2e6 --- /dev/null +++ b/tests/test_acm/resources/star_moto_com-bad.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEUDCCAjgCCQDfXZHMio+6oDANBgkqhkiG9w0BAQ0FADBjMQswCQYDVQQGEwJH +gbhdsgndthgngfdhujmnfhjmnftghjmQjESMBAGA1UECAwJQmVya3NoaXJlMQ8wDQYDVQQHDAZTbG91Z2gxEzARBgNVBAoM +Ck1vdG9TZXJ2ZXIxCzAJBgNVBAsMAlFBMQ0wCwYDVQQDDARNb3RvMB4XDTE3MDky +MTIxMjQ1MFoXDTI3MDkxOTIxMjQ1MFowcTELMAkGA1UEBhMCR0IxEjAQBgNVBAgM +CUJlcmtzaGlyZTEPMA0GA1UEBwwGU2xvdWdoMRMwEQYDVQQKDApNb3RvU2VydmVy +MRMwEQYDVQQLDApPcGVyYXRpb25zMRMwEQYDVQQDDAoqLm1vdG8uY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzC/oBkzwiIBEceSC/tSD7hkqs8AW +niDXbMgAQE9oxUxtkFESxiNa+EbAMLBFtBkPRvc3iKXh/cfLo7yP8VdqEIDmJCB/ +3T3ljjmrCMwquxYgZWMShnXZV0YfC19Vzq/gFpiyoaI2SI5NOFlfwhs5hFacTGkf +vpjJvf6HnrNJ7keQR+oGJNf7jVaCgOVdJ4lt7+98YDVde7jLx1DN+QbvViJQl60n +K3bmfuLiiw8154Eyi9DOcJE8AB+W7KpPdrmbPisR1EiqY0i0L62ZixN0rPi5hHF+ +ozwURL1axcmLjlhIFi8YhBCNcY6ThE7jrqgLIq1n6d8ezRxjDKmqfH1spQIDAQAB +MA0GCSqGSIb3DQEBDQUAA4ICAQCgl/EfjE0Jh3cqQgoOlaFq6L1iJVgy5sYKCC4r +OU4dHgifZ6/grqCJesGiS1Vh4L8XklN++C2aSL73lVtxXoCSopP8Yj0rOGeA6b+7 +Fetm4ZQYF61QtahC0L2fkvKXR+uz1I85ndSoMJPT8lbm7sYJuL81Si32NOo6kC6y +4eKzV4KznxdAf6XaQMKtMIyXO3PWTrjm5ayzS6UsmnBvULGDCaAQznFlVFdGNSHx +CaENICR0CBcB+vbL7FPC683a4afceM+aMcMVElWG5q8fxtgbL/aPhzfonhDGWOM4 +Rdg8x+yDdi7swxmWlcW5wlP8LpLxN/S3GR9j9IyelxUGmb20yTph3i1K6RM/Fm2W +PI8xdneA6qycUAJo93NfaCuNK7yBfK3uDLqmWlGh3xCG+I1JETLRbxYBWiqeVTb3 +qjHMrsgqTqjcaCiKR/5H2eVkdcr8mLxrV5niyBItDl1xGxj4LF8hDLormhaCjiBb +N1cMq5saj/BpoIanlqOWby6uRMYlZvuhwKQGPVWgfuRWKFzGbMWyPCxATbiU89Wb +IykNkT1zTCE/eZwH12T4A7jrBiWq8WNfIST0Z7MReE6Oz+M9Pxx7DyDzSb2Y1RmU +xNYd8CavZLCfns00xZSo+10deMoKVS9GgxSHcS4ELaVaBQwu35emiMJSLcK7iNGE +I4WVSA== +-----END CERTIFICATE----- diff --git a/tests/test_acm/resources/star_moto_com.csr b/tests/test_acm/resources/star_moto_com.csr new file mode 100644 index 000000000..9b745261f --- /dev/null +++ b/tests/test_acm/resources/star_moto_com.csr @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICtjCCAZ4CAQAwcTELMAkGA1UEBhMCR0IxEjAQBgNVBAgMCUJlcmtzaGlyZTEP +MA0GA1UEBwwGU2xvdWdoMRMwEQYDVQQKDApNb3RvU2VydmVyMRMwEQYDVQQLDApP +cGVyYXRpb25zMRMwEQYDVQQDDAoqLm1vdG8uY29tMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzC/oBkzwiIBEceSC/tSD7hkqs8AWniDXbMgAQE9oxUxt +kFESxiNa+EbAMLBFtBkPRvc3iKXh/cfLo7yP8VdqEIDmJCB/3T3ljjmrCMwquxYg +ZWMShnXZV0YfC19Vzq/gFpiyoaI2SI5NOFlfwhs5hFacTGkfvpjJvf6HnrNJ7keQ +R+oGJNf7jVaCgOVdJ4lt7+98YDVde7jLx1DN+QbvViJQl60nK3bmfuLiiw8154Ey +i9DOcJE8AB+W7KpPdrmbPisR1EiqY0i0L62ZixN0rPi5hHF+ozwURL1axcmLjlhI +Fi8YhBCNcY6ThE7jrqgLIq1n6d8ezRxjDKmqfH1spQIDAQABoAAwDQYJKoZIhvcN +AQELBQADggEBAAioQDDifgKjJXhK9w0+dvTdw80cdc8Y4/vkkJe6fqR5i6qM6Nbk +FQt0YNy4dScU6/u+YBFRqRfSKS1QOh2Uq6pKoloHxlhf9gh/8aqjvgN3qy3Ncyya +D9pqlSe70NIHIIBB3EDyocTFtscEX4s8ysuGDxKysWsL57YrDCbVjliK6sRIDPOk +CqkQJXjbQdi4bwqE5iYgheQEFQV+uGpdsV7ZZi4E7KcFmIKk3PzattWUd8+bglPC +/rrzb97nRiz8J5XzoqrEPA+0ZCuQ6cvbbEOWggs5kMJe/MfihH0yGA5kIQNmTmRK +1PLqpTE6g293pgeBcWsuydIBB9pUmSMDT1I= +-----END CERTIFICATE REQUEST----- diff --git a/tests/test_acm/resources/star_moto_com.key b/tests/test_acm/resources/star_moto_com.key new file mode 100644 index 000000000..f8585a81e --- /dev/null +++ b/tests/test_acm/resources/star_moto_com.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAzC/oBkzwiIBEceSC/tSD7hkqs8AWniDXbMgAQE9oxUxtkFES +xiNa+EbAMLBFtBkPRvc3iKXh/cfLo7yP8VdqEIDmJCB/3T3ljjmrCMwquxYgZWMS +hnXZV0YfC19Vzq/gFpiyoaI2SI5NOFlfwhs5hFacTGkfvpjJvf6HnrNJ7keQR+oG +JNf7jVaCgOVdJ4lt7+98YDVde7jLx1DN+QbvViJQl60nK3bmfuLiiw8154Eyi9DO +cJE8AB+W7KpPdrmbPisR1EiqY0i0L62ZixN0rPi5hHF+ozwURL1axcmLjlhIFi8Y +hBCNcY6ThE7jrqgLIq1n6d8ezRxjDKmqfH1spQIDAQABAoIBAECa588WiQSnkQB4 +TPpUQ2oSjHBTVtSxj3fb0DiI552FkSUYgdgvV5k2yZieLW/Ofgb2MZwK4HZrwQMN +pn22KtkN78N+hPZ7nyZhGLyv3NVVKurpbfMdVqdGiIwQnhXHkB+WMO7zZDmQzN4H +aUUBWDGHNez3VhP4Q9zZrA+Kqtm5OYmkDQYO6LqR+OQmqmLEeJOsbR9EUXDuhd5O +CyWkBwZP5JcmP985hZ7dGTZJ9ehFLYq6i6ZLmuSkt6QS/jf+AdLjd6b2b326CUwJ +xEf3ZwQ9b+BPZ+gCx91FsooRqa3NbFhvGJ34sN25xzppa5+IDDk5XZnXJugwq5Sg +t5f07AECgYEA/G3+GIXlnyLwOksFFHQp1yZIlXxeGhVZyDwSHkXcAwRnTWZHHftr +fZ2TQkyYxsySx/pP6PUHQDwhZKFSLIpc2Di2ZIUPZSNYrzEqCZIBTO9+2DBshjs6 +2tUyvpD68lZsQpjipD6wNF+308Px5hAg5mKr5IstHCcXkJcxa3v5kVMCgYEAzxM8 +PbGQmSNalcO1cBcj/f7sbEbJOtdb94ig8KRc8ImL3ZM9dJOugqc0EchMzUzFD4H/ +CjaC25CjxfBZSxV+0D6spUeLKogdwoyAM08/ZwD6BuMKZlbim84wV0VZBXjSaihq +qdaLnx0qC7/DPLf2zQfWkJCcqvPzMf+W6PgQcycCgYA3VW0jtwY0shXy0UsVxrj9 +Ppkem5qNIS0DJZfbJvkpeCek4cypF9niOU50dBHxUhrC12345O1n+UZgprQ6q0Ha +6+OfeUN8qhjgnmhWnLjIQp+NiF/htM4b9iwfdexsfuFQX+8ejddWQ70qIIPAKLzt +g6eme5Ox3ifePCZLJ2v3nQKBgFBeitb2/8Qv8IyH9PeYQ6PlOSWdI6TuyQb9xFkh +seC5wcsxxnxkhSq4coEkWIql7SXjsnToS0mkjavZaQ63PQzeBmvvpJfRVJuZpHhF +nboAqwnZPMQTnMgT8rcsdyykhCYnoZ5hYrdSvmro9oGudN+G10QsnGHNZOpW5N9u +yBOpAoGASb5aNQU9QFT8kyxZB+nKAuh6efa6HNMXMdEoYD9VOm0zPMRtorZdX4s4 +nYctHiIUmVAIXtkG0tR+cOelv2qKR5EfOo3HZtaP+fbOd0IykoZcbQJpc3PwDcCq +WgkRhN4dCVYD3ZXFYlUrCoDca7JE1KxmIbrlVSAaYilkt7UB3Qk= +-----END RSA PRIVATE KEY----- diff --git a/tests/test_acm/resources/star_moto_com.pem b/tests/test_acm/resources/star_moto_com.pem new file mode 100644 index 000000000..6d599d53e --- /dev/null +++ b/tests/test_acm/resources/star_moto_com.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEUDCCAjgCCQDfXZHMio+6oDANBgkqhkiG9w0BAQ0FADBjMQswCQYDVQQGEwJH +QjESMBAGA1UECAwJQmVya3NoaXJlMQ8wDQYDVQQHDAZTbG91Z2gxEzARBgNVBAoM +Ck1vdG9TZXJ2ZXIxCzAJBgNVBAsMAlFBMQ0wCwYDVQQDDARNb3RvMB4XDTE3MDky +MTIxMjQ1MFoXDTI3MDkxOTIxMjQ1MFowcTELMAkGA1UEBhMCR0IxEjAQBgNVBAgM +CUJlcmtzaGlyZTEPMA0GA1UEBwwGU2xvdWdoMRMwEQYDVQQKDApNb3RvU2VydmVy +MRMwEQYDVQQLDApPcGVyYXRpb25zMRMwEQYDVQQDDAoqLm1vdG8uY29tMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzC/oBkzwiIBEceSC/tSD7hkqs8AW +niDXbMgAQE9oxUxtkFESxiNa+EbAMLBFtBkPRvc3iKXh/cfLo7yP8VdqEIDmJCB/ +3T3ljjmrCMwquxYgZWMShnXZV0YfC19Vzq/gFpiyoaI2SI5NOFlfwhs5hFacTGkf +vpjJvf6HnrNJ7keQR+oGJNf7jVaCgOVdJ4lt7+98YDVde7jLx1DN+QbvViJQl60n +K3bmfuLiiw8154Eyi9DOcJE8AB+W7KpPdrmbPisR1EiqY0i0L62ZixN0rPi5hHF+ +ozwURL1axcmLjlhIFi8YhBCNcY6ThE7jrqgLIq1n6d8ezRxjDKmqfH1spQIDAQAB +MA0GCSqGSIb3DQEBDQUAA4ICAQCgl/EfjE0Jh3cqQgoOlaFq6L1iJVgy5sYKCC4r +OU4dHgifZ6/grqCJesGiS1Vh4L8XklN++C2aSL73lVtxXoCSopP8Yj0rOGeA6b+7 +Fetm4ZQYF61QtahC0L2fkvKXR+uz1I85ndSoMJPT8lbm7sYJuL81Si32NOo6kC6y +4eKzV4KznxdAf6XaQMKtMIyXO3PWTrjm5ayzS6UsmnBvULGDCaAQznFlVFdGNSHx +CaENICR0CBcB+vbL7FPC683a4afceM+aMcMVElWG5q8fxtgbL/aPhzfonhDGWOM4 +Rdg8x+yDdi7swxmWlcW5wlP8LpLxN/S3GR9j9IyelxUGmb20yTph3i1K6RM/Fm2W +PI8xdneA6qycUAJo93NfaCuNK7yBfK3uDLqmWlGh3xCG+I1JETLRbxYBWiqeVTb3 +qjHMrsgqTqjcaCiKR/5H2eVkdcr8mLxrV5niyBItDl1xGxj4LF8hDLormhaCjiBb +N1cMq5saj/BpoIanlqOWby6uRMYlZvuhwKQGPVWgfuRWKFzGbMWyPCxATbiU89Wb +IykNkT1zTCE/eZwH12T4A7jrBiWq8WNfIST0Z7MReE6Oz+M9Pxx7DyDzSb2Y1RmU +xNYd8CavZLCfns00xZSo+10deMoKVS9GgxSHcS4ELaVaBQwu35emiMJSLcK7iNGE +I4WVSA== +-----END CERTIFICATE----- diff --git a/tests/test_acm/test_acm.py b/tests/test_acm/test_acm.py new file mode 100644 index 000000000..96e362d1e --- /dev/null +++ b/tests/test_acm/test_acm.py @@ -0,0 +1,357 @@ +from __future__ import unicode_literals + +import os +import boto3 +from freezegun import freeze_time +import sure # noqa + +from botocore.exceptions import ClientError + +from moto import mock_acm + + +RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), 'resources') +_GET_RESOURCE = lambda x: open(os.path.join(RESOURCE_FOLDER, x), 'rb').read() +CA_CRT = _GET_RESOURCE('ca.pem') +CA_KEY = _GET_RESOURCE('ca.key') +SERVER_CRT = _GET_RESOURCE('star_moto_com.pem') +SERVER_COMMON_NAME = '*.moto.com' +SERVER_CRT_BAD = _GET_RESOURCE('star_moto_com-bad.pem') +SERVER_KEY = _GET_RESOURCE('star_moto_com.key') +BAD_ARN = 'arn:aws:acm:us-east-2:123456789012:certificate/_0000000-0000-0000-0000-000000000000' + + +def _import_cert(client): + response = client.import_certificate( + Certificate=SERVER_CRT, + PrivateKey=SERVER_KEY, + CertificateChain=CA_CRT + ) + return response['CertificateArn'] + + +# Also tests GetCertificate +@mock_acm +def test_import_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + resp = client.import_certificate( + Certificate=SERVER_CRT, + PrivateKey=SERVER_KEY, + CertificateChain=CA_CRT + ) + resp = client.get_certificate(CertificateArn=resp['CertificateArn']) + + resp['Certificate'].should.equal(SERVER_CRT.decode()) + resp.should.contain('CertificateChain') + + +@mock_acm +def test_import_bad_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.import_certificate( + Certificate=SERVER_CRT_BAD, + PrivateKey=SERVER_KEY, + ) + except ClientError as err: + err.response['Error']['Code'].should.equal('ValidationException') + else: + raise RuntimeError('Should of raised ValidationException') + + +@mock_acm +def test_list_certificates(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + resp = client.list_certificates() + len(resp['CertificateSummaryList']).should.equal(1) + + resp['CertificateSummaryList'][0]['CertificateArn'].should.equal(arn) + resp['CertificateSummaryList'][0]['DomainName'].should.equal(SERVER_COMMON_NAME) + + +@mock_acm +def test_get_invalid_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.get_certificate(CertificateArn=BAD_ARN) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +# Also tests deleting invalid certificate +@mock_acm +def test_delete_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + # If it does not raise an error and the next call does, all is fine + client.delete_certificate(CertificateArn=arn) + + try: + client.delete_certificate(CertificateArn=arn) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +@mock_acm +def test_describe_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + resp = client.describe_certificate(CertificateArn=arn) + resp['Certificate']['CertificateArn'].should.equal(arn) + resp['Certificate']['DomainName'].should.equal(SERVER_COMMON_NAME) + resp['Certificate']['Issuer'].should.equal('Moto') + resp['Certificate']['KeyAlgorithm'].should.equal('RSA_2048') + resp['Certificate']['Status'].should.equal('ISSUED') + resp['Certificate']['Type'].should.equal('IMPORTED') + + +@mock_acm +def test_describe_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.describe_certificate(CertificateArn=BAD_ARN) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +# Also tests ListTagsForCertificate +@mock_acm +def test_add_tags_to_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + client.add_tags_to_certificate( + CertificateArn=arn, + Tags=[ + {'Key': 'key1', 'Value': 'value1'}, + {'Key': 'key2'}, + ] + ) + + resp = client.list_tags_for_certificate(CertificateArn=arn) + tags = {item['Key']: item.get('Value', '__NONE__') for item in resp['Tags']} + + tags.should.contain('key1') + tags.should.contain('key2') + tags['key1'].should.equal('value1') + + # This way, it ensures that we can detect if None is passed back when it shouldnt, + # as we store keys without values with a value of None, but it shouldnt be passed back + tags['key2'].should.equal('__NONE__') + + +@mock_acm +def test_add_tags_to_invalid_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.add_tags_to_certificate( + CertificateArn=BAD_ARN, + Tags=[ + {'Key': 'key1', 'Value': 'value1'}, + {'Key': 'key2'}, + ] + ) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +@mock_acm +def test_list_tags_for_invalid_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.list_tags_for_certificate(CertificateArn=BAD_ARN) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +@mock_acm +def test_remove_tags_from_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + client.add_tags_to_certificate( + CertificateArn=arn, + Tags=[ + {'Key': 'key1', 'Value': 'value1'}, + {'Key': 'key2'}, + {'Key': 'key3', 'Value': 'value3'}, + {'Key': 'key4', 'Value': 'value4'}, + ] + ) + + client.remove_tags_from_certificate( + CertificateArn=arn, + Tags=[ + {'Key': 'key1', 'Value': 'value2'}, # Should not remove as doesnt match + {'Key': 'key2'}, # Single key removal + {'Key': 'key3', 'Value': 'value3'}, # Exact match removal + {'Key': 'key4'} # Partial match removal + ] + ) + + resp = client.list_tags_for_certificate(CertificateArn=arn) + tags = {item['Key']: item.get('Value', '__NONE__') for item in resp['Tags']} + + for key in ('key2', 'key3', 'key4'): + tags.should_not.contain(key) + + tags.should.contain('key1') + + +@mock_acm +def test_remove_tags_from_invalid_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + try: + client.remove_tags_from_certificate( + CertificateArn=BAD_ARN, + Tags=[ + {'Key': 'key1', 'Value': 'value1'}, + {'Key': 'key2'}, + ] + ) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +@mock_acm +def test_resend_validation_email(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + client.resend_validation_email( + CertificateArn=arn, + Domain='*.moto.com', + ValidationDomain='NOTUSEDYET' + ) + # Returns nothing, boto would raise Exceptions otherwise + + +@mock_acm +def test_resend_validation_email_invalid(): + client = boto3.client('acm', region_name='eu-central-1') + arn = _import_cert(client) + + try: + client.resend_validation_email( + CertificateArn=arn, + Domain='no-match.moto.com', + ValidationDomain='NOTUSEDYET' + ) + except ClientError as err: + err.response['Error']['Code'].should.equal('InvalidDomainValidationOptionsException') + else: + raise RuntimeError('Should of raised InvalidDomainValidationOptionsException') + + try: + client.resend_validation_email( + CertificateArn=BAD_ARN, + Domain='no-match.moto.com', + ValidationDomain='NOTUSEDYET' + ) + except ClientError as err: + err.response['Error']['Code'].should.equal('ResourceNotFoundException') + else: + raise RuntimeError('Should of raised ResourceNotFoundException') + + +@mock_acm +def test_request_certificate(): + client = boto3.client('acm', region_name='eu-central-1') + + resp = client.request_certificate( + DomainName='google.com', + SubjectAlternativeNames=['google.com', 'www.google.com', 'mail.google.com'], + ) + resp.should.contain('CertificateArn') + + +# # Also tests the SAN code +# # requires Pull: https://github.com/spulec/freezegun/pull/210 +# @freeze_time("2012-01-01 12:00:00", as_arg=True) +# @mock_acm +# def test_request_certificate(frozen_time): +# # After requesting a certificate, it should then auto-validate after 1 minute +# # Some sneaky programming for that ;-) +# client = boto3.client('acm', region_name='eu-central-1') +# +# resp = client.request_certificate( +# DomainName='google.com', +# SubjectAlternativeNames=['google.com', 'www.google.com', 'mail.google.com'], +# ) +# arn = resp['CertificateArn'] +# +# resp = client.describe_certificate(CertificateArn=arn) +# resp['Certificate']['CertificateArn'].should.equal(arn) +# resp['Certificate']['DomainName'].should.equal('google.com') +# resp['Certificate']['Issuer'].should.equal('Amazon') +# resp['Certificate']['KeyAlgorithm'].should.equal('RSA_2048') +# resp['Certificate']['Status'].should.equal('PENDING_VALIDATION') +# resp['Certificate']['Type'].should.equal('AMAZON_ISSUED') +# len(resp['Certificate']['SubjectAlternativeNames']).should.equal(3) +# +# # Move time +# frozen_time.move_to('2012-01-01 12:02:00') +# resp = client.describe_certificate(CertificateArn=arn) +# resp['Certificate']['CertificateArn'].should.equal(arn) +# resp['Certificate']['Status'].should.equal('ISSUED') +# +# +# # requires Pull: https://github.com/spulec/freezegun/pull/210 +# @freeze_time("2012-01-01 12:00:00", as_arg=True) +# @mock_acm +# def test_request_certificate(frozen_time): +# # After requesting a certificate, it should then auto-validate after 1 minute +# # Some sneaky programming for that ;-) +# client = boto3.client('acm', region_name='eu-central-1') +# +# resp = client.request_certificate( +# IdempotencyToken='test_token', +# DomainName='google.com', +# SubjectAlternativeNames=['google.com', 'www.google.com', 'mail.google.com'], +# ) +# original_arn = resp['CertificateArn'] +# +# # Should be able to request a certificate multiple times in an hour +# # after that it makes a new one +# for time_intervals in ('2012-01-01 12:15:00', '2012-01-01 12:30:00', '2012-01-01 12:45:00'): +# frozen_time.move_to(time_intervals) +# resp = client.request_certificate( +# IdempotencyToken='test_token', +# DomainName='google.com', +# SubjectAlternativeNames=['google.com', 'www.google.com', 'mail.google.com'], +# ) +# arn = resp['CertificateArn'] +# arn.should.equal(original_arn) +# +# # Move time +# frozen_time.move_to('2012-01-01 13:01:00') +# resp = client.request_certificate( +# IdempotencyToken='test_token', +# DomainName='google.com', +# SubjectAlternativeNames=['google.com', 'www.google.com', 'mail.google.com'], +# ) +# arn = resp['CertificateArn'] +# arn.should_not.equal(original_arn)