openssl_certificate: fix failing SAN comparisons (#58256)

* Fix failing SAN comparison for older cryptography versions due to not implemented __hashh__ functions.

* Fix SAN comparison: IPv6 addresses need to be normalized before comparing strings.

* Add changelog.

* Fix comment.
This commit is contained in:
Felix Fontein 2019-06-24 06:34:12 +02:00 committed by GitHub
parent 73dc4d7e97
commit 75ca8eb42f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 98 additions and 4 deletions

View file

@ -0,0 +1,2 @@
bugfixes:
- "openssl_certificate - fix Subject Alternate Name comparison, which was broken for IPv6 addresses with PyOpenSSL, or with older cryptography versions (before 2.1)."

View file

@ -27,6 +27,8 @@
# For more details, search for the function _OID_MAP. # For more details, search for the function _OID_MAP.
from distutils.version import LooseVersion
try: try:
import OpenSSL import OpenSSL
from OpenSSL import crypto from OpenSSL import crypto
@ -36,11 +38,43 @@ except ImportError:
pass pass
try: try:
import cryptography
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend as cryptography_backend from cryptography.hazmat.backends import default_backend as cryptography_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
import ipaddress import ipaddress
# Older versions of cryptography (< 2.1) do not have __hash__ functions for
# general name objects (DNSName, IPAddress, ...), while providing overloaded
# equality and string representation operations. This makes it impossible to
# use them in hash-based data structures such as set or dict. Since we are
# actually doing that in openssl_certificate, and potentially in other code,
# we need to monkey-patch __hash__ for these classes to make sure our code
# works fine.
if LooseVersion(cryptography.__version__) < LooseVersion('2.1'):
# A very simply hash function which relies on the representation
# of an object to be implemented. This is the case since at least
# cryptography 1.0, see
# https://github.com/pyca/cryptography/commit/7a9abce4bff36c05d26d8d2680303a6f64a0e84f
def simple_hash(self):
return hash(repr(self))
# The hash functions for the following types were added for cryptography 2.1:
# https://github.com/pyca/cryptography/commit/fbfc36da2a4769045f2373b004ddf0aff906cf38
x509.DNSName.__hash__ = simple_hash
x509.DirectoryName.__hash__ = simple_hash
x509.GeneralName.__hash__ = simple_hash
x509.IPAddress.__hash__ = simple_hash
x509.OtherName.__hash__ = simple_hash
x509.RegisteredID.__hash__ = simple_hash
if LooseVersion(cryptography.__version__) < LooseVersion('1.2'):
# The hash functions for the following types were added for cryptography 1.2:
# https://github.com/pyca/cryptography/commit/b642deed88a8696e5f01ce6855ccf89985fc35d0
# https://github.com/pyca/cryptography/commit/d1b5681f6db2bde7a14625538bd7907b08dfb486
x509.RFC822Name.__hash__ = simple_hash
x509.UniformResourceIdentifier.__hash__ = simple_hash
except ImportError: except ImportError:
# Error handled in the calling module. # Error handled in the calling module.
pass pass

View file

@ -542,6 +542,7 @@ from distutils.version import LooseVersion
from ansible.module_utils import crypto as crypto_utils from ansible.module_utils import crypto as crypto_utils
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native, to_bytes, to_text from ansible.module_utils._text import to_native, to_bytes, to_text
from ansible.module_utils.compat import ipaddress as compat_ipaddress
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
MINIMAL_PYOPENSSL_VERSION = '0.15' MINIMAL_PYOPENSSL_VERSION = '0.15'
@ -1654,15 +1655,26 @@ class AssertOnlyCertificate(AssertOnlyCertificateBase):
if self.extended_key_usage: if self.extended_key_usage:
return NO_EXTENSION return NO_EXTENSION
def _normalize_san(self, san):
# Apparently OpenSSL returns 'IP address' not 'IP' as specifier when converting the subjectAltName to string
# although it won't accept this specifier when generating the CSR. (https://github.com/openssl/openssl/issues/4004)
if san.startswith('IP Address:'):
san = 'IP:' + san[len('IP Address:'):]
if san.startswith('IP:'):
ip = compat_ipaddress.ip_address(san[3:])
san = 'IP:{0}'.format(ip.compressed)
return san
def _validate_subject_alt_name(self): def _validate_subject_alt_name(self):
found = False found = False
for extension_idx in range(0, self.cert.get_extension_count()): for extension_idx in range(0, self.cert.get_extension_count()):
extension = self.cert.get_extension(extension_idx) extension = self.cert.get_extension(extension_idx)
if extension.get_short_name() == b'subjectAltName': if extension.get_short_name() == b'subjectAltName':
found = True found = True
l_altnames = [altname.replace(b'IP Address', b'IP') for altname in l_altnames = [self._normalize_san(altname.strip()) for altname in
to_bytes(extension, errors='surrogate_or_strict').split(b', ')] to_text(extension, errors='surrogate_or_strict').split(', ')]
if not compare_sets(self.subject_alt_name, l_altnames, self.subject_alt_name_strict): sans = [self._normalize_san(to_text(san, errors='surrogate_or_strict')) for san in self.subject_alt_name]
if not compare_sets(sans, l_altnames, self.subject_alt_name_strict):
return self.subject_alt_name, l_altnames return self.subject_alt_name, l_altnames
if not found: if not found:
# This is only bad if the user specified a non-empty list # This is only bad if the user specified a non-empty list

View file

@ -555,7 +555,7 @@ class CertificateSigningRequestPyOpenSSL(CertificateSigningRequestBase):
raise CertificateSigningRequestError(exc) raise CertificateSigningRequestError(exc)
def _normalize_san(self, san): def _normalize_san(self, san):
# apperently openssl returns 'IP address' not 'IP' as specifier when converting the subjectAltName to string # Apparently OpenSSL returns 'IP address' not 'IP' as specifier when converting the subjectAltName to string
# although it won't accept this specifier when generating the CSR. (https://github.com/openssl/openssl/issues/4004) # although it won't accept this specifier when generating the CSR. (https://github.com/openssl/openssl/issues/4004)
if san.startswith('IP Address:'): if san.startswith('IP Address:'):
san = 'IP:' + san[len('IP Address:'):] san = 'IP:' + san[len('IP Address:'):]

View file

@ -18,6 +18,18 @@
commonName: www.example.com commonName: www.example.com
useCommonNameForSAN: no useCommonNameForSAN: no
- name: (Assertonly, {{select_crypto_backend}}) - Generate CSR (with SANs)
openssl_csr:
path: '{{ output_dir }}/csr_sans.csr'
privatekey_path: '{{ output_dir }}/privatekey.pem'
subject:
commonName: www.example.com
subject_alt_name:
- "DNS:ansible.com"
- "IP:127.0.0.1"
- "IP:::1"
useCommonNameForSAN: no
- name: (Assertonly, {{select_crypto_backend}}) - Generate selfsigned certificate (no extensions) - name: (Assertonly, {{select_crypto_backend}}) - Generate selfsigned certificate (no extensions)
openssl_certificate: openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem' path: '{{ output_dir }}/cert_noext.pem'
@ -27,6 +39,15 @@
selfsigned_digest: sha256 selfsigned_digest: sha256
select_crypto_backend: '{{ select_crypto_backend }}' select_crypto_backend: '{{ select_crypto_backend }}'
- name: (Assertonly, {{select_crypto_backend}}) - Generate selfsigned certificate (with SANs)
openssl_certificate:
path: '{{ output_dir }}/cert_sans.pem'
csr_path: '{{ output_dir }}/csr_sans.csr'
privatekey_path: '{{ output_dir }}/privatekey.pem'
provider: selfsigned
selfsigned_digest: sha256
select_crypto_backend: '{{ select_crypto_backend }}'
- name: (Assertonly, {{select_crypto_backend}}) - Assert that subject_alt_name is there (should fail) - name: (Assertonly, {{select_crypto_backend}}) - Assert that subject_alt_name is there (should fail)
openssl_certificate: openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem' path: '{{ output_dir }}/cert_noext.pem'
@ -37,6 +58,29 @@
ignore_errors: yes ignore_errors: yes
register: extension_missing_san register: extension_missing_san
- name: (Assertonly, {{select_crypto_backend}}) - Assert that subject_alt_name is there
openssl_certificate:
path: '{{ output_dir }}/cert_sans.pem'
provider: assertonly
subject_alt_name:
- "DNS:ansible.com"
- "IP:127.0.0.1"
- "IP:::1"
select_crypto_backend: '{{ select_crypto_backend }}'
register: extension_san
- name: (Assertonly, {{select_crypto_backend}}) - Assert that subject_alt_name is there (strict)
openssl_certificate:
path: '{{ output_dir }}/cert_sans.pem'
provider: assertonly
subject_alt_name:
- "DNS:ansible.com"
- "IP:127.0.0.1"
- "IP:::1"
subject_alt_name_strict: yes
select_crypto_backend: '{{ select_crypto_backend }}'
register: extension_san_strict
- name: (Assertonly, {{select_crypto_backend}}) - Assert that key_usage is there (should fail) - name: (Assertonly, {{select_crypto_backend}}) - Assert that key_usage is there (should fail)
openssl_certificate: openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem' path: '{{ output_dir }}/cert_noext.pem'
@ -61,6 +105,8 @@
that: that:
- extension_missing_san is failed - extension_missing_san is failed
- "'Found no subjectAltName extension' in extension_missing_san.msg" - "'Found no subjectAltName extension' in extension_missing_san.msg"
- extension_san is succeeded
- extension_san_strict is succeeded
- extension_missing_ku is failed - extension_missing_ku is failed
- "'Found no keyUsage extension' in extension_missing_ku.msg" - "'Found no keyUsage extension' in extension_missing_ku.msg"
- extension_missing_eku is failed - extension_missing_eku is failed