From 7af589b7093f04aedde2b6575f0b425e6315681c Mon Sep 17 00:00:00 2001 From: Sayali Charhate Date: Tue, 24 Mar 2026 14:42:13 -0700 Subject: [PATCH 1/3] Support ACM certs without CN --- moto/acm/models.py | 43 ++++++++++++++++++--------------- tests/test_acm/test_acm.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/moto/acm/models.py b/moto/acm/models.py index 572809f8127e..c4920f3d9fe0 100644 --- a/moto/acm/models.py +++ b/moto/acm/models.py @@ -155,9 +155,27 @@ 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 = issuer_cn_attrs[0].value if issuer_cn_attrs else "" + if chain is not None: self.validate_chain() @@ -361,25 +379,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 +394,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 +405,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..0eea80b9b618 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,49 @@ 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") From 992c60a03f093060f9e9697aacb1c3dbb1cd5671 Mon Sep 17 00:00:00 2001 From: Sayali Charhate Date: Fri, 27 Mar 2026 16:36:35 -0700 Subject: [PATCH 2/3] lint --- moto/acm/models.py | 4 +++- tests/test_acm/test_acm.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/moto/acm/models.py b/moto/acm/models.py index c4920f3d9fe0..c28761d414a1 100644 --- a/moto/acm/models.py +++ b/moto/acm/models.py @@ -174,7 +174,9 @@ def __init__( # Parse issuer CN, also optional issuer_cn_attrs = self._cert.issuer.get_attributes_for_oid(OID_COMMON_NAME) - self.issuer_common_name: str = issuer_cn_attrs[0].value if issuer_cn_attrs else "" + self.issuer_common_name: str = ( + issuer_cn_attrs[0].value if issuer_cn_attrs else "" + ) if chain is not None: self.validate_chain() diff --git a/tests/test_acm/test_acm.py b/tests/test_acm/test_acm.py index 0eea80b9b618..357b6fbacd5a 100644 --- a/tests/test_acm/test_acm.py +++ b/tests/test_acm/test_acm.py @@ -101,7 +101,9 @@ def test_import_certificate_without_cn(): .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")]), + x509.SubjectAlternativeName( + [DNSName("app.test.example.com"), DNSName("app2.test.example.com")] + ), critical=False, ) .sign(key, hashes.SHA256(), default_backend()) From 8d4a53444daa278893167e4c5d26a35a79e36c47 Mon Sep 17 00:00:00 2001 From: Sayali Charhate Date: Mon, 30 Mar 2026 10:31:47 -0700 Subject: [PATCH 3/3] lint --- moto/acm/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moto/acm/models.py b/moto/acm/models.py index c28761d414a1..ebb00399e62e 100644 --- a/moto/acm/models.py +++ b/moto/acm/models.py @@ -174,7 +174,7 @@ def __init__( # Parse issuer CN, also optional issuer_cn_attrs = self._cert.issuer.get_attributes_for_oid(OID_COMMON_NAME) - self.issuer_common_name: str = ( + self.issuer_common_name: str = str( issuer_cn_attrs[0].value if issuer_cn_attrs else "" )