openssl_csr: allow to specify CRL distribution endpoints (#167)

* Improve error messages for name decoding (not all names appear in SANs).

* Refactor DN parsing, add relative DN parsing code.

* Allow to specify CRL distribution points.

* Add changelog fragment.

* Fix typo.

* Make sure value argument to x509.NameAttribute is a text.

* Update changelogs/fragments/167-openssl_csr-crl-distribution-points.yml

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Add example.

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
pull/178/head
Felix Fontein 2021-01-26 09:57:40 +01:00 committed by GitHub
parent a7c06b2ec4
commit d8ccebce60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 306 additions and 43 deletions

View File

@ -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)."

View File

@ -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

View File

@ -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:<OID>;<ASN.1 OpenSSL Encoded String>" or '
'"otherName:<OID>;<hex string>"'.format(name))
'"otherName:<OID>;<hex string>"'.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):

View File

@ -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=[

View File

@ -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'''

View File

@ -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']
)

View File

@ -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', '>=')

View File

@ -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', '>=')