mirror of
https://github.com/certbot/certbot.git
synced 2026-06-09 08:42:57 -04:00
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:
parent
2ec8320763
commit
ada2c547cf
4 changed files with 389 additions and 12 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
158
certbot/src/certbot/_internal/san.py
Normal file
158
certbot/src/certbot/_internal/san.py
Normal 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]
|
||||
205
certbot/src/certbot/_internal/san_test.py
Normal file
205
certbot/src/certbot/_internal/san_test.py
Normal 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")],
|
||||
)
|
||||
Loading…
Reference in a new issue