diff --git a/changelogs/fragments/167-openssl_csr-crl-distribution-points.yml b/changelogs/fragments/167-openssl_csr-crl-distribution-points.yml new file mode 100644 index 00000000..15824ae9 --- /dev/null +++ b/changelogs/fragments/167-openssl_csr-crl-distribution-points.yml @@ -0,0 +1,2 @@ +minor_changes: +- "openssl_csr, openssl_csr_pipe - allow to specify CRL distribution endpoints with ``crl_distribution_points`` (https://github.com/ansible-collections/community.crypto/issues/147, https://github.com/ansible-collections/community.crypto/pull/167)." diff --git a/plugins/doc_fragments/module_csr.py b/plugins/doc_fragments/module_csr.py index 249ec167..8d9f0b73 100644 --- a/plugins/doc_fragments/module_csr.py +++ b/plugins/doc_fragments/module_csr.py @@ -260,6 +260,48 @@ options: - The C(AuthorityKeyIdentifier) will only be added if at least one of I(authority_key_identifier), I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. type: int + crl_distribution_points: + description: + - Allows to specify one or multiple CRL distribution points. + - Only supported by the C(cryptography) backend. + type: list + elements: dict + suboptions: + full_name: + description: + - Describes how the CRL can be retrieved. + - Mutually exclusive with I(relative_name). + - "Example: C(URI:https://ca.example.com/revocations.crl)." + type: list + elements: str + relative_name: + description: + - Describes how the CRL can be retrieved relative to the CRL issuer. + - Mutually exclusive with I(full_name). + - "Example: C(/CN=example.com)." + - Can only be used when cryptography >= 1.6 is installed. + type: list + elements: str + crl_issuer: + description: + - Information about the issuer of the CRL. + type: list + elements: str + reasons: + description: + - List of reasons that this distribution point can be used for when performing revocation checks. + type: list + elements: str + choices: + - key_compromise + - ca_compromise + - affiliation_changed + - superseded + - cessation_of_operation + - certificate_hold + - privilege_withdrawn + - aa_compromise + version_added: 1.4.0 notes: - If the certificate signing request already exists it will be checked whether subjectAltName, keyUsage, extendedKeyUsage and basicConstraints only contain the requested values, whether diff --git a/plugins/module_utils/crypto/cryptography_support.py b/plugins/module_utils/crypto/cryptography_support.py index d156ecd3..67ec34f4 100644 --- a/plugins/module_utils/crypto/cryptography_support.py +++ b/plugins/module_utils/crypto/cryptography_support.py @@ -152,6 +152,36 @@ def _parse_hex(bytesstr): return data +DN_COMPONENT_START_RE = re.compile(r'^ *([a-zA-z0-9]+) *= *') + + +def _parse_dn_component(name, sep=',', sep_str='\\', decode_remainder=True): + m = DN_COMPONENT_START_RE.match(name) + if not m: + raise OpenSSLObjectError('cannot start part in "{0}"'.format(name)) + oid = cryptography_name_to_oid(m.group(1)) + idx = len(m.group(0)) + decoded_name = [] + if decode_remainder: + length = len(name) + while idx < length: + i = idx + while i < length and name[i] not in sep_str: + i += 1 + if i > idx: + decoded_name.append(name[idx:i]) + idx = i + while idx + 1 < length and name[idx] == '\\': + decoded_name.append(name[idx + 1]) + idx += 2 + if idx < length and name[idx] == sep: + break + else: + decoded_name.append(name[idx:]) + idx = len(name) + return x509.NameAttribute(oid, ''.join(decoded_name)), name[idx:] + + def _parse_dn(name): ''' Parse a Distinguished Name. @@ -166,29 +196,12 @@ def _parse_dn(name): name = name[1:] sep_str = sep + '\\' result = [] - start_re = re.compile(r'^ *([a-zA-z0-9]+) *= *') while name: - m = start_re.match(name) - if not m: - raise OpenSSLObjectError('Error while parsing distinguished name "{0}": cannot start part in "{1}"'.format(original_name, name)) - oid = cryptography_name_to_oid(m.group(1)) - idx = len(m.group(0)) - decoded_name = [] - length = len(name) - while idx < length: - i = idx - while i < length and name[i] not in sep_str: - i += 1 - if i > idx: - decoded_name.append(name[idx:i]) - idx = i - while idx + 1 < length and name[idx] == '\\': - decoded_name.append(name[idx + 1]) - idx += 2 - if idx < length and name[idx] == sep: - break - result.append(x509.NameAttribute(oid, ''.join(decoded_name))) - name = name[idx:] + try: + attribute, name = _parse_dn_component(name, sep=sep, sep_str=sep_str) + except OpenSSLObjectError as e: + raise OpenSSLObjectError('Error while parsing distinguished name "{0}": {1}'.format(original_name, e)) + result.append(attribute) if name: if name[0] != sep or len(name) < 2: raise OpenSSLObjectError('Error while parsing distinguished name "{0}": unexpected end of string'.format(original_name)) @@ -196,9 +209,19 @@ def _parse_dn(name): return result -def cryptography_get_name(name): +def cryptography_parse_relative_distinguished_name(rdn): + names = [] + for part in rdn: + try: + names.append(_parse_dn_component(to_text(part), decode_remainder=False)[0]) + except OpenSSLObjectError as e: + raise OpenSSLObjectError('Error while parsing relative distinguished name "{0}": {1}'.format(part, e)) + return cryptography.x509.RelativeDistinguishedName(names) + + +def cryptography_get_name(name, what='Subject Alternative Name'): ''' - Given a name string, returns a cryptography x509.Name object. + Given a name string, returns a cryptography x509.GeneralName object. Raises an OpenSSLObjectError if the name is unknown or cannot be parsed. ''' try: @@ -216,7 +239,7 @@ def cryptography_get_name(name): if name.startswith('RID:'): m = re.match(r'^([0-9]+(?:\.[0-9]+)*)$', to_text(name[4:])) if not m: - raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}"'.format(name)) + raise OpenSSLObjectError('Cannot parse {what} "{name}"'.format(name=name, what=what)) return x509.RegisteredID(x509.oid.ObjectIdentifier(m.group(1))) if name.startswith('otherName:'): # otherName can either be a raw ASN.1 hex string or in the format that OpenSSL works with. @@ -228,9 +251,9 @@ def cryptography_get_name(name): # defailts on the format expected. name = to_text(name[10:], errors='surrogate_or_strict') if ';' not in name: - raise OpenSSLObjectError('Cannot parse Subject Alternative Name otherName "{0}", must be in the ' + raise OpenSSLObjectError('Cannot parse {what} otherName "{name}", must be in the ' 'format "otherName:;" or ' - '"otherName:;"'.format(name)) + '"otherName:;"'.format(name=name, what=what)) oid, value = name.split(';', 1) b_value = serialize_asn1_string_as_der(value) @@ -238,10 +261,10 @@ def cryptography_get_name(name): if name.startswith('dirName:'): return x509.DirectoryName(x509.Name(_parse_dn(to_text(name[8:])))) except Exception as e: - raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}": {1}'.format(name, e)) + raise OpenSSLObjectError('Cannot parse {what} "{name}": {error}'.format(name=name, what=what, error=e)) if ':' not in name: - raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}" (forgot "DNS:" prefix?)'.format(name)) - raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}" (potentially unsupported by cryptography backend)'.format(name)) + raise OpenSSLObjectError('Cannot parse {what} "{name}" (forgot "DNS:" prefix?)'.format(name=name, what=what)) + raise OpenSSLObjectError('Cannot parse {what} "{name}" (potentially unsupported by cryptography backend)'.format(name=name, what=what)) def _dn_escape_value(value): @@ -258,7 +281,7 @@ def _dn_escape_value(value): def cryptography_decode_name(name): ''' - Given a cryptography x509.Name object, returns a string. + Given a cryptography x509.GeneralName object, returns a string. Raises an OpenSSLObjectError if the name is not supported. ''' if isinstance(name, x509.DNSName): diff --git a/plugins/module_utils/crypto/module_backends/csr.py b/plugins/module_utils/crypto/module_backends/csr.py index 457eab7a..f5ba21ff 100644 --- a/plugins/module_utils/crypto/module_backends/csr.py +++ b/plugins/module_utils/crypto/module_backends/csr.py @@ -36,6 +36,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp cryptography_name_to_oid, cryptography_key_needs_digest_for_signing, cryptography_parse_key_usage_params, + cryptography_parse_relative_distinguished_name, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import ( + REVOCATION_REASON_MAP, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import ( @@ -128,6 +133,7 @@ class CertificateSigningRequestBackend(object): self.authority_key_identifier = module.params['authority_key_identifier'] self.authority_cert_issuer = module.params['authority_cert_issuer'] self.authority_cert_serial_number = module.params['authority_cert_serial_number'] + self.crl_distribution_points = module.params['crl_distribution_points'] self.csr = None self.privatekey = None @@ -245,9 +251,10 @@ class CertificateSigningRequestBackend(object): # Implementation with using pyOpenSSL class CertificateSigningRequestPyOpenSSLBackend(CertificateSigningRequestBackend): def __init__(self, module): - if module.params['create_subject_key_identifier']: - module.fail_json(msg='You cannot use create_subject_key_identifier with the pyOpenSSL backend!') - for o in ('subject_key_identifier', 'authority_key_identifier', 'authority_cert_issuer', 'authority_cert_serial_number'): + for o in ('create_subject_key_identifier', ): + if module.params[o]: + module.fail_json(msg='You cannot use {0} with the pyOpenSSL backend!'.format(o)) + for o in ('subject_key_identifier', 'authority_key_identifier', 'authority_cert_issuer', 'authority_cert_serial_number', 'crl_distribution_points'): if module.params[o] is not None: module.fail_json(msg='You cannot use {0} with the pyOpenSSL backend!'.format(o)) super(CertificateSigningRequestPyOpenSSLBackend, self).__init__(module, 'pyopenssl') @@ -409,6 +416,39 @@ class CertificateSigningRequestPyOpenSSLBackend(CertificateSigningRequestBackend return _check_subject(self.existing_csr) and _check_extensions(self.existing_csr) and _check_signature(self.existing_csr) +def parse_crl_distribution_points(module, crl_distribution_points): + result = [] + for index, parse_crl_distribution_point in enumerate(crl_distribution_points): + try: + params = dict( + full_name=None, + relative_name=None, + crl_issuer=None, + reasons=None, + ) + if parse_crl_distribution_point['full_name'] is not None: + params['full_name'] = [cryptography_get_name(name, 'full name') for name in parse_crl_distribution_point['full_name']] + if parse_crl_distribution_point['relative_name'] is not None: + try: + params['relative_name'] = cryptography_parse_relative_distinguished_name(parse_crl_distribution_point['relative_name']) + except Exception: + # If cryptography's version is < 1.6, the error is probably caused by that + if CRYPTOGRAPHY_VERSION < LooseVersion('1.6'): + raise OpenSSLObjectError('Cannot specify relative_name for cryptography < 1.6') + raise + if parse_crl_distribution_point['crl_issuer'] is not None: + params['crl_issuer'] = [cryptography_get_name(name, 'CRL issuer') for name in parse_crl_distribution_point['crl_issuer']] + if parse_crl_distribution_point['reasons'] is not None: + reasons = [] + for reason in parse_crl_distribution_point['reasons']: + reasons.append(REVOCATION_REASON_MAP[reason]) + params['reasons'] = frozenset(reasons) + result.append(cryptography.x509.DistributionPoint(**params)) + except OpenSSLObjectError as e: + raise OpenSSLObjectError('Error while parsing CRL distribution point #{index}: {error}'.format(index=index, error=e)) + return result + + # Implementation with using cryptography class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBackend): def __init__(self, module): @@ -417,6 +457,9 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack if self.version != 1: module.warn('The cryptography backend only supports version 1. (The only valid value according to RFC 2986.)') + if self.crl_distribution_points: + self.crl_distribution_points = parse_crl_distribution_points(module, self.crl_distribution_points) + def generate_csr(self): """(Re-)Generate CSR.""" self._ensure_private_key_loaded() @@ -460,8 +503,8 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack 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], + [cryptography_get_name(name, 'name constraints permitted') for name in self.name_constraints_permitted], + [cryptography_get_name(name, 'name constraints excluded') 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)) @@ -477,12 +520,18 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack if self.authority_key_identifier is not None or self.authority_cert_issuer is not None or self.authority_cert_serial_number is not None: issuers = None if self.authority_cert_issuer is not None: - issuers = [cryptography_get_name(n) for n in self.authority_cert_issuer] + issuers = [cryptography_get_name(n, 'authority cert issuer') for n in self.authority_cert_issuer] csr = csr.add_extension( cryptography.x509.AuthorityKeyIdentifier(self.authority_key_identifier, issuers, self.authority_cert_serial_number), critical=False ) + if self.crl_distribution_points: + csr = csr.add_extension( + cryptography.x509.CRLDistributionPoints(self.crl_distribution_points), + critical=False + ) + digest = None if cryptography_key_needs_digest_for_signing(self.privatekey): digest = select_message_digest(self.digest) @@ -606,8 +655,8 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack 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] + nc_perm = [str(cryptography_get_name(altname, 'name constraints permitted')) for altname in self.name_constraints_permitted] + nc_excl = [str(cryptography_get_name(altname, 'name constraints excluded')) 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: @@ -636,7 +685,7 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack aci = None csr_aci = None if self.authority_cert_issuer is not None: - aci = [str(cryptography_get_name(n)) for n in self.authority_cert_issuer] + aci = [str(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer] if ext.value.authority_cert_issuer is not None: csr_aci = [str(n) for n in ext.value.authority_cert_issuer] return (ext.value.key_identifier == self.authority_key_identifier @@ -645,12 +694,21 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack else: return ext is None + def _check_crl_distribution_points(extensions): + ext = _find_extension(extensions, cryptography.x509.CRLDistributionPoints) + if self.crl_distribution_points is None: + return ext is None + if not ext: + return False + return list(ext.value) == self.crl_distribution_points + def _check_extensions(csr): extensions = csr.extensions 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) and _check_nameConstraints(extensions)) + _check_authority_key_identifier(extensions) and _check_nameConstraints(extensions) and + _check_crl_distribution_points(extensions)) def _check_signature(csr): if not csr.is_signature_valid: @@ -750,6 +808,26 @@ def get_csr_argument_spec(): authority_key_identifier=dict(type='str'), authority_cert_issuer=dict(type='list', elements='str'), authority_cert_serial_number=dict(type='int'), + crl_distribution_points=dict( + type='list', + elements='dict', + options=dict( + full_name=dict(type='list', elements='str'), + relative_name=dict(type='list', elements='str'), + crl_issuer=dict(type='list', elements='str'), + reasons=dict(type='list', elements='str', choices=[ + 'key_compromise', + 'ca_compromise', + 'affiliation_changed', + 'superseded', + 'cessation_of_operation', + 'certificate_hold', + 'privilege_withdrawn', + 'aa_compromise', + ]), + ), + mutually_exclusive=[('full_name', 'relative_name')] + ), select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']), ), required_together=[ diff --git a/plugins/modules/openssl_csr.py b/plugins/modules/openssl_csr.py index 031fa962..e9d375ac 100644 --- a/plugins/modules/openssl_csr.py +++ b/plugins/modules/openssl_csr.py @@ -142,6 +142,21 @@ EXAMPLES = r''' extended_key_usage: - clientAuth subject_alt_name: otherName:1.3.6.1.4.1.311.20.2.3;UTF8:username@localhost + +- name: Generate an OpenSSL Certificate Signing Request with a CRL distribution point + community.crypto.openssl_csr: + path: /etc/ssl/csr/www.ansible.com.csr + privatekey_path: /etc/ssl/private/ansible.com.pem + common_name: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + crl_issuer: + - "URI:https://ca.example.com/" + reasons: + - key_compromise + - ca_compromise + - cessation_of_operation ''' RETURN = r''' diff --git a/plugins/modules/x509_crl.py b/plugins/modules/x509_crl.py index 62afc3ea..bf9fadef 100644 --- a/plugins/modules/x509_crl.py +++ b/plugins/modules/x509_crl.py @@ -502,7 +502,7 @@ class CRL(OpenSSLObject): result['serial_number'] = rc['serial_number'] # All other options if rc['issuer']: - result['issuer'] = [cryptography_get_name(issuer) for issuer in rc['issuer']] + result['issuer'] = [cryptography_get_name(issuer, 'issuer') for issuer in rc['issuer']] result['issuer_critical'] = rc['issuer_critical'] result['revocation_date'] = get_relative_time_option( rc['revocation_date'], @@ -648,7 +648,7 @@ class CRL(OpenSSLObject): if entry['issuer'] is not None: revoked_cert = revoked_cert.add_extension( x509.CertificateIssuer([ - cryptography_get_name(name) for name in entry['issuer'] + cryptography_get_name(name, 'issuer') for name in entry['issuer'] ]), entry['issuer_critical'] ) diff --git a/tests/integration/targets/openssl_csr/tasks/impl.yml b/tests/integration/targets/openssl_csr/tasks/impl.yml index 2db7c60b..20227ce9 100644 --- a/tests/integration/targets/openssl_csr/tasks/impl.yml +++ b/tests/integration/targets/openssl_csr/tasks/impl.yml @@ -902,3 +902,96 @@ ignore_errors: yes when: select_crypto_backend == 'cryptography' and cryptography_version.stdout is version('2.6', '>=') + +- name: "({{ select_crypto_backend }}) CRL distribution endpoints (for cryptography >= 1.6)" + block: + - name: "({{ select_crypto_backend }}) Create CSR with CRL distribution endpoints" + openssl_csr: + path: '{{ output_dir }}/csr_crl_d_e.csr' + privatekey_path: '{{ output_dir }}/privatekey.pem' + subject: + commonName: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + crl_issuer: + - "URI:https://ca.example.com/" + reasons: + - key_compromise + - ca_compromise + - cessation_of_operation + - relative_name: + - CN=ca.example.com + reasons: + - certificate_hold + - {} + select_crypto_backend: '{{ select_crypto_backend }}' + register: crl_distribution_endpoints_1 + + - name: "({{ select_crypto_backend }}) Create CSR with CRL distribution endpoints (idempotence)" + openssl_csr: + path: '{{ output_dir }}/csr_crl_d_e.csr' + privatekey_path: '{{ output_dir }}/privatekey.pem' + subject: + commonName: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + crl_issuer: + - "URI:https://ca.example.com/" + reasons: + - key_compromise + - ca_compromise + - cessation_of_operation + - relative_name: + - CN=ca.example.com + reasons: + - certificate_hold + - {} + select_crypto_backend: '{{ select_crypto_backend }}' + register: crl_distribution_endpoints_2 + + - name: "({{ select_crypto_backend }}) Create CSR with CRL distribution endpoints (change)" + openssl_csr: + path: '{{ output_dir }}/csr_crl_d_e.csr' + privatekey_path: '{{ output_dir }}/privatekey.pem' + subject: + commonName: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + crl_issuer: + - "URI:https://ca.example.com/" + reasons: + - key_compromise + - ca_compromise + - cessation_of_operation + - relative_name: + - CN=ca.example.com + reasons: + - certificate_hold + select_crypto_backend: '{{ select_crypto_backend }}' + register: crl_distribution_endpoints_3 + + - name: "({{ select_crypto_backend }}) Create CSR with CRL distribution endpoints (no endpoints)" + openssl_csr: + path: '{{ output_dir }}/csr_crl_d_e.csr' + privatekey_path: '{{ output_dir }}/privatekey.pem' + subject: + commonName: www.ansible.com + select_crypto_backend: '{{ select_crypto_backend }}' + register: crl_distribution_endpoints_4 + + - name: "({{ select_crypto_backend }}) Create CSR with CRL distribution endpoints" + openssl_csr: + path: '{{ output_dir }}/csr_crl_d_e.csr' + privatekey_path: '{{ output_dir }}/privatekey.pem' + subject: + commonName: www.ansible.com + crl_distribution_points: + - full_name: + - "URI:https://ca.example.com/revocations.crl" + select_crypto_backend: '{{ select_crypto_backend }}' + register: crl_distribution_endpoints_5 + + when: select_crypto_backend == 'cryptography' and cryptography_version.stdout is version('1.6', '>=') diff --git a/tests/integration/targets/openssl_csr/tests/validate.yml b/tests/integration/targets/openssl_csr/tests/validate.yml index a13454f4..8958a7ae 100644 --- a/tests/integration/targets/openssl_csr/tests/validate.yml +++ b/tests/integration/targets/openssl_csr/tests/validate.yml @@ -334,3 +334,13 @@ - generate_csr_ed25519_ed448_idempotent.results[0] is not changed - generate_csr_ed25519_ed448_idempotent.results[1] is not changed when: select_crypto_backend == 'cryptography' and cryptography_version.stdout is version('2.8', '>=') and generate_csr_ed25519_ed448_privatekey is not failed + +- name: "({{ select_crypto_backend }}) Verify CRL distribution endpoints (for cryptography >= 1.6)" + assert: + that: + - crl_distribution_endpoints_1 is changed + - crl_distribution_endpoints_2 is not changed + - crl_distribution_endpoints_3 is changed + - crl_distribution_endpoints_4 is changed + - crl_distribution_endpoints_5 is changed + when: select_crypto_backend == 'cryptography' and cryptography_version.stdout is version('1.6', '>=')