diff --git a/moto/acm/models.py b/moto/acm/models.py index 572809f8127e..ebb00399e62e 100644 --- a/moto/acm/models.py +++ b/moto/acm/models.py @@ -155,9 +155,29 @@ def __init__( self._cert = self.validate_certificate() # Extracting some common fields for ease of use # Have to search through cert.subject for OIDs - self.common_name: Any = self._cert.subject.get_attributes_for_oid( - OID_COMMON_NAME - )[0].value + + # Parse SANs once here so they can be reused in describe() without re-parsing + try: + san_obj: Any = self._cert.extensions.get_extension_for_oid( + cryptography.x509.OID_SUBJECT_ALTERNATIVE_NAME + ) + self.sans: list[str] = [str(item.value) for item in san_obj.value] + except cryptography.x509.ExtensionNotFound: + self.sans = [] + + # CN is optional per CAB Forum baseline requirements; fall back to first SAN + # (matching real AWS ACM DomainName behaviour) or empty string if no SANs either + cn_attrs = self._cert.subject.get_attributes_for_oid(OID_COMMON_NAME) + self.common_name: Any = ( + cn_attrs[0].value if cn_attrs else (self.sans[0] if self.sans else "") + ) + + # Parse issuer CN, also optional + issuer_cn_attrs = self._cert.issuer.get_attributes_for_oid(OID_COMMON_NAME) + self.issuer_common_name: str = str( + issuer_cn_attrs[0].value if issuer_cn_attrs else "" + ) + if chain is not None: self.validate_chain() @@ -361,25 +381,12 @@ def describe(self) -> dict[str, Any]: # Handle RSA keys key_algo = f"RSA_{self._key.key_size}" - # Look for SANs - try: - san_obj: Any = self._cert.extensions.get_extension_for_oid( - cryptography.x509.OID_SUBJECT_ALTERNATIVE_NAME - ) - except cryptography.x509.ExtensionNotFound: - san_obj = None - sans = [] - if san_obj is not None: - sans = [str(item.value) for item in san_obj.value] - result: dict[str, Any] = { "Certificate": { "CertificateArn": self.arn, "DomainName": self.common_name, "InUseBy": self.in_use_by, - "Issuer": self._cert.issuer.get_attributes_for_oid(OID_COMMON_NAME)[ - 0 - ].value, + "Issuer": self.issuer_common_name, "KeyAlgorithm": key_algo, "NotAfter": datetime_to_epoch(self._not_valid_after(self._cert)), "NotBefore": datetime_to_epoch(self._not_valid_before(self._cert)), @@ -389,7 +396,7 @@ def describe(self) -> dict[str, Any]: ), "Status": self.status, # One of PENDING_VALIDATION, ISSUED, INACTIVE, EXPIRED, VALIDATION_TIMED_OUT, REVOKED, FAILED. "Subject": f"CN={self.common_name}", - "SubjectAlternativeNames": sans, + "SubjectAlternativeNames": self.sans, "Type": self.type, # One of IMPORTED, AMAZON_ISSUED, "ExtendedKeyUsages": [], "RenewalEligibility": "INELIGIBLE", @@ -400,7 +407,7 @@ def describe(self) -> dict[str, Any]: if self.cert_authority_arn is not None: result["Certificate"]["CertificateAuthorityArn"] = self.cert_authority_arn - domain_names = set(sans + [self.common_name]) + domain_names = set(self.sans + ([self.common_name] if self.common_name else [])) validation_options = [] domain_name_status = "SUCCESS" if self.status == "ISSUED" else self.status diff --git a/tests/test_acm/test_acm.py b/tests/test_acm/test_acm.py index 88743c7c0c48..357b6fbacd5a 100644 --- a/tests/test_acm/test_acm.py +++ b/tests/test_acm/test_acm.py @@ -7,8 +7,14 @@ import boto3 import pytest from botocore.exceptions import ClientError +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509 import ( + DNSName, IPAddress, + NameOID, SubjectAlternativeName, load_pem_x509_certificate, ) @@ -75,6 +81,51 @@ def test_import_certificate(): assert "CertificateChain" in resp +@mock_aws +def test_import_certificate_without_cn(): + """CN is optional per CAB Forum baseline requirements since 2017. + import_certificate should succeed and DomainName should fall back to the first SAN.""" + # Generate a cert with SANs but no CN in the subject + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + subject = x509.Name([]) # empty subject — no CN + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test Issuer")]) + ) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) + .add_extension( + x509.SubjectAlternativeName( + [DNSName("app.test.example.com"), DNSName("app2.test.example.com")] + ), + critical=False, + ) + .sign(key, hashes.SHA256(), default_backend()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + client = boto3.client("acm", region_name="us-east-1") + resp = client.import_certificate(Certificate=cert_pem, PrivateKey=key_pem) + arn = resp["CertificateArn"] + + desc = client.describe_certificate(CertificateArn=arn)["Certificate"] + # DomainName should fall back to the first SAN when CN is absent + assert desc["DomainName"] == "app.test.example.com" + assert "app.test.example.com" in desc["SubjectAlternativeNames"] + assert "app2.test.example.com" in desc["SubjectAlternativeNames"] + + @mock_aws def test_import_certificate_with_tags(): client = boto3.client("acm", region_name="eu-central-1")