diff --git a/changelogs/fragments/92-ip-networks.yml b/changelogs/fragments/92-ip-networks.yml new file mode 100644 index 00000000..64594ab8 --- /dev/null +++ b/changelogs/fragments/92-ip-networks.yml @@ -0,0 +1,2 @@ +bugfixes: +- "openssl_*, x509_* modules - fix handling of general names which refer to IP networks and not IP addresses (https://github.com/ansible-collections/community.crypto/pull/92)." diff --git a/changelogs/fragments/92-openssl_csr-name-constraints.yml b/changelogs/fragments/92-openssl_csr-name-constraints.yml new file mode 100644 index 00000000..f0d64f9a --- /dev/null +++ b/changelogs/fragments/92-openssl_csr-name-constraints.yml @@ -0,0 +1,3 @@ +minor_changes: +- "openssl_csr - add support for name constraints extension (https://github.com/ansible-collections/community.crypto/issues/46)." +- "openssl_csr_info - add support for name constraints extension (https://github.com/ansible-collections/community.crypto/issues/46)." diff --git a/plugins/module_utils/crypto/cryptography_support.py b/plugins/module_utils/crypto/cryptography_support.py index a527d1c0..d156ecd3 100644 --- a/plugins/module_utils/crypto/cryptography_support.py +++ b/plugins/module_utils/crypto/cryptography_support.py @@ -205,7 +205,10 @@ def cryptography_get_name(name): if name.startswith('DNS:'): return x509.DNSName(to_text(name[4:])) if name.startswith('IP:'): - return x509.IPAddress(ipaddress.ip_address(to_text(name[3:]))) + 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:])) if name.startswith('URI:'): @@ -261,6 +264,8 @@ def cryptography_decode_name(name): if isinstance(name, x509.DNSName): return 'DNS:{0}'.format(name.value) if isinstance(name, x509.IPAddress): + if isinstance(name.value, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + return 'IP:{0}/{1}'.format(name.value.network_address.compressed, name.value.prefixlen) return 'IP:{0}'.format(name.value.compressed) if isinstance(name, x509.RFC822Name): return 'email:{0}'.format(name.value) diff --git a/plugins/module_utils/crypto/pyopenssl_support.py b/plugins/module_utils/crypto/pyopenssl_support.py index 8a14cf37..17c98d27 100644 --- a/plugins/module_utils/crypto/pyopenssl_support.py +++ b/plugins/module_utils/crypto/pyopenssl_support.py @@ -38,6 +38,10 @@ from ._objects import ( from ._obj2txt import obj2txt +from .basic import ( + OpenSSLObjectError, +) + def pyopenssl_normalize_name(name, short=False): nid = OpenSSL._util.lib.OBJ_txt2nid(to_bytes(name)) @@ -56,9 +60,13 @@ def pyopenssl_normalize_name_attribute(san): 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) - + address = san[3:] + if '/' in address: + ip = compat_ipaddress.ip_network(address) + san = 'IP:{0}/{1}'.format(ip.network_address.compressed, ip.prefixlen) + else: + ip = compat_ipaddress.ip_address(address) + san = 'IP:{0}'.format(ip.compressed) if san.startswith('Registered ID:'): san = 'RID:' + san[len('Registered ID:'):] # Some versions of OpenSSL apparently forgot the colon. Happens in CI with Ubuntu 16.04 and FreeBSD 11.1 @@ -119,3 +127,28 @@ def pyopenssl_get_extensions_from_csr(csr): # similarly to how cryptography does it. result[oid] = entry return result + + +def pyopenssl_parse_name_constraints(name_constraints_extension): + lines = to_text(name_constraints_extension, errors='surrogate_or_strict').splitlines() + exclude = None + excluded = [] + permitted = [] + for line in lines: + if line.startswith(' ') or line.startswith('\t'): + name = pyopenssl_normalize_name_attribute(line.strip()) + if exclude is True: + excluded.append(name) + elif exclude is False: + permitted.append(name) + else: + raise OpenSSLObjectError('Unexpected nameConstraint line: "{0}"'.format(line)) + else: + line_lc = line.lower() + if line_lc.startswith('exclud'): + exclude = True + elif line_lc.startswith('includ') or line_lc.startswith('permitt'): + exclude = False + else: + raise OpenSSLObjectError('Cannot parse nameConstraint line: "{0}"'.format(line)) + return permitted, excluded diff --git a/plugins/modules/openssl_csr.py b/plugins/modules/openssl_csr.py index fb205c0f..5560db99 100644 --- a/plugins/modules/openssl_csr.py +++ b/plugins/modules/openssl_csr.py @@ -183,13 +183,36 @@ options: aliases: [ ocspMustStaple ] ocsp_must_staple_critical: description: - - Should the OCSP Must Staple extension be considered as critical + - Should the OCSP Must Staple extension be considered as critical. - Note that according to the RFC, this extension should not be marked as critical, as old clients not knowing about OCSP Must Staple are required to reject such certificates (see U(https://tools.ietf.org/html/rfc7633#section-4)). type: bool aliases: [ ocspMustStaple_critical ] + name_constraints_permitted: + description: + - For CA certificates, this specifies a list of identifiers which describe + subtrees of names that this CA is allowed to issue certificates for. + - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName) and the ones specific to your CA). + type: list + elements: str + version_added: 1.1.0 + name_constraints_excluded: + description: + - For CA certificates, this specifies a list of identifiers which describe + subtrees of names that this CA is *not* allowed to issue certificates for. + - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), + C(otherName) and the ones specific to your CA). + type: list + elements: str + version_added: 1.1.0 + name_constraints_critical: + description: + - Should the Name Constraints extension be considered as critical. + type: bool + version_added: 1.1.0 select_crypto_backend: description: - Determines which crypto backend to use. @@ -412,6 +435,20 @@ ocsp_must_staple: returned: changed or success type: bool sample: false +name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.somedomain.com'] + version_added: 1.1.0 +name_constraints_excluded: + description: List of excluded subtrees the CA cannot sign certificates for. + returned: changed or success + type: list + elements: str + sample: ['email:.com'] + version_added: 1.1.0 backup_file: description: Name of backup file created. returned: changed and if I(backup) is C(yes) @@ -461,6 +498,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import ( pyopenssl_normalize_name_attribute, + pyopenssl_parse_name_constraints, ) MINIMAL_PYOPENSSL_VERSION = '0.15' @@ -534,6 +572,9 @@ class CertificateSigningRequestBase(OpenSSLObject): self.basicConstraints_critical = module.params['basic_constraints_critical'] self.ocspMustStaple = module.params['ocsp_must_staple'] self.ocspMustStaple_critical = module.params['ocsp_must_staple_critical'] + self.name_constraints_permitted = module.params['name_constraints_permitted'] or [] + self.name_constraints_excluded = module.params['name_constraints_excluded'] or [] + self.name_constraints_critical = module.params['name_constraints_critical'] self.create_subject_key_identifier = module.params['create_subject_key_identifier'] self.subject_key_identifier = module.params['subject_key_identifier'] self.authority_key_identifier = module.params['authority_key_identifier'] @@ -637,7 +678,9 @@ class CertificateSigningRequestBase(OpenSSLObject): 'extendedKeyUsage': self.extendedKeyUsage, 'basicConstraints': self.basicConstraints, 'ocspMustStaple': self.ocspMustStaple, - 'changed': self.changed + 'changed': self.changed, + 'name_constraints_permitted': self.name_constraints_permitted, + 'name_constraints_excluded': self.name_constraints_excluded, } if self.backup_file: result['backup_file'] = self.backup_file @@ -697,6 +740,13 @@ class CertificateSigningRequestPyOpenSSL(CertificateSigningRequestBase): usages = ', '.join(self.basicConstraints) extensions.append(crypto.X509Extension(b"basicConstraints", self.basicConstraints_critical, usages.encode('ascii'))) + if self.name_constraints_permitted or self.name_constraints_excluded: + usages = ', '.join( + ['permitted;{0}'.format(name) for name in self.name_constraints_permitted] + + ['excluded;{0}'.format(name) for name in self.name_constraints_excluded] + ) + extensions.append(crypto.X509Extension(b"nameConstraints", self.name_constraints_critical, usages.encode('ascii'))) + if self.ocspMustStaple: extensions.append(crypto.X509Extension(OPENSSL_MUST_STAPLE_NAME, self.ocspMustStaple_critical, OPENSSL_MUST_STAPLE_VALUE)) @@ -773,6 +823,22 @@ class CertificateSigningRequestPyOpenSSL(CertificateSigningRequestBase): def _check_basicConstraints(extensions): return _check_keyUsage_(extensions, b'basicConstraints', self.basicConstraints, self.basicConstraints_critical) + def _check_nameConstraints(extensions): + nc_ext = next((ext for ext in extensions if ext.get_short_name() == b'nameConstraints'), '') + permitted, excluded = pyopenssl_parse_name_constraints(nc_ext) + if self.name_constraints_permitted or self.name_constraints_excluded: + if set(permitted) != set([pyopenssl_normalize_name_attribute(to_text(name)) for name in self.name_constraints_permitted]): + return False + if set(excluded) != set([pyopenssl_normalize_name_attribute(to_text(name)) for name in self.name_constraints_excluded]): + return False + if nc_ext.get_critical() != self.name_constraints_critical: + return False + else: + if permitted or excluded: + return False + + return True + def _check_ocspMustStaple(extensions): oms_ext = [ext for ext in extensions if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE] if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000: @@ -787,7 +853,7 @@ class CertificateSigningRequestPyOpenSSL(CertificateSigningRequestBase): extensions = csr.get_extensions() return (_check_subjectAltName(extensions) and _check_keyUsage(extensions) and _check_extenededKeyUsage(extensions) and _check_basicConstraints(extensions) and - _check_ocspMustStaple(extensions)) + _check_ocspMustStaple(extensions) and _check_nameConstraints(extensions)) def _check_signature(csr): try: @@ -849,6 +915,15 @@ class CertificateSigningRequestCryptography(CertificateSigningRequestBase): critical=self.ocspMustStaple_critical ) + if self.name_constraints_permitted or self.name_constraints_excluded: + try: + csr = csr.add_extension(cryptography.x509.NameConstraints( + [cryptography_get_name(name) for name in self.name_constraints_permitted], + [cryptography_get_name(name) for name in self.name_constraints_excluded], + ), critical=self.name_constraints_critical) + except TypeError as e: + raise OpenSSLObjectError('Error while parsing name constraint: {0}'.format(e)) + if self.create_subject_key_identifier: csr = csr.add_extension( cryptography.x509.SubjectKeyIdentifier.from_public_key(self.privatekey.public_key()), @@ -991,6 +1066,19 @@ class CertificateSigningRequestCryptography(CertificateSigningRequestBase): else: return tlsfeature_ext is None + def _check_nameConstraints(extensions): + current_nc_ext = _find_extension(extensions, cryptography.x509.NameConstraints) + current_nc_perm = [str(altname) for altname in current_nc_ext.value.permitted_subtrees] if current_nc_ext else [] + current_nc_excl = [str(altname) for altname in current_nc_ext.value.excluded_subtrees] if current_nc_ext else [] + nc_perm = [str(cryptography_get_name(altname)) for altname in self.name_constraints_permitted] + nc_excl = [str(cryptography_get_name(altname)) for altname in self.name_constraints_excluded] + if set(nc_perm) != set(current_nc_perm) or set(nc_excl) != set(current_nc_excl): + return False + if nc_perm or nc_excl: + if current_nc_ext.critical != self.name_constraints_critical: + return False + return True + def _check_subject_key_identifier(extensions): ext = _find_extension(extensions, cryptography.x509.SubjectKeyIdentifier) if self.create_subject_key_identifier or self.subject_key_identifier is not None: @@ -1026,7 +1114,7 @@ class CertificateSigningRequestCryptography(CertificateSigningRequestBase): return (_check_subjectAltName(extensions) and _check_keyUsage(extensions) and _check_extenededKeyUsage(extensions) and _check_basicConstraints(extensions) and _check_ocspMustStaple(extensions) and _check_subject_key_identifier(extensions) and - _check_authority_key_identifier(extensions)) + _check_authority_key_identifier(extensions) and _check_nameConstraints(extensions)) def _check_signature(csr): if not csr.is_signature_valid: @@ -1081,6 +1169,9 @@ def main(): basic_constraints_critical=dict(type='bool', default=False, aliases=['basicConstraints_critical']), ocsp_must_staple=dict(type='bool', default=False, aliases=['ocspMustStaple']), ocsp_must_staple_critical=dict(type='bool', default=False, aliases=['ocspMustStaple_critical']), + name_constraints_permitted=dict(type='list', elements='str'), + name_constraints_excluded=dict(type='list', elements='str'), + name_constraints_critical=dict(type='bool', default=False), backup=dict(type='bool', default=False), create_subject_key_identifier=dict(type='bool', default=False), subject_key_identifier=dict(type='str'), diff --git a/plugins/modules/openssl_csr_info.py b/plugins/modules/openssl_csr_info.py index b136ab58..2d113740 100644 --- a/plugins/modules/openssl_csr_info.py +++ b/plugins/modules/openssl_csr_info.py @@ -141,6 +141,29 @@ ocsp_must_staple_critical: description: Whether the C(ocsp_must_staple) extension is critical. returned: success type: bool +name_constraints_permitted: + description: List of permitted subtrees to sign certificates for. + returned: success + type: list + elements: str + sample: ['email:.somedomain.com'] + version_added: 1.1.0 +name_constraints_excluded: + description: + - List of excluded subtrees the CA cannot sign certificates for. + - Is C(none) if extension is not present. + returned: success + type: list + elements: str + sample: ['email:.com'] + version_added: 1.1.0 +name_constraints_critical: + description: + - Whether the C(name_constraints) extension is critical. + - Is C(none) if extension is not present. + returned: success + type: bool + version_added: 1.1.0 subject: description: - The CSR's subject as a dictionary. @@ -231,6 +254,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_ pyopenssl_get_extensions_from_csr, pyopenssl_normalize_name, pyopenssl_normalize_name_attribute, + pyopenssl_parse_name_constraints, ) MINIMAL_CRYPTOGRAPHY_VERSION = '1.3' @@ -317,6 +341,10 @@ class CertificateSigningRequestInfo(OpenSSLObject): def _get_subject_alt_name(self): pass + @abc.abstractmethod + def _get_name_constraints(self): + pass + @abc.abstractmethod def _get_public_key(self, binary): pass @@ -351,6 +379,11 @@ class CertificateSigningRequestInfo(OpenSSLObject): result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints() result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple() result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name() + ( + result['name_constraints_permitted'], + result['name_constraints_excluded'], + result['name_constraints_critical'], + ) = self._get_name_constraints() result['public_key'] = self._get_public_key(binary=False) pk = self._get_public_key(binary=True) @@ -474,6 +507,15 @@ class CertificateSigningRequestInfoCryptography(CertificateSigningRequestInfo): except cryptography.x509.ExtensionNotFound: return None, False + 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 []] + return permitted, excluded, nc_ext.critical + except cryptography.x509.ExtensionNotFound: + return None, None, False + def _get_public_key(self, binary): return self.csr.public_key().public_bytes( serialization.Encoding.DER if binary else serialization.Encoding.PEM, @@ -559,6 +601,13 @@ class CertificateSigningRequestInfoPyOpenSSL(CertificateSigningRequestInfo): return result, bool(extension.get_critical()) return None, False + def _get_name_constraints(self): + for extension in self.csr.get_extensions(): + if extension.get_short_name() == b'nameConstraints': + permitted, excluded = pyopenssl_parse_name_constraints(extension) + return permitted, excluded, bool(extension.get_critical()) + return None, None, False + def _get_public_key(self, binary): try: return crypto.dump_publickey( diff --git a/tests/integration/targets/openssl_csr/tasks/impl.yml b/tests/integration/targets/openssl_csr/tasks/impl.yml index ccecfefb..6a12f9e9 100644 --- a/tests/integration/targets/openssl_csr/tasks/impl.yml +++ b/tests/integration/targets/openssl_csr/tasks/impl.yml @@ -556,6 +556,11 @@ - "CA:TRUE" - "pathlen:23" basic_constraints_critical: yes + name_constraints_permitted: '{{ value_for_name_constraints_permitted if select_crypto_backend != "pyopenssl" else value_for_name_constraints_permitted_pyopenssl }}' + name_constraints_excluded: + - "DNS:.example.com" + - "DNS:.org" + name_constraints_critical: yes ocsp_must_staple: yes subject_key_identifier: '{{ "00:11:22:33" if select_crypto_backend != "pyopenssl" else omit }}' authority_key_identifier: '{{ "44:55:66:77" if select_crypto_backend != "pyopenssl" else omit }}' @@ -611,6 +616,13 @@ - "otherName:1.3.6.1.4.1.311.20.2.3;UTF8:bob@localhost" - "dirName:O = Example Net, CN = example.net" - "dirName:/O=Example Com/CN=example.com" + value_for_name_constraints_permitted: + - "DNS:www.example.com" + - "IP:1.2.3.0/24" + - "IP:::1:0:0/112" + value_for_name_constraints_permitted_pyopenssl: + - "DNS:www.example.com" + - "IP:1.2.3.0/255.255.255.0" register: everything_1 - name: Generate CSR with everything (idempotent, check mode) @@ -652,6 +664,11 @@ - "CA:TRUE" - "pathlen:23" basic_constraints_critical: yes + name_constraints_permitted: '{{ value_for_name_constraints_permitted if select_crypto_backend != "pyopenssl" else value_for_name_constraints_permitted_pyopenssl }}' + name_constraints_excluded: + - "DNS:.org" + - "DNS:.example.com" + name_constraints_critical: yes ocsp_must_staple: yes subject_key_identifier: '{{ "00:11:22:33" if select_crypto_backend != "pyopenssl" else omit }}' authority_key_identifier: '{{ "44:55:66:77" if select_crypto_backend != "pyopenssl" else omit }}' @@ -707,6 +724,13 @@ - "otherName:1.3.6.1.4.1.311.20.2.3;UTF8:bob@localhost" - "dirName:O=Example Net,CN=example.net" - "dirName:/O = Example Com/CN = example.com" + value_for_name_constraints_permitted: + - "DNS:www.example.com" + - "IP:1.2.3.0/255.255.255.0" + - "IP:0::0:1:0:0/112" + value_for_name_constraints_permitted_pyopenssl: + - "DNS:www.example.com" + - "IP:1.2.3.0/255.255.255.0" check_mode: yes register: everything_2 @@ -749,6 +773,11 @@ - "CA:TRUE" - "pathlen:23" basic_constraints_critical: yes + name_constraints_permitted: '{{ value_for_name_constraints_permitted if select_crypto_backend != "pyopenssl" else value_for_name_constraints_permitted_pyopenssl }}' + name_constraints_excluded: + - "DNS:.org" + - "DNS:.example.com" + name_constraints_critical: yes ocsp_must_staple: yes subject_key_identifier: '{{ "00:11:22:33" if select_crypto_backend != "pyopenssl" else omit }}' authority_key_identifier: '{{ "44:55:66:77" if select_crypto_backend != "pyopenssl" else omit }}' @@ -804,6 +833,13 @@ - "otherName:1.3.6.1.4.1.311.20.2.3;UTF8:bob@localhost" - "dirName:O =Example Net, CN= example.net" - "dirName:/O =Example Com/CN= example.com" + value_for_name_constraints_permitted: + - "DNS:www.example.com" + - "IP:1.2.3.0/255.255.255.0" + - "IP:0::0:1:0:0/112" + value_for_name_constraints_permitted_pyopenssl: + - "DNS:www.example.com" + - "IP:1.2.3.0/255.255.255.0" register: everything_3 - name: Get info from CSR with everything diff --git a/tests/integration/targets/openssl_csr/tests/validate.yml b/tests/integration/targets/openssl_csr/tests/validate.yml index b5de7f0f..bfbb9d87 100644 --- a/tests/integration/targets/openssl_csr/tests/validate.yml +++ b/tests/integration/targets/openssl_csr/tests/validate.yml @@ -200,7 +200,7 @@ "Key Agreement", "Key Encipherment", "Non Repudiation" - ], + ] - everything_info.key_usage_critical == true - everything_info.ocsp_must_staple == true - everything_info.ocsp_must_staple_critical == false @@ -223,6 +223,11 @@ - everything_info.subject.userId == "asdf" - everything_info.subject | length == 16 - everything_info.subject_alt_name_critical == false + - everything_info.name_constraints_excluded == [ + "DNS:.example.com", + "DNS:.org", + ] + - everything_info.name_constraints_critical == true - name: Check CSR with everything (pyOpenSSL specific) assert: @@ -249,6 +254,10 @@ "dvcs", "qcStatements", ] + - everything_info.name_constraints_permitted == [ + "DNS:www.example.com", + "IP:1.2.3.0/24", + ] when: select_crypto_backend == 'pyopenssl' - name: Check CSR with everything (non-pyOpenSSL specific) @@ -288,6 +297,11 @@ "dvcs", "qcStatements", ] + - everything_info.name_constraints_permitted == [ + "DNS:www.example.com", + "IP:1.2.3.0/24", + "IP:::1:0:0/112", + ] when: select_crypto_backend != 'pyopenssl' - name: Verify Ed25519 and Ed448 tests (for cryptography >= 2.6, < 2.8)