Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add x509_certificate_revocation_info module #30

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
366 changes: 366 additions & 0 deletions plugins/modules/x509_certificate_revocation_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2020, Felix Fontein <[email protected]>
# 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

ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}

DOCUMENTATION = r'''
---
module: x509_certificate_revocation_info
short_description: Query revocation information for X.509 certificates
felixfontein marked this conversation as resolved.
Show resolved Hide resolved
version_added: 1.1.0
description:
- This module allows one to query revocation information for X.509 certificates.
requirements:
- cryptography >= 1.2
author:
- Felix Fontein (@felixfontein)
options:
path:
description:
- Remote absolute path where the certificate file is loaded from.
- Exactly one of I(path), I(content) or I(serial_number) must be specified.
type: path
content:
description:
- Content of the X.509 certificate in PEM format.
- Exactly one of I(path), I(content) or I(serial_number) must be specified.
type: str
serial_number:
description:
- The X.509 certificate's serial number.
- Exactly one of I(path), I(content) or I(serial_number) must be specified.
type: int
crl_path:
description:
- Path to CRL to check the certificate against.
type: path
crl_url:
description:
- URL of CRL to check the certificate against.
type: str
crl_from_cert:
description:
- If set to C(ignore), will ignore CRL Distribution Points specified in the certificate.
- If set to C(check), will check CRL Distribution Points specified in the certificate
until a CRL is found which contains the certificate. Will fail if a CRL cannot be
retrieved.
- If set to C(check_soft_fail), will check CRL Distribution Points specified in the certificate
until a CRL is found which contains the certificate. Will only warn if a CRL cannot be
retrieved.
type: str
default: ignore
choices: [ignore, check, check_soft_fail]

notes:
- All timestamp values are provided in ASN.1 TIME format, i.e. following the C(YYYYMMDDHHMMSSZ) pattern.
They are all in UTC.
seealso:
- module: x509_certificate
- module: x509_certificate_info
- module: x509_crl
- module: x509_crl_info
'''

EXAMPLES = r'''
- name: Check revocation
community.crypto.x509_certificate_revocation_info:
path: /etc/ssl/crt/ansible.com.crt
register: result

- name: Dump information
debug:
var: result
'''

RETURN = r'''
revoked:
description: Whether the certificate was determined to be revoked
returned: success
type: bool
crl_contained:
description: Whether the certificate has been found in a CRL.
returned: I(crl_path) has been specified
type: bool
crl_record:
description: Whether the certificate is expired (i.e. C(notAfter) is in the past)
returned: I(crl_path) has been specified
type: dict
contains:
serial_number:
description: Serial number of the certificate.
type: int
sample: 1234
revocation_date:
description: The point in time the certificate was revoked as ASN.1 TIME.
type: str
sample: 20190413202428Z
issuer:
description: The certificate's issuer.
type: list
elements: str
sample: '["DNS:ca.example.org"]'
issuer_critical:
description: Whether the certificate issuer extension is critical.
type: bool
sample: no
reason:
description:
- The value for the revocation reason extension.
- One of C(unspecified), C(key_compromise), C(ca_compromise), C(affiliation_changed), C(superseded),
C(cessation_of_operation), C(certificate_hold), C(privilege_withdrawn), C(aa_compromise), and
C(remove_from_crl).
type: str
sample: key_compromise
reason_critical:
description: Whether the revocation reason extension is critical.
type: bool
sample: no
invalidity_date:
description: |
The point in time it was known/suspected that the private key was compromised
or that the certificate otherwise became invalid as ASN.1 TIME.
type: str
sample: 20190413202428Z
invalidity_date_critical:
description: Whether the invalidity date extension is critical.
type: bool
sample: no
crl_source:
description: CRL where certificate was found in
returned: I(crl_path) has been specified and I(crl_contained) is C(true)
type: str
'''


import os
import traceback

from distutils.version import LooseVersion

from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native
from ansible.module_utils.urls import fetch_url

from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError,
)

from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
OpenSSLObject,
load_certificate,
)

from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import (
cryptography_decode_revoked_certificate,
cryptography_dump_revoked,
)

from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_serial_number_of_cert,
)

from ansible_collections.community.crypto.plugins.module_utils.crypto.identify import (
identify_pem_format,
)

MINIMAL_CRYPTOGRAPHY_VERSION = '1.2'

CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.backends import default_backend
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True


TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"


class CertificateRevocationInfo(OpenSSLObject):
def __init__(self, module):
super(CertificateRevocationInfo, self).__init__(
module.params['path'] or '',
'present',
False,
module.check_mode,
)
self.backend = 'cryptography'
self.module = module

self.content = module.params['content']
if self.content is not None:
self.content = self.content.encode('utf-8')

self.cert_serial_number = module.params['serial_number']
self.cert = None

def load(self):
if self.content is not None or self.module.params['path'] is not None:
self.cert = load_certificate(self.path, content=self.content, backend=self.backend)
self.cert_serial_number = cryptography_serial_number_of_cert(self.cert)
if self.cert_serial_number is None:
raise AssertionError('Internal error - no certificate serial number found')

def generate(self):
# Empty method because OpenSSLObject wants this
pass

def dump(self):
# Empty method because OpenSSLObject wants this
pass

def _get_ocsp_uri(self):
try:
ext = self.cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess)
for desc in ext.value:
if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP:
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
return desc.access_location.value
except x509.ExtensionNotFound as dummy:
pass
return None

def _check_crl(self, result, crl_blob, crl_source):
# Decode CRL
try:
if identify_pem_format(crl_blob):
crl = x509.load_pem_x509_crl(crl_blob, default_backend())
else:
crl = x509.load_der_x509_crl(crl_blob, default_backend())
except Exception as e:
self.module.fail_json(msg='Error while decoding CRL from {1}: {0}'.format(e, crl_source))

# Check revoked certificates
if 'crl_contained' not in result:
result['crl_contained'] = False
result['crl_record'] = None
for cert in crl:
if cert.serial_number == self.cert_serial_number:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition for serial number match, to be considered revoked a certificate also must have either:

  • The same issuer as the CRL
  • The "certificateIssuer" field on the CRL entry set to the same issuer as the certificate (which is one or more entries in the issuer alternate names).

Also,there's sort of a "fall through" for CRL issuer values, where if it's not set, it defaults to whatever the value was for the previous entry in the CRL. So you could have CRL Entries (don't quote me on this):

  1. <CertIssuer=NotSet> Defaults to Z, which was the CRL Issuer.
  2. <CertIssuer=A> Users Cert Issuer A
  3. <CertIssuer=NotSet> Uses Cert Issuer A
  4. <CertIssuer=NotSet> Uses Cert Issuer A
  5. <CertIssuer=B> Uses Cert Issuer B
  6. <CertIssuer=NotSet> Uses Cert Issuer B

Relevant section from RFC5280 https://tools.ietf.org/html/rfc5280

5.3.3. Certificate Issuer
This CRL entry extension identifies the certificate issuer associated
with an entry in an indirect CRL, that is, a CRL that has the
indirectCRL indicator set in its issuing distribution point
extension. When present, the certificate issuer CRL entry extension
includes one or more names from the issuer field and/or issuer
alternative name extension of the certificate that corresponds to the
CRL entry. If this extension is not present on the first entry in an
indirect CRL, the certificate issuer defaults to the CRL issuer. On
subsequent entries in an indirect CRL, if this extension is not
present, the certificate issuer for the entry is the same as that for
the preceding entry. This field is defined as follows:

id-ce-certificateIssuer OBJECT IDENTIFIER ::= { id-ce 29 }

CertificateIssuer ::= GeneralNames

Conforming CRL issuers MUST include in this extension the
distinguished name (DN) from the issuer field of the certificate that
corresponds to this CRL entry. The encoding of the DN MUST be
identical to the encoding used in the certificate.

CRL issuers MUST mark this extension as critical since an
implementation that ignored this extension could not correctly
attribute CRL entries to certificates. This specification RECOMMENDS
that implementations recognize this extension.

result['crl_contained'] = True
result['crl_record'] = cryptography_dump_revoked(cryptography_decode_revoked_certificate(cert))
result['crl_source'] = crl_source
result['revoked'] = True

def _report_error(self, soft_fail, msg):
if soft_fail:
self.module.fail_json(msg=msg)
else:
self.module.warn(msg)

def _check_crl_url(self, result, crl_url, soft_fail=False):
resp, info = fetch_url(self.module, crl_url, method='GET')
if info['status'] != 200:
self._report_error(soft_fail, 'HTTP error while loading CRL from {0}: {1}'.format(crl_url, info['status']))
else:
try:
crl_blob = resp.read()
except AttributeError as e:
self._report_error(soft_fail, 'Error while loading CRL from {0}: {1}'.format(crl_url, to_native(e)))
crl_blob = None
if crl_blob is not None:
self._check_crl(result, crl_blob, crl_url)

def check_revocation(self):
result = dict()
result['revoked'] = False

if self.module.params['crl_path'] is not None:
crl_path = self.module.params['crl_path']
try:
with open(crl_path, 'rb') as f:
crl_blob = f.read()
except Exception as e:
self.module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
self._check_crl(result, crl_blob, crl_source=crl_path)

if self.module.params['crl_url'] is not None:
self._check_crl_url(result, self.module.params['crl_url'])

if self.module.params['crl_from_cert'] != 'ignore':
soft_fail = (self.module.params['crl_from_cert'] == 'check_soft_fail')
ext = None
try:
ext = self.cert.extensions.get_extension_for_class(x509.CRLDistributionPoints)
except x509.ExtensionNotFound:
pass
if ext is None:
self._report_error(soft_fail, 'No CRL Distribution Points extension found in certificate')
else:
for distribution_point in ext.value:
if distribution_point.relative_name is not None:
self._report_error(soft_fail, 'Distribution point with relative name found in certificate')
if distribution_point.full_name is not None:
had_crl_url = False
for name in distribution_point.full_name:
if isinstance(name, x509.UniformResourceIdentifier):
Copy link
Collaborator

@ctrufan ctrufan May 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible for a full name to be an LDAP entry (e.g. would show up in windows cert browser as "URL=ldap://blah", not sure about how it gets decoded by python). Not sure what that would do to the getURL. bit in the subsequent check_crl call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it would error out with "ldap is an unsupported URL schema", or something like that. Which I think is acceptable.

had_crl_url = True
self._check_crl_url(result, name.value, soft_fail=soft_fail)
if not had_crl_url:
self._report_error(soft_fail, 'Distribution point with full name found in certificate which does not contain a URI')
if result.get('crl_contained'):
continue

# result['ocsp_uri'] = self._get_ocsp_uri()

return result


def main():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path'),
content=dict(type='str'),
serial_number=dict(type='int'),
crl_path=dict(type='path'),
crl_url=dict(type='str'),
crl_from_cert=dict(type='str', default='ignore', choices=['ignore', 'check', 'check_soft_fail']),
),
required_if=(
['crl_from_cert', 'check', ['path', 'content'], True],
['crl_from_cert', 'check_soft_fail', ['path', 'content'], True],
),
required_one_of=(
['path', 'content', 'serial_number'],
),
mutually_exclusive=(
['path', 'content', 'serial_number'],
),
supports_check_mode=True,
)

try:
if module.params['path'] is not None:
base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)

if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)

certificate = CertificateRevocationInfo(module)
certificate.load()
result = certificate.check_revocation()
module.exit_json(**result)
except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc))


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
shippable/posix/group1
destructive
needs/httptester
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
# needed when role is used outside of ansible-test
httpbin_host: httpbin.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies:
- setup_openssl
- prepare_http_tests
Loading