Add san module (#10478)

Contains san.DNSName, san.IPAddress, and a parent class san.SAN.

Split out from #10468 as a standalone PR. To see examples of how it's
intended to be used, please see that PR.

The constructor for DNSName incorporates the same validation done in
`enforce_domain_sanity`, and the tests from `enforce_domain_sanity` are
copied here as well. The goal is to delete `enforce_domain_sanity`
entirely as part of #10468.

In support of #10346.
This commit is contained in:
Jacob Hoffman-Andrews 2025-11-04 19:44:18 -08:00 committed by GitHub
parent 2ec8320763
commit ada2c547cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 389 additions and 12 deletions

View file

@ -121,18 +121,12 @@ class ClientV2:
:rtype: OrderResource
"""
csr = x509.load_pem_x509_csr(csr_pem)
dnsNames = crypto_util.get_names_from_subject_and_extensions(csr.subject, csr.extensions)
try:
san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
except x509.ExtensionNotFound:
ipNames = []
else:
ipNames = san_ext.value.get_values_for_type(x509.IPAddress)
dns_names, ip_addrs = crypto_util.get_identifiers_from_x509(csr.subject, csr.extensions)
identifiers = []
for name in dnsNames:
for name in dns_names:
identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_FQDN,
value=name))
for ip in ipNames:
for ip in ip_addrs:
identifiers.append(messages.Identifier(typ=messages.IDENTIFIER_IP,
value=str(ip)))
if profile is None:

View file

@ -112,7 +112,7 @@ def make_csr(
def get_names_from_subject_and_extensions(
subject: x509.Name, exts: x509.Extensions
) -> list[str]:
"""Gets all DNS SAN names as well as the first Common Name from subject.
"""Gets all DNS SANs as well as the first Common Name from subject.
:param subject: Name of the x509 object, which may include Common Name
:type subject: `cryptography.x509.Name`
@ -122,6 +122,24 @@ def get_names_from_subject_and_extensions(
:returns: List of DNS Subject Alternative Names and first Common Name
:rtype: `list` of `str`
"""
dns_names, _ = get_identifiers_from_x509(subject, exts)
return dns_names
def get_identifiers_from_x509(
subject: x509.Name, exts: x509.Extensions
) -> tuple[list[str], list[str]]:
"""Gets all DNS and/or IP address SANs as well as the first Common Name from subject.
The CN will be first in the list of DNS names, if present.
:param subject: Name of the x509 object, which may include Common Name
:type subject: `cryptography.x509.Name`
:param exts: Extensions of the x509 object, which may include SANs
:type exts: `cryptography.x509.Extensions`
:returns: Tuple containing DNS names and IP addresses.
"""
# We know these are always `str` because `bytes` is only possible for
# other OIDs.
cns = [
@ -132,15 +150,17 @@ def get_names_from_subject_and_extensions(
san_ext = exts.get_extension_for_class(x509.SubjectAlternativeName)
except x509.ExtensionNotFound:
dns_names = []
ip_addresses = []
else:
dns_names = san_ext.value.get_values_for_type(x509.DNSName)
ip_addresses = [str(ip) for ip in san_ext.value.get_values_for_type(x509.IPAddress)]
if not cns:
return dns_names
return dns_names, ip_addresses
else:
# We only include the first CN, if there are multiple. This matches
# the behavior of the previous implementation using pyOpenSSL.
return [cns[0]] + [d for d in dns_names if d != cns[0]]
return [cns[0]] + [d for d in dns_names if d != cns[0]], ip_addresses
def _cryptography_cert_or_req_san(

View file

@ -0,0 +1,158 @@
"""Types for representing IP addresses and DNS names internal to Certbot."""
import ipaddress
from abc import abstractmethod
from typing import Any, Iterable
from acme import crypto_util as acme_crypto_util
from cryptography import x509
from certbot import errors
class SAN:
"""A domain or IP address.
These are Certbot-internal types, independent of the acme module's messages.Identifier.
"""
@abstractmethod
def is_wildcard(self) -> bool:
"""Return True if this is a wildcard DNS name."""
class DNSName(SAN):
"""An FQDN or wildcard domain name.
Raises ConfigurationError if the domain name is syntactically invalid.
Normalizes inputs by converting to lowercase and removing a trailing dot, if present.
"""
def __init__(self, dns_name: str) -> None:
if not isinstance(dns_name, str):
raise TypeError("tried to initialize DNSName with non-str")
try:
dns_name.encode('ascii')
except UnicodeError:
raise errors.ConfigurationError("Non-ASCII domain names not supported. "
"To issue for an Internationalized Domain Name, use Punycode.")
dns_name = dns_name.lower()
# Remove trailing dot
dns_name = dns_name.removesuffix(".")
# Separately check for odd "domains" like "http://example.com" to fail
# fast and provide a clear error message
for scheme in ["http", "https"]: # Other schemes seem unlikely
if dns_name.startswith("{0}://".format(scheme)):
raise errors.ConfigurationError(
"Requested name {0} appears to be a URL, not a FQDN. "
"Try again without the leading \"{1}://\".".format(
dns_name, scheme
)
)
try:
IPAddress(dns_name)
raise errors.ConfigurationError(
"Requested name {0} is an IP address. The Let's Encrypt "
"certificate authority will not issue certificates for a "
"bare IP address.".format(dns_name))
except ValueError:
pass
# FQDN checks according to RFC 2181: domain name should be less than 255
# octets (inclusive). And each label is 1 - 63 octets (inclusive).
# https://tools.ietf.org/html/rfc2181#section-11
msg = "Requested domain {0} is not a FQDN because".format(dns_name)
if len(dns_name) > 255:
raise errors.ConfigurationError("{0} it is too long.".format(msg))
labels = dns_name.split('.')
for l in labels:
if not l:
raise errors.ConfigurationError("{0} it contains an empty label.".format(msg))
if len(l) > 63:
raise errors.ConfigurationError("{0} label {1} is too long.".format(msg, l))
self.dns_name = dns_name
def __str__(self) -> str:
return self.dns_name
def __hash__(self) -> int:
return hash(self.dns_name)
def __repr__(self) -> str:
return 'DNS(%s)' % self.dns_name
def __eq__(self, other: Any) -> bool:
match other:
case DNSName():
return self.dns_name == other.dns_name
case IPAddress():
return False
case _:
raise TypeError(f"DNSName SAN compared to non-SAN: {type(other)}")
def is_wildcard(self) -> bool:
"""Return True if this DNS name is a wildcard."""
return self.dns_name.startswith('*.')
class IPAddress(SAN):
"""An IP address (IPv4 or IPv6).
Validated upon construction.
"""
def __init__(self, ip_address: str) -> None:
self.ip_address = ipaddress.ip_address(ip_address)
def __str__(self) -> str:
return str(self.ip_address)
def __hash__(self) -> int:
return hash(self.ip_address)
def __repr__(self) -> str:
return 'IP(%s)' % self.ip_address
def __eq__(self, other: Any) -> bool:
match other:
case IPAddress():
return self.ip_address == other.ip_address
case DNSName():
return False
case _:
raise TypeError(f"IPAddress SAN compared to non-SAN: {type(other)}")
def is_wildcard(self) -> bool:
"""Always False."""
return False
def split(sans: Iterable[SAN]) -> tuple[list[DNSName], list[IPAddress]]:
"""Split a list of SANs into a list of DNSNames and one of IPAddress, in that order."""
domains = []
ip_addresses = []
for s in sans:
match s:
case IPAddress():
ip_addresses.append(s)
case DNSName():
domains.append(s)
case _:
raise TypeError(f"SAN of type {type(s)}")
return domains, ip_addresses
def display(sans: Iterable[SAN]) -> str:
"""Return the list of SANs in string form, separated by comma and space."""
return ", ".join(map(str, sans))
def from_x509(subject: x509.Name, exts: x509.Extensions) -> tuple[list[DNSName], list[IPAddress]]:
"""Get all DNS names and IP addresses, plus the first Common Name from subject.
The CN will be first in the list, if present. It will always be interpreted
as a DNS name.
:param subject: Name of the x509 object, which may include Common Name
:type subject: `cryptography.x509.Name`
:param exts: Extensions of the x509 object, which may include SANs
:type exts: `cryptography.x509.Extensions`
:returns: Tuple containing a list of DNSNames and a list of IPAddresses
"""
dns_names, ip_addresses = acme_crypto_util.get_identifiers_from_x509(subject, exts)
return [DNSName(d) for d in dns_names], [IPAddress(i) for i in ip_addresses]

View file

@ -0,0 +1,205 @@
"""Tests for the san module"""
import ipaddress
from datetime import datetime
import pytest
import unittest
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from certbot import errors
from certbot._internal import san
class SanTest(unittest.TestCase):
def test_str(self) -> None:
assert str(san.DNSName("example.com")) == "example.com"
assert str(san.IPAddress("192.168.1.1")) == "192.168.1.1"
def test_hash(self) -> None:
assert hash(san.DNSName("example.com")) == hash(san.DNSName("example.com"))
assert hash(san.DNSName("EXAMPLE.COM.")) == hash(san.DNSName("example.com"))
assert hash(san.IPAddress("192.168.1.1")) == hash(san.IPAddress("192.168.1.1"))
def test_repr(self) -> None:
assert repr(san.DNSName("example.com")) == "DNS(example.com)"
assert repr(san.IPAddress("192.168.1.1")) == "IP(192.168.1.1)"
def test_eq(self) -> None:
with pytest.raises(TypeError):
san.DNSName("example.com") == "example.com" # pylint: disable=expression-not-assigned
with pytest.raises(TypeError):
"example.com" == san.DNSName("example.com") # pylint: disable=expression-not-assigned
with pytest.raises(TypeError):
san.IPAddress("192.168.1.1") == "192.168.1.1" # pylint: disable=expression-not-assigned
with pytest.raises(TypeError):
"192.168.1.1" == san.IPAddress("192.168.1.1") # pylint: disable=expression-not-assigned
assert san.DNSName("example.com") == san.DNSName("example.com")
assert san.DNSName("Example.com") == san.DNSName("example.com")
assert san.DNSName("example.com") == san.DNSName("Example.com")
assert san.DNSName("EXAMPLE.COM") == san.DNSName("Example.com")
assert san.DNSName("EXAMPLE.COM.") == san.DNSName("example.com")
def test_is_wildcard(self) -> None:
assert not san.DNSName("example.com").is_wildcard()
assert not san.DNSName("example.*.com").is_wildcard()
assert san.DNSName("*.example.com").is_wildcard()
assert not san.IPAddress("192.168.1.1").is_wildcard()
def test_split(self) -> None:
assert san.split([]) == ([], [])
assert san.split([san.IPAddress("192.168.1.1")]) == ([], [san.IPAddress("192.168.1.1")])
assert san.split([san.DNSName("example.com")]) == ([san.DNSName("example.com")], [])
assert san.split([san.DNSName("example.com"), san.IPAddress("192.168.1.1"),
san.DNSName("example.org"), san.IPAddress("192.168.1.2")]) == (
[san.DNSName("example.com"), san.DNSName("example.org")],
[san.IPAddress("192.168.1.1"), san.IPAddress("192.168.1.2")])
def test_display(self) -> None:
assert san.display([san.DNSName("example.com")]) == "example.com"
assert san.display([san.DNSName("example.com"), san.IPAddress("192.168.1.1")]) \
== "example.com, 192.168.1.1"
def test_ip_address(self) -> None:
with pytest.raises(ValueError):
san.IPAddress("example.com")
class EnforceDomainSyntaxTest(unittest.TestCase):
"""Test validation of domain names."""
def _call(self, dns_name: str) -> None:
san.DNSName(dns_name)
def test_nonascii_str(self) -> None:
with pytest.raises(errors.ConfigurationError):
self._call("eichh\u00f6rnchen.example.com")
def test_too_long(self) -> None:
long_domain = "a"*256
with pytest.raises(errors.ConfigurationError):
self._call(long_domain)
def test_not_too_long(self) -> None:
not_too_long_domain = "{0}.{1}.{2}.{3}".format("a"*63, "b"*63, "c"*63, "d"*63)
self._call(not_too_long_domain)
def test_empty_label(self) -> None:
empty_label_domain = "fizz..example.com"
with pytest.raises(errors.ConfigurationError):
self._call(empty_label_domain)
def test_empty_trailing_label(self) -> None:
empty_trailing_label_domain = "example.com.."
with pytest.raises(errors.ConfigurationError):
self._call(empty_trailing_label_domain)
def test_long_label_1(self) -> None:
long_label_domain = "a"*64
with pytest.raises(errors.ConfigurationError):
self._call(long_label_domain)
def test_long_label_2(self) -> None:
long_label_domain = "{0}.{1}.com".format("a"*64, "b"*63)
with pytest.raises(errors.ConfigurationError):
self._call(long_label_domain)
def test_not_long_label(self) -> None:
not_too_long_label_domain = "{0}.{1}.com".format("a"*63, "b"*63)
self._call(not_too_long_label_domain)
def test_empty_domain(self) -> None:
empty_domain = ""
with pytest.raises(errors.ConfigurationError):
self._call(empty_domain)
def test_punycode_ok(self) -> None:
# Punycode is now legal, so no longer an error; instead check
# that it's _not_ an error (at the initial sanity check stage)
self._call('this.is.xn--ls8h.tld')
class FromX509Test(unittest.TestCase):
def test_csr(self) -> None:
key = ec.generate_private_key(ec.SECP256R1())
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([]))
.add_extension(
x509.SubjectAlternativeName(
[x509.DNSName("example.com"),
x509.IPAddress(ipaddress.ip_address("192.168.1.1"))]
),
critical=False,
)
).sign(key, hashes.SHA256())
result = san.from_x509(csr.subject, csr.extensions)
assert result == (
[san.DNSName("example.com")],
[san.IPAddress("192.168.1.1")],
)
def test_cert(self) -> None:
key = ec.generate_private_key(ec.SECP256R1())
cert = (
x509.CertificateBuilder()
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now())
.not_valid_after(datetime.now())
.subject_name(x509.Name([]))
.issuer_name(x509.Name([]))
.public_key(key.public_key())
.add_extension(
x509.SubjectAlternativeName(
[x509.DNSName("example.com"),
x509.IPAddress(ipaddress.ip_address("192.168.1.1"))]
),
critical=False,
)
).sign(key, hashes.SHA256())
result = san.from_x509(cert.subject, cert.extensions)
assert result == (
[san.DNSName("example.com")],
[san.IPAddress("192.168.1.1")],
)
def test_cn(self) -> None:
key = ec.generate_private_key(ec.SECP256R1())
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "common.example"),
]))
.add_extension(
x509.SubjectAlternativeName(
[x509.DNSName("example.com"),
x509.IPAddress(ipaddress.ip_address("192.168.1.1"))]
),
critical=False,
)
).sign(key, hashes.SHA256())
result = san.from_x509(csr.subject, csr.extensions)
assert result == (
[san.DNSName("common.example"), san.DNSName("example.com")],
[san.IPAddress("192.168.1.1")],
)
def test_cn_duplicate(self) -> None:
key = ec.generate_private_key(ec.SECP256R1())
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "example.com"),
]))
.add_extension(
x509.SubjectAlternativeName(
[x509.DNSName("example.com"),
x509.IPAddress(ipaddress.ip_address("192.168.1.1"))]
),
critical=False,
)
).sign(key, hashes.SHA256())
result = san.from_x509(csr.subject, csr.extensions)
assert result == (
[san.DNSName("example.com")],
[san.IPAddress("192.168.1.1")],
)