diff --git a/changelogs/fragments/436-idns.yml b/changelogs/fragments/436-idns.yml new file mode 100644 index 00000000..2f9149fc --- /dev/null +++ b/changelogs/fragments/436-idns.yml @@ -0,0 +1,12 @@ +minor_changes: + - "Support automatic conversion for Internalionalized Domain Names (IDNs). + When passing general names, for example Subject Altenative Names to ``community.crypto.openssl_csr``, these will automatically be converted to IDNA. + Conversion will be done per label to IDNA2008 if possible, and IDNA2003 if IDNA2008 conversion fails for that label. + Note that IDNA conversion requires `the Python idna library `_ to be installed. + Please note that depending on which versions of the cryptography library are used, it could try to process the converted IDNA + another time with the Python ``idna`` library and reject IDNA2003 encoded values. Using a new enough ``cryptography`` version avoids this + (https://github.com/ansible-collections/community.crypto/issues/426, https://github.com/ansible-collections/community.crypto/pull/436)." + - "openssl_csr_info - add ``name_encoding`` option to control the encoding (IDNA, Unicode) used to return domain names in general names (https://github.com/ansible-collections/community.crypto/pull/436)." + - "x509_certificate_info - add ``name_encoding`` option to control the encoding (IDNA, Unicode) used to return domain names in general names (https://github.com/ansible-collections/community.crypto/pull/436)." + - "x509_crl - add ``name_encoding`` option to control the encoding (IDNA, Unicode) used to return domain names in general names (https://github.com/ansible-collections/community.crypto/pull/436)." + - "x509_crl_info - add ``name_encoding`` option to control the encoding (IDNA, Unicode) used to return domain names in general names (https://github.com/ansible-collections/community.crypto/pull/436)." diff --git a/plugins/doc_fragments/name_encoding.py b/plugins/doc_fragments/name_encoding.py new file mode 100644 index 00000000..d42d8aea --- /dev/null +++ b/plugins/doc_fragments/name_encoding.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +class ModuleDocFragment(object): + DOCUMENTATION = r''' +options: + name_encoding: + description: + - How to encode names (DNS names, URIs, email addresses) in return values. + - C(ignore) will use the encoding returned by the backend. + - C(idna) will convert all labels of domain names to IDNA encoding. + IDNA2008 will be preferred, and IDNA2003 will be used if IDNA2008 encoding fails. + - C(unicode) will convert all labels of domain names to Unicode. + IDNA2008 will be preferred, and IDNA2003 will be used if IDNA2008 decoding fails. + - B(Note) that C(idna) and C(unicode) require the L(idna Python library,https://pypi.org/project/idna/) to be installed. + type: str + default: ignore + choices: + - ignore + - idna + - unicode +requirements: + - If I(name_encoding) is set to another value than C(ignore), the L(idna Python library,https://pypi.org/project/idna/) needs to be installed. +''' diff --git a/plugins/module_utils/crypto/cryptography_crl.py b/plugins/module_utils/crypto/cryptography_crl.py index a7148687..04130dfd 100644 --- a/plugins/module_utils/crypto/cryptography_crl.py +++ b/plugins/module_utils/crypto/cryptography_crl.py @@ -95,12 +95,12 @@ def cryptography_decode_revoked_certificate(cert): return result -def cryptography_dump_revoked(entry): +def cryptography_dump_revoked(entry, idn_rewrite='ignore'): return { 'serial_number': entry['serial_number'], 'revocation_date': entry['revocation_date'].strftime(TIMESTAMP_FORMAT), 'issuer': - [cryptography_decode_name(issuer) for issuer in entry['issuer']] + [cryptography_decode_name(issuer, idn_rewrite=idn_rewrite) for issuer in entry['issuer']] if entry['issuer'] is not None else None, 'issuer_critical': entry['issuer_critical'], 'reason': REVOCATION_REASON_MAP_INVERSE.get(entry['reason']) if entry['reason'] is not None else None, diff --git a/plugins/module_utils/crypto/cryptography_support.py b/plugins/module_utils/crypto/cryptography_support.py index 776a7be1..77b8ed79 100644 --- a/plugins/module_utils/crypto/cryptography_support.py +++ b/plugins/module_utils/crypto/cryptography_support.py @@ -23,8 +23,11 @@ import base64 import binascii import re import sys +import traceback from ansible.module_utils.common.text.converters import to_text, to_bytes +from ansible.module_utils.six.moves.urllib.parse import urlparse, urlunparse, ParseResult + from ._asn1 import serialize_asn1_string_as_der from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion @@ -80,6 +83,16 @@ except ImportError: # Error handled in the calling module. _load_pkcs12 = None +try: + import idna + + HAS_IDNA = True +except ImportError: + HAS_IDNA = False + IDNA_IMP_ERROR = traceback.format_exc() + +from ansible.module_utils.basic import missing_required_lib + from .basic import ( CRYPTOGRAPHY_HAS_DSA_SIGN, CRYPTOGRAPHY_HAS_EC_SIGN, @@ -359,6 +372,80 @@ def cryptography_parse_relative_distinguished_name(rdn): return cryptography.x509.RelativeDistinguishedName(names) +def _is_ascii(value): + '''Check whether the Unicode string `value` contains only ASCII characters.''' + try: + value.encode("ascii") + return True + except UnicodeEncodeError: + return False + + +def _adjust_idn(value, idn_rewrite): + if idn_rewrite == 'ignore' or not value: + return value + if idn_rewrite == 'idna' and _is_ascii(value): + return value + if idn_rewrite not in ('idna', 'unicode'): + raise ValueError('Invalid value for idn_rewrite: "{0}"'.format(idn_rewrite)) + if not HAS_IDNA: + raise OpenSSLObjectError( + missing_required_lib('idna', reason='to transform {what} DNS name "{name}" to {dest}'.format( + name=value, + what='IDNA' if idn_rewrite == 'unicode' else 'Unicode', + dest='Unicode' if idn_rewrite == 'unicode' else 'IDNA', + ))) + # Since IDNA does not like '*' or empty labels (except one empty label at the end), + # we split and let IDNA only handle labels that are neither empty or '*'. + parts = value.split(u'.') + for index, part in enumerate(parts): + if part in (u'', u'*'): + continue + try: + if idn_rewrite == 'idna': + parts[index] = idna.encode(part).decode('ascii') + elif idn_rewrite == 'unicode' and part.startswith(u'xn--'): + parts[index] = idna.decode(part) + except idna.IDNAError as exc2008: + try: + if idn_rewrite == 'idna': + parts[index] = part.encode('idna').decode('ascii') + elif idn_rewrite == 'unicode' and part.startswith(u'xn--'): + parts[index] = part.encode('ascii').decode('idna') + except Exception as exc2003: + raise OpenSSLObjectError( + u'Error while transforming part "{part}" of {what} DNS name "{name}" to {dest}.' + u' IDNA2008 transformation resulted in "{exc2008}", IDNA2003 transformation resulted in "{exc2003}".'.format( + part=part, + name=value, + what='IDNA' if idn_rewrite == 'unicode' else 'Unicode', + dest='Unicode' if idn_rewrite == 'unicode' else 'IDNA', + exc2003=exc2003, + exc2008=exc2008, + )) + return u'.'.join(parts) + + +def _adjust_idn_email(value, idn_rewrite): + idx = value.find(u'@') + if idx < 0: + return value + return u'{0}@{1}'.format(value[:idx], _adjust_idn(value[idx + 1:], idn_rewrite)) + + +def _adjust_idn_url(value, idn_rewrite): + url = urlparse(value) + host = _adjust_idn(url.hostname, idn_rewrite) + if url.username is not None and url.password is not None: + host = u'{0}:{1}@{2}'.format(url.username, url.password, host) + elif url.username is not None: + host = u'{0}@{1}'.format(url.username, host) + if url.port is not None: + host = u'{0}:{1}'.format(host, url.port) + return urlunparse( + ParseResult(scheme=url.scheme, netloc=host, path=url.path, params=url.params, query=url.query, fragment=url.fragment)) + + def cryptography_get_name(name, what='Subject Alternative Name'): ''' Given a name string, returns a cryptography x509.GeneralName object. @@ -366,16 +453,16 @@ def cryptography_get_name(name, what='Subject Alternative Name'): ''' try: if name.startswith('DNS:'): - return x509.DNSName(to_text(name[4:])) + return x509.DNSName(_adjust_idn(to_text(name[4:]), 'idna')) if name.startswith('IP:'): address = to_text(name[3:]) if '/' in address: return x509.IPAddress(ipaddress.ip_network(address)) return x509.IPAddress(ipaddress.ip_address(address)) if name.startswith('email:'): - return x509.RFC822Name(to_text(name[6:])) + return x509.RFC822Name(_adjust_idn_email(to_text(name[6:]), 'idna')) if name.startswith('URI:'): - return x509.UniformResourceIdentifier(to_text(name[4:])) + return x509.UniformResourceIdentifier(_adjust_idn_url(to_text(name[4:]), 'idna')) if name.startswith('RID:'): m = re.match(r'^([0-9]+(?:\.[0-9]+)*)$', to_text(name[4:])) if not m: @@ -422,21 +509,23 @@ def _dn_escape_value(value): return value -def cryptography_decode_name(name): +def cryptography_decode_name(name, idn_rewrite='ignore'): ''' Given a cryptography x509.GeneralName object, returns a string. Raises an OpenSSLObjectError if the name is not supported. ''' + if idn_rewrite not in ('ignore', 'idna', 'unicode'): + raise AssertionError('idn_rewrite must be one of "ignore", "idna", or "unicode"') if isinstance(name, x509.DNSName): - return u'DNS:{0}'.format(name.value) + return u'DNS:{0}'.format(_adjust_idn(name.value, idn_rewrite)) if isinstance(name, x509.IPAddress): if isinstance(name.value, (ipaddress.IPv4Network, ipaddress.IPv6Network)): return u'IP:{0}/{1}'.format(name.value.network_address.compressed, name.value.prefixlen) return u'IP:{0}'.format(name.value.compressed) if isinstance(name, x509.RFC822Name): - return u'email:{0}'.format(name.value) + return u'email:{0}'.format(_adjust_idn_email(name.value, idn_rewrite)) if isinstance(name, x509.UniformResourceIdentifier): - return u'URI:{0}'.format(name.value) + return u'URI:{0}'.format(_adjust_idn_url(name.value, idn_rewrite)) if isinstance(name, x509.DirectoryName): # According to https://datatracker.ietf.org/doc/html/rfc4514.html#section-2.1 the # list needs to be reversed, and joined by commas diff --git a/plugins/module_utils/crypto/module_backends/certificate_info.py b/plugins/module_utils/crypto/module_backends/certificate_info.py index 15c08a93..4089a7f4 100644 --- a/plugins/module_utils/crypto/module_backends/certificate_info.py +++ b/plugins/module_utils/crypto/module_backends/certificate_info.py @@ -207,6 +207,7 @@ class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval): """Validate the supplied cert, using the cryptography backend""" def __init__(self, module, content): super(CertificateInfoRetrievalCryptography, self).__init__(module, 'cryptography', content) + self.name_encoding = module.params.get('name_encoding', 'ignore') def _get_der_bytes(self): return self.cert.public_bytes(serialization.Encoding.DER) @@ -309,7 +310,7 @@ class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval): def _get_subject_alt_name(self): try: san_ext = self.cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) - result = [cryptography_decode_name(san) for san in san_ext.value] + result = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in san_ext.value] return result, san_ext.critical except cryptography.x509.ExtensionNotFound: return None, False @@ -341,7 +342,7 @@ class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval): ext = self.cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) issuer = None if ext.value.authority_cert_issuer is not None: - issuer = [cryptography_decode_name(san) for san in ext.value.authority_cert_issuer] + issuer = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in ext.value.authority_cert_issuer] return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number except cryptography.x509.ExtensionNotFound: return None, None, None diff --git a/plugins/module_utils/crypto/module_backends/crl_info.py b/plugins/module_utils/crypto/module_backends/crl_info.py index cf1e6e93..d15b378e 100644 --- a/plugins/module_utils/crypto/module_backends/crl_info.py +++ b/plugins/module_utils/crypto/module_backends/crl_info.py @@ -51,6 +51,7 @@ class CRLInfoRetrieval(object): self.module = module self.content = content self.list_revoked_certificates = list_revoked_certificates + self.name_encoding = module.params.get('name_encoding', 'ignore') def get_info(self): self.crl_pem = identify_pem_format(self.content) @@ -86,7 +87,7 @@ class CRLInfoRetrieval(object): result['revoked_certificates'] = [] for cert in self.crl: entry = cryptography_decode_revoked_certificate(cert) - result['revoked_certificates'].append(cryptography_dump_revoked(entry)) + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) return result diff --git a/plugins/module_utils/crypto/module_backends/csr_info.py b/plugins/module_utils/crypto/module_backends/csr_info.py index 8e733147..ba9240da 100644 --- a/plugins/module_utils/crypto/module_backends/csr_info.py +++ b/plugins/module_utils/crypto/module_backends/csr_info.py @@ -174,6 +174,7 @@ class CSRInfoRetrievalCryptography(CSRInfoRetrieval): """Validate the supplied CSR, using the cryptography backend""" def __init__(self, module, content, validate_signature): super(CSRInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, validate_signature) + self.name_encoding = module.params.get('name_encoding', 'ignore') def _get_subject_ordered(self): result = [] @@ -256,7 +257,7 @@ class CSRInfoRetrievalCryptography(CSRInfoRetrieval): def _get_subject_alt_name(self): try: san_ext = self.csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) - result = [cryptography_decode_name(san) for san in san_ext.value] + result = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in san_ext.value] return result, san_ext.critical except cryptography.x509.ExtensionNotFound: return None, False @@ -264,8 +265,8 @@ class CSRInfoRetrievalCryptography(CSRInfoRetrieval): def _get_name_constraints(self): try: nc_ext = self.csr.extensions.get_extension_for_class(x509.NameConstraints) - permitted = [cryptography_decode_name(san) for san in nc_ext.value.permitted_subtrees or []] - excluded = [cryptography_decode_name(san) for san in nc_ext.value.excluded_subtrees or []] + permitted = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in nc_ext.value.permitted_subtrees or []] + excluded = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in nc_ext.value.excluded_subtrees or []] return permitted, excluded, nc_ext.critical except cryptography.x509.ExtensionNotFound: return None, None, False @@ -291,7 +292,7 @@ class CSRInfoRetrievalCryptography(CSRInfoRetrieval): ext = self.csr.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier) issuer = None if ext.value.authority_cert_issuer is not None: - issuer = [cryptography_decode_name(san) for san in ext.value.authority_cert_issuer] + issuer = [cryptography_decode_name(san, idn_rewrite=self.name_encoding) for san in ext.value.authority_cert_issuer] return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number except cryptography.x509.ExtensionNotFound: return None, None, None diff --git a/plugins/modules/openssl_csr_info.py b/plugins/modules/openssl_csr_info.py index 62527bc5..d3d08ce7 100644 --- a/plugins/modules/openssl_csr_info.py +++ b/plugins/modules/openssl_csr_info.py @@ -44,6 +44,9 @@ options: default: auto choices: [ auto, cryptography ] +extends_documentation_fragment: + - community.crypto.name_encoding + seealso: - module: community.crypto.openssl_csr - module: community.crypto.openssl_csr_pipe @@ -124,7 +127,9 @@ key_usage_critical: returned: success type: bool subject_alt_name: - description: Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. returned: success type: list elements: str @@ -152,6 +157,7 @@ name_constraints_excluded: description: - List of excluded subtrees the CA cannot sign certificates for. - Is C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. returned: success type: list elements: str @@ -281,6 +287,7 @@ authority_cert_issuer: description: - The CSR's authority cert issuer as a list of general names. - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. returned: success type: list elements: str @@ -312,6 +319,7 @@ def main(): argument_spec=dict( path=dict(type='path'), content=dict(type='str'), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), ), required_one_of=( diff --git a/plugins/modules/x509_certificate_info.py b/plugins/modules/x509_certificate_info.py index ae3ed9cd..e80ca2c4 100644 --- a/plugins/modules/x509_certificate_info.py +++ b/plugins/modules/x509_certificate_info.py @@ -62,6 +62,9 @@ options: default: auto choices: [ auto, cryptography ] +extends_documentation_fragment: + - community.crypto.name_encoding + notes: - All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern. They are all in UTC. @@ -168,7 +171,9 @@ key_usage_critical: returned: success type: bool subject_alt_name: - description: Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + description: + - Entries in the C(subject_alt_name) extension, or C(none) if extension is not present. + - See I(name_encoding) for how IDNs are handled. returned: success type: list elements: str @@ -355,6 +360,7 @@ authority_cert_issuer: description: - The certificate's authority cert issuer as a list of general names. - Is C(none) if the C(AuthorityKeyIdentifier) extension is not present. + - See I(name_encoding) for how IDNs are handled. returned: success type: list elements: str @@ -397,6 +403,7 @@ def main(): path=dict(type='path'), content=dict(type='str'), valid_at=dict(type='dict'), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']), ), required_one_of=( diff --git a/plugins/modules/x509_crl.py b/plugins/modules/x509_crl.py index 72408950..9a85ae39 100644 --- a/plugins/modules/x509_crl.py +++ b/plugins/modules/x509_crl.py @@ -242,6 +242,7 @@ options: extends_documentation_fragment: - files + - community.crypto.name_encoding notes: - All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern. @@ -297,6 +298,7 @@ issuer: description: - The CRL's issuer. - Note that for repeated values, only the last one will be returned. + - See I(name_encoding) for how IDNs are handled. returned: success type: dict sample: '{"organizationName": "Ansible", "commonName": "ca.example.com"}' @@ -336,7 +338,9 @@ revoked_certificates: type: str sample: 20190413202428Z issuer: - description: The certificate's issuer. + description: + - The certificate's issuer. + - See I(name_encoding) for how IDNs are handled. type: list elements: str sample: '["DNS:ca.example.org"]' @@ -405,6 +409,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im ) from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + cryptography_decode_name, cryptography_get_name, cryptography_name_to_oid, cryptography_oid_to_name, @@ -468,6 +473,7 @@ class CRL(OpenSSLObject): self.update = module.params['mode'] == 'update' self.ignore_timestamps = module.params['ignore_timestamps'] self.return_content = module.params['return_content'] + self.name_encoding = module.params['name_encoding'] self.crl_content = None self.privatekey_path = module.params['privatekey_path'] @@ -595,11 +601,20 @@ class CRL(OpenSSLObject): super(CRL, self).remove(self.module) def _compress_entry(self, entry): + issuer = None + if entry['issuer'] is not None: + # Normalize to IDNA. If this is used-provided, it was already converted to + # IDNA (by cryptography_get_name) and thus the `idna` library is present. + # If this is coming from cryptography and isn't already in IDNA (i.e. ascii), + # cryptography < 2.1 must be in use, which depends on `idna`. So this should + # not require `idna` except if it was already used by code earlier during + # this invocation. + issuer = tuple(cryptography_decode_name(issuer, idn_rewrite='idna') for issuer in entry['issuer']) if self.ignore_timestamps: # Throw out revocation_date return ( entry['serial_number'], - tuple(entry['issuer']) if entry['issuer'] is not None else None, + issuer, entry['issuer_critical'], entry['reason'], entry['reason_critical'], @@ -610,7 +625,7 @@ class CRL(OpenSSLObject): return ( entry['serial_number'], entry['revocation_date'], - tuple(entry['issuer']) if entry['issuer'] is not None else None, + issuer, entry['issuer_critical'], entry['reason'], entry['reason_critical'], @@ -765,7 +780,7 @@ class CRL(OpenSSLObject): result['issuer'][k] = v result['revoked_certificates'] = [] for entry in self.revoked_certificates: - result['revoked_certificates'].append(cryptography_dump_revoked(entry)) + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) elif self.crl: result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT) result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT) @@ -780,7 +795,7 @@ class CRL(OpenSSLObject): result['revoked_certificates'] = [] for cert in self.crl: entry = cryptography_decode_revoked_certificate(cert) - result['revoked_certificates'].append(cryptography_dump_revoked(entry)) + result['revoked_certificates'].append(cryptography_dump_revoked(entry, idn_rewrite=self.name_encoding)) if self.return_content: result['crl'] = self.crl_content @@ -836,6 +851,7 @@ def main(): required_one_of=[['path', 'content', 'serial_number']], mutually_exclusive=[['path', 'content', 'serial_number']], ), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), ), required_if=[ ('state', 'present', ['privatekey_path', 'privatekey_content'], True), diff --git a/plugins/modules/x509_crl_info.py b/plugins/modules/x509_crl_info.py index e393a81e..29eee516 100644 --- a/plugins/modules/x509_crl_info.py +++ b/plugins/modules/x509_crl_info.py @@ -40,6 +40,9 @@ options: default: true version_added: 1.7.0 +extends_documentation_fragment: + - community.crypto.name_encoding + notes: - All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern. They are all in UTC. @@ -76,6 +79,7 @@ issuer: description: - The CRL's issuer. - Note that for repeated values, only the last one will be returned. + - See I(name_encoding) for how IDNs are handled. returned: success type: dict sample: '{"organizationName": "Ansible", "commonName": "ca.example.com"}' @@ -115,7 +119,9 @@ revoked_certificates: type: str sample: 20190413202428Z issuer: - description: The certificate's issuer. + description: + - The certificate's issuer. + - See I(name_encoding) for how IDNs are handled. type: list elements: str sample: '["DNS:ca.example.org"]' @@ -173,6 +179,7 @@ def main(): path=dict(type='path'), content=dict(type='str'), list_revoked_certificates=dict(type='bool', default=True), + name_encoding=dict(type='str', default='ignore', choices=['ignore', 'idna', 'unicode']), ), required_one_of=( ['path', 'content'], diff --git a/tests/integration/targets/openssl_csr_info/tasks/impl.yml b/tests/integration/targets/openssl_csr_info/tasks/impl.yml index 5083e497..ef589df4 100644 --- a/tests/integration/targets/openssl_csr_info/tasks/impl.yml +++ b/tests/integration/targets/openssl_csr_info/tasks/impl.yml @@ -8,6 +8,20 @@ select_crypto_backend: '{{ select_crypto_backend }}' register: result +- name: "({{ select_crypto_backend }}) Get CSR info (IDNA encoding)" + openssl_csr_info: + path: '{{ remote_tmp_dir }}/csr_1.csr' + name_encoding: idna + select_crypto_backend: '{{ select_crypto_backend }}' + register: result_idna + +- name: "({{ select_crypto_backend }}) Get CSR info (Unicode encoding)" + openssl_csr_info: + path: '{{ remote_tmp_dir }}/csr_1.csr' + name_encoding: unicode + select_crypto_backend: '{{ select_crypto_backend }}' + register: result_unicode + - name: "({{ select_crypto_backend }}) Check whether subject and extensions behaves as expected" assert: that: @@ -23,8 +37,11 @@ - result.extensions_by_oid['2.5.29.15'].critical == true - result.extensions_by_oid['2.5.29.15'].value in ['AwMA/4A=', 'AwMH/4A='] # Subject Alternative Names + - result.subject_alt_name[1] == ("DNS:âņsïbłè.com" if cryptography_version.stdout is version('2.1', '<') else "DNS:xn--sb-oia0a7a53bya.com") + - result_unicode.subject_alt_name[1] == "DNS:âņsïbłè.com" + - result_idna.subject_alt_name[1] == "DNS:xn--sb-oia0a7a53bya.com" - result.extensions_by_oid['2.5.29.17'].critical == false - - result.extensions_by_oid['2.5.29.17'].value == 'MGCCD3d3dy5hbnNpYmxlLmNvbYcEAQIDBIcQAAAAAAAAAAAAAAAAAAAAAYEQdGVzdEBleGFtcGxlLm9yZ4YjaHR0cHM6Ly9leGFtcGxlLm9yZy90ZXN0L2luZGV4Lmh0bWw=' + - result.extensions_by_oid['2.5.29.17'].value == 'MHmCD3d3dy5hbnNpYmxlLmNvbYIXeG4tLXNiLW9pYTBhN2E1M2J5YS5jb22HBAECAwSHEAAAAAAAAAAAAAAAAAAAAAGBEHRlc3RAZXhhbXBsZS5vcmeGI2h0dHBzOi8vZXhhbXBsZS5vcmcvdGVzdC9pbmRleC5odG1s' # Basic Constraints - result.extensions_by_oid['2.5.29.19'].critical == true - result.extensions_by_oid['2.5.29.19'].value == 'MAYBAf8CARc=' diff --git a/tests/integration/targets/openssl_csr_info/tasks/main.yml b/tests/integration/targets/openssl_csr_info/tasks/main.yml index 566d4cb5..d5327e34 100644 --- a/tests/integration/targets/openssl_csr_info/tasks/main.yml +++ b/tests/integration/targets/openssl_csr_info/tasks/main.yml @@ -4,6 +4,11 @@ # and should not be used as examples of how to write Ansible roles # #################################################################### +- name: Make sure the Python idna library is installed + pip: + name: idna + state: present + - name: Generate privatekey openssl_privatekey: path: '{{ remote_tmp_dir }}/privatekey.pem' @@ -67,6 +72,7 @@ - biometricInfo subject_alt_name: - "DNS:www.ansible.com" + - "DNS:âņsïbłè.com" - "IP:1.2.3.4" - "IP:::1" - "email:test@example.org" diff --git a/tests/integration/targets/x509_certificate_info/tasks/impl.yml b/tests/integration/targets/x509_certificate_info/tasks/impl.yml index 65096f56..9b8599bd 100644 --- a/tests/integration/targets/x509_certificate_info/tasks/impl.yml +++ b/tests/integration/targets/x509_certificate_info/tasks/impl.yml @@ -8,6 +8,20 @@ select_crypto_backend: '{{ select_crypto_backend }}' register: result +- name: ({{select_crypto_backend}}) Get certificate info (IDNA encoding) + x509_certificate_info: + path: '{{ remote_tmp_dir }}/cert_1.pem' + name_encoding: idna + select_crypto_backend: '{{ select_crypto_backend }}' + register: result_idna + +- name: ({{select_crypto_backend}}) Get certificate info (Unicode encoding) + x509_certificate_info: + path: '{{ remote_tmp_dir }}/cert_1.pem' + name_encoding: unicode + select_crypto_backend: '{{ select_crypto_backend }}' + register: result_unicode + - name: Check whether issuer and subject and extensions behave as expected assert: that: @@ -21,7 +35,26 @@ - result.public_key_data.size == (default_rsa_key_size_certifiates | int) - "result.subject_alt_name == [ 'DNS:www.ansible.com', - 'DNS:' ~ ('öç' if cryptography_version.stdout is version('2.1', '<') else 'xn--74h') ~ '.com', + 'DNS:' ~ ('öç' if cryptography_version.stdout is version('2.1', '<') else 'xn--7ca3a') ~ '.com', + 'DNS:' ~ ('www.öç' if cryptography_version.stdout is version('2.1', '<') else 'xn--74h') ~ '.com', + 'IP:1.2.3.4', + 'IP:::1', + 'email:test@example.org', + 'URI:https://example.org/test/index.html' + ]" + - "result_idna.subject_alt_name == [ + 'DNS:www.ansible.com', + 'DNS:xn--7ca3a.com', + 'DNS:' ~ ('www.xn--7ca3a' if cryptography_version.stdout is version('2.1', '<') else 'xn--74h') ~ '.com', + 'IP:1.2.3.4', + 'IP:::1', + 'email:test@example.org', + 'URI:https://example.org/test/index.html' + ]" + - "result_unicode.subject_alt_name == [ + 'DNS:www.ansible.com', + 'DNS:öç.com', + 'DNS:' ~ ('www.öç' if cryptography_version.stdout is version('2.1', '<') else '☺') ~ '.com', 'IP:1.2.3.4', 'IP:::1', 'email:test@example.org', @@ -37,9 +70,9 @@ - result.extensions_by_oid['2.5.29.17'].critical == false - > result.extensions_by_oid['2.5.29.17'].value == ( - 'MG+CD3d3dy5hbnNpYmxlLmNvbYINeG4tLTdjYTNhLmNvbYcEAQIDBIcQAAAAAAAAAAAAAAAAAAAAAYEQdGVzdEBleGFtcGxlLm9yZ4YjaHR0cHM6Ly9leGFtcGxlLm9yZy90ZXN0L2luZGV4Lmh0bWw=' + 'MIGCgg93d3cuYW5zaWJsZS5jb22CDXhuLS03Y2EzYS5jb22CEXd3dy54bi0tN2NhM2EuY29thwQBAgMEhxAAAAAAAAAAAAAAAAAAAAABgRB0ZXN0QGV4YW1wbGUub3JnhiNodHRwczovL2V4YW1wbGUub3JnL3Rlc3QvaW5kZXguaHRtbA==' if cryptography_version.stdout is version('2.1', '<') else - 'MG2CD3d3dy5hbnNpYmxlLmNvbYILeG4tLTc0aC5jb22HBAECAwSHEAAAAAAAAAAAAAAAAAAAAAGBEHRlc3RAZXhhbXBsZS5vcmeGI2h0dHBzOi8vZXhhbXBsZS5vcmcvdGVzdC9pbmRleC5odG1s' + 'MHyCD3d3dy5hbnNpYmxlLmNvbYINeG4tLTdjYTNhLmNvbYILeG4tLTc0aC5jb22HBAECAwSHEAAAAAAAAAAAAAAAAAAAAAGBEHRlc3RAZXhhbXBsZS5vcmeGI2h0dHBzOi8vZXhhbXBsZS5vcmcvdGVzdC9pbmRleC5odG1s' ) # Basic Constraints - result.extensions_by_oid['2.5.29.19'].critical == true diff --git a/tests/integration/targets/x509_certificate_info/tasks/main.yml b/tests/integration/targets/x509_certificate_info/tasks/main.yml index 96e7d2a0..89cbedc9 100644 --- a/tests/integration/targets/x509_certificate_info/tasks/main.yml +++ b/tests/integration/targets/x509_certificate_info/tasks/main.yml @@ -4,6 +4,11 @@ # and should not be used as examples of how to write Ansible roles # #################################################################### +- name: Make sure the Python idna library is installed + pip: + name: idna + state: present + - name: Generate privatekey openssl_privatekey: path: '{{ remote_tmp_dir }}/privatekey.pem' @@ -67,7 +72,9 @@ - biometricInfo subject_alt_name: - "DNS:www.ansible.com" - - "DNS:{{ 'öç' if cryptography_version.stdout is version('2.1', '<') else 'xn--74h' }}.com" + - "DNS:öç.com" + # cryptography < 2.1 cannot handle certain Unicode characters + - "DNS:{{ 'www.öç' if cryptography_version.stdout is version('2.1', '<') else '☺' }}.com" - "IP:1.2.3.4" - "IP:::1" - "email:test@example.org" diff --git a/tests/integration/targets/x509_crl/tasks/impl.yml b/tests/integration/targets/x509_crl/tasks/impl.yml index 45bb74fd..5284d5c5 100644 --- a/tests/integration/targets/x509_crl/tasks/impl.yml +++ b/tests/integration/targets/x509_crl/tasks/impl.yml @@ -536,13 +536,91 @@ revoked_certificates: - serial_number: 1234 revocation_date: 20191001000000Z + # * cryptography < 2.1 strips username and password from URIs. To avoid problems, we do + # not pass usernames and passwords for URIs when the cryptography version is < 2.1. + # * Python 3.5 before 3.5.8 rc 1 has a bug in urllib.parse.urlparse() that results in an + # error if a Unicode netloc has a username or password included. + # (https://github.com/ansible-collections/community.crypto/pull/436#issuecomment-1101737134) + # This affects the Python 3.5 included in Ansible 2.9's default test container; to avoid + # this, we also do not pass usernames and passwords for Python 3.5. issuer: - "DNS:ca.example.org" + - "DNS:ffóò.ḃâŗ.çøṁ" + - "email:foo@ḃâŗ.çøṁ" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'admin:hunter2@' }}ffóò.ḃâŗ.çøṁ/baz?foo=bar" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'goo@' }}www.straße.de" + - "URI:https://straße.de:8080" + - "URI:http://gefäß.org" + - "URI:http://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'a:b@' }}ä:1" issuer_critical: true register: crl_3 +- name: Create CRL 3 (IDNA encoding) + x509_crl: + path: '{{ remote_tmp_dir }}/ca-crl3.crl' + privatekey_path: '{{ remote_tmp_dir }}/ca.key' + issuer: + CN: Ansible + last_update: +0d + next_update: +0d + revoked_certificates: + - serial_number: 1234 + revocation_date: 20191001000000Z + issuer: + - "DNS:ca.example.org" + - "DNS:xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n" + - "email:foo@xn--2ca8uh37e.xn--7ca8a981n" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'admin:hunter2@' }}xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n/baz?foo=bar" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'goo@' }}www.xn--strae-oqa.de" + - "URI:https://xn--strae-oqa.de:8080" + - "URI:http://xn--gef-7kay.org" + - "URI:http://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'a:b@' }}xn--4ca:1" + issuer_critical: true + ignore_timestamps: true + name_encoding: idna + register: crl_3_idna + +- name: Create CRL 3 (Unicode encoding) + x509_crl: + path: '{{ remote_tmp_dir }}/ca-crl3.crl' + privatekey_path: '{{ remote_tmp_dir }}/ca.key' + issuer: + CN: Ansible + last_update: +0d + next_update: +0d + revoked_certificates: + - serial_number: 1234 + revocation_date: 20191001000000Z + issuer: + - "DNS:ca.example.org" + - "DNS:ffóò.ḃâŗ.çøṁ" + - "email:foo@ḃâŗ.çøṁ" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'admin:hunter2@' }}ffóò.ḃâŗ.çøṁ/baz?foo=bar" + - "URI:https://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'goo@' }}www.straße.de" + - "URI:https://straße.de:8080" + - "URI:http://gefäß.org" + - "URI:http://{{ '' if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else 'a:b@' }}ä:1" + issuer_critical: true + ignore_timestamps: true + name_encoding: unicode + register: crl_3_unicode + - name: Retrieve CRL 3 infos x509_crl_info: path: '{{ remote_tmp_dir }}/ca-crl3.crl' list_revoked_certificates: true register: crl_3_info + +- name: Retrieve CRL 3 infos (IDNA encoding) + x509_crl_info: + path: '{{ remote_tmp_dir }}/ca-crl3.crl' + name_encoding: idna + list_revoked_certificates: true + register: crl_3_info_idna + +- name: Retrieve CRL 3 infos (Unicode encoding) + x509_crl_info: + path: '{{ remote_tmp_dir }}/ca-crl3.crl' + name_encoding: unicode + list_revoked_certificates: true + register: crl_3_info_unicode diff --git a/tests/integration/targets/x509_crl/tasks/main.yml b/tests/integration/targets/x509_crl/tasks/main.yml index baf2ff6c..1f7e8ba9 100644 --- a/tests/integration/targets/x509_crl/tasks/main.yml +++ b/tests/integration/targets/x509_crl/tasks/main.yml @@ -4,6 +4,11 @@ # and should not be used as examples of how to write Ansible roles # #################################################################### +- name: Make sure the Python idna library is installed + pip: + name: idna + state: present + - set_fact: certificates: - name: ca diff --git a/tests/integration/targets/x509_crl/tests/validate.yml b/tests/integration/targets/x509_crl/tests/validate.yml index e5871a6c..da5083df 100644 --- a/tests/integration/targets/x509_crl/tests/validate.yml +++ b/tests/integration/targets/x509_crl/tests/validate.yml @@ -107,6 +107,73 @@ assert: that: - crl_3.revoked_certificates == crl_3_info.revoked_certificates - - crl_3.revoked_certificates[0].issuer == [ + - crl_3.revoked_certificates[0].issuer == ([ "DNS:ca.example.org", - ] + "DNS:ffóò.ḃâŗ.çøṁ", + "email:foo@ḃâŗ.çøṁ", + "URI:https://ffóò.ḃâŗ.çøṁ/baz?foo=bar", + "URI:https://www.straße.de", + "URI:https://straße.de:8080", + "URI:http://gefäß.org", + "URI:http://ä:1", + ] if cryptography_version.stdout is version('2.1', '<') else [ + "DNS:ca.example.org", + "DNS:xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n", + "email:foo@xn--2ca8uh37e.xn--7ca8a981n", + "URI:https://xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n/baz?foo=bar", + "URI:https://www.xn--strae-oqa.de", + "URI:https://xn--strae-oqa.de:8080", + "URI:http://xn--gef-7kay.org", + "URI:http://xn--4ca:1", + ] if ansible_facts.python.version.minor == 5 else [ + "DNS:ca.example.org", + "DNS:xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n", + "email:foo@xn--2ca8uh37e.xn--7ca8a981n", + "URI:https://admin:hunter2@xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n/baz?foo=bar", + "URI:https://goo@www.xn--strae-oqa.de", + "URI:https://xn--strae-oqa.de:8080", + "URI:http://xn--gef-7kay.org", + "URI:http://a:b@xn--4ca:1", + ]) + - crl_3_idna is not changed + - crl_3_idna.revoked_certificates == crl_3_info_idna.revoked_certificates + - crl_3_idna.revoked_certificates[0].issuer == ([ + "DNS:ca.example.org", + "DNS:xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n", + "email:foo@xn--2ca8uh37e.xn--7ca8a981n", + "URI:https://xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n/baz?foo=bar", + "URI:https://www.xn--strae-oqa.de", + "URI:https://xn--strae-oqa.de:8080", + "URI:http://xn--gef-7kay.org", + "URI:http://xn--4ca:1", + ] if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else [ + "DNS:ca.example.org", + "DNS:xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n", + "email:foo@xn--2ca8uh37e.xn--7ca8a981n", + "URI:https://admin:hunter2@xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n/baz?foo=bar", + "URI:https://goo@www.xn--strae-oqa.de", + "URI:https://xn--strae-oqa.de:8080", + "URI:http://xn--gef-7kay.org", + "URI:http://a:b@xn--4ca:1", + ]) + - crl_3_unicode is not changed + - crl_3_unicode.revoked_certificates == crl_3_info_unicode.revoked_certificates + - crl_3_unicode.revoked_certificates[0].issuer == ([ + "DNS:ca.example.org", + "DNS:ffóò.ḃâŗ.çøṁ", + "email:foo@ḃâŗ.çøṁ", + "URI:https://ffóò.ḃâŗ.çøṁ/baz?foo=bar", + "URI:https://www.straße.de", + "URI:https://straße.de:8080", + "URI:http://gefäß.org", + "URI:http://ä:1", + ] if cryptography_version.stdout is version('2.1', '<') or ansible_facts.python.version.minor == 5 else [ + "DNS:ca.example.org", + "DNS:ffóò.ḃâŗ.çøṁ", + "email:foo@ḃâŗ.çøṁ", + "URI:https://admin:hunter2@ffóò.ḃâŗ.çøṁ/baz?foo=bar", + "URI:https://goo@www.straße.de", + "URI:https://straße.de:8080", + "URI:http://gefäß.org", + "URI:http://a:b@ä:1", + ]) diff --git a/tests/unit/plugins/module_utils/crypto/test_cryptography_support.py b/tests/unit/plugins/module_utils/crypto/test_cryptography_support.py index 64a986f9..fb6b97f5 100644 --- a/tests/unit/plugins/module_utils/crypto/test_cryptography_support.py +++ b/tests/unit/plugins/module_utils/crypto/test_cryptography_support.py @@ -20,6 +20,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( cryptography_get_name, + _adjust_idn, _parse_dn_component, _parse_dn, ) @@ -27,6 +28,67 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion +@pytest.mark.parametrize('unicode, idna, cycled_unicode', [ + (u'..', u'..', None), + (u'foo.com', u'foo.com', None), + (u'.foo.com.', u'.foo.com.', None), + (u'*.foo.com', u'*.foo.com', None), + (u'straße', u'xn--strae-oqa', None), + (u'ffóò.ḃâŗ.çøṁ', u'xn--ff-3jad.xn--2ca8uh37e.xn--7ca8a981n', u'ffóò.ḃâŗ.çøṁ'), + (u'*.☺.', u'*.xn--74h.', None), +]) +def test_adjust_idn(unicode, idna, cycled_unicode): + if cycled_unicode is None: + cycled_unicode = unicode + + result = _adjust_idn(unicode, 'ignore') + print(result, unicode) + assert result == unicode + + result = _adjust_idn(idna, 'ignore') + print(result, idna) + assert result == idna + + result = _adjust_idn(unicode, 'unicode') + print(result, unicode) + assert result == unicode + + result = _adjust_idn(idna, 'unicode') + print(result, cycled_unicode) + assert result == cycled_unicode + + result = _adjust_idn(unicode, 'idna') + print(result, idna) + assert result == idna + + result = _adjust_idn(idna, 'idna') + print(result, idna) + assert result == idna + + +@pytest.mark.parametrize('value, idn_rewrite, message', [ + (u'bar', 'foo', re.escape(u'Invalid value for idn_rewrite: "foo"')), +]) +def test_adjust_idn_fail_valueerror(value, idn_rewrite, message): + with pytest.raises(ValueError, match=message): + result = _adjust_idn(value, idn_rewrite) + + +@pytest.mark.parametrize('value, idn_rewrite, message', [ + ( + u'xn--a', + 'unicode', + u'''^Error while transforming part u?"xn\\-\\-a" of IDNA DNS name u?"xn\\-\\-a" to Unicode\\.''' + u''' IDNA2008 transformation resulted in "Codepoint U\\+0080 at position 1 of u?'\\\\x80' not allowed",''' + u''' IDNA2003 transformation resulted in "(decoding with 'idna' codec failed''' + u''' \\(UnicodeError: )?Invalid character u?'\\\\x80'\\)?"\\.$''' + ), +]) +def test_adjust_idn_fail_user_error(value, idn_rewrite, message): + with pytest.raises(OpenSSLObjectError, match=message): + result = _adjust_idn(value, idn_rewrite) + + def test_cryptography_get_name_invalid_prefix(): with pytest.raises(OpenSSLObjectError, match="^Cannot parse Subject Alternative Name"): cryptography_get_name('fake:value') diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index c072b0f7..4e4ee801 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -1,5 +1,6 @@ bcrypt cryptography +idna ipaddress ; python_version < '3.0' unittest2 ; python_version < '2.7'