diff options
Diffstat (limited to 'ansible_collections/community/crypto/plugins')
35 files changed, 1640 insertions, 308 deletions
diff --git a/ansible_collections/community/crypto/plugins/doc_fragments/acme.py b/ansible_collections/community/crypto/plugins/doc_fragments/acme.py index 2b5bfc231..e5ee3ec4e 100644 --- a/ansible_collections/community/crypto/plugins/doc_fragments/acme.py +++ b/ansible_collections/community/crypto/plugins/doc_fragments/acme.py @@ -11,6 +11,9 @@ __metaclass__ = type class ModuleDocFragment(object): # Standard files documentation fragment + # + # NOTE: This document fragment is DEPRECATED and will be removed from community.crypto 3.0.0. + # Use both the BASIC and ACCOUNT fragments as a replacement. DOCUMENTATION = r''' notes: - "If a new enough version of the C(cryptography) library @@ -137,3 +140,178 @@ options: default: 10 version_added: 2.3.0 ''' + + # Basic documentation fragment without account data + BASIC = r''' +notes: + - "Although the defaults are chosen so that the module can be used with + the L(Let's Encrypt,https://letsencrypt.org/) CA, the module can in + principle be used with any CA providing an ACME endpoint, such as + L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme)." + - "So far, the ACME modules have only been tested by the developers against + Let's Encrypt (staging and production), Buypass (staging and production), ZeroSSL (production), + and L(Pebble testing server,https://github.com/letsencrypt/Pebble). We have got + community feedback that they also work with Sectigo ACME Service for InCommon. + If you experience problems with another ACME server, please + L(create an issue,https://github.com/ansible-collections/community.crypto/issues/new/choose) + to help us supporting it. Feedback that an ACME server not mentioned does work + is also appreciated." +requirements: + - either openssl or L(cryptography,https://cryptography.io/) >= 1.5 + - ipaddress +options: + acme_version: + description: + - "The ACME version of the endpoint." + - "Must be V(1) for the classic Let's Encrypt and Buypass ACME endpoints, + or V(2) for standardized ACME v2 endpoints." + - "The value V(1) is deprecated since community.crypto 2.0.0 and will be + removed from community.crypto 3.0.0." + required: true + type: int + choices: [ 1, 2 ] + acme_directory: + description: + - "The ACME directory to use. This is the entry point URL to access + the ACME CA server API." + - "For safety reasons the default is set to the Let's Encrypt staging + server (for the ACME v1 protocol). This will create technically correct, + but untrusted certificates." + - "For Let's Encrypt, all staging endpoints can be found here: + U(https://letsencrypt.org/docs/staging-environment/). For Buypass, all + endpoints can be found here: + U(https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints)" + - "For B(Let's Encrypt), the production directory URL for ACME v2 is + U(https://acme-v02.api.letsencrypt.org/directory)." + - "For B(Buypass), the production directory URL for ACME v2 and v1 is + U(https://api.buypass.com/acme/directory)." + - "For B(ZeroSSL), the production directory URL for ACME v2 is + U(https://acme.zerossl.com/v2/DV90)." + - "For B(Sectigo), the production directory URL for ACME v2 is + U(https://acme-qa.secure.trust-provider.com/v2/DV)." + - The notes for this module contain a list of ACME services this module has + been tested against. + required: true + type: str + validate_certs: + description: + - Whether calls to the ACME directory will validate TLS certificates. + - "B(Warning:) Should B(only ever) be set to V(false) for testing purposes, + for example when testing against a local Pebble server." + type: bool + default: true + select_crypto_backend: + description: + - Determines which crypto backend to use. + - The default choice is V(auto), which tries to use C(cryptography) if available, and falls back to + C(openssl). + - If set to V(openssl), will try to use the C(openssl) binary. + - If set to V(cryptography), will try to use the + L(cryptography,https://cryptography.io/) library. + type: str + default: auto + choices: [ auto, cryptography, openssl ] + request_timeout: + description: + - The time Ansible should wait for a response from the ACME API. + - This timeout is applied to all HTTP(S) requests (HEAD, GET, POST). + type: int + default: 10 + version_added: 2.3.0 +''' + + # Account data documentation fragment + ACCOUNT = r''' +notes: + - "If a new enough version of the C(cryptography) library + is available (see Requirements for details), it will be used + instead of the C(openssl) binary. This can be explicitly disabled + or enabled with the O(select_crypto_backend) option. Note that using + the C(openssl) binary will be slower and less secure, as private key + contents always have to be stored on disk (see + O(account_key_content))." +options: + account_key_src: + description: + - "Path to a file containing the ACME account RSA or Elliptic Curve + key." + - "Private keys can be created with the + M(community.crypto.openssl_privatekey) or M(community.crypto.openssl_privatekey_pipe) + modules. If the requisite (cryptography) is not available, + keys can also be created directly with the C(openssl) command line tool: + RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys + can be created with C(openssl ecparam -genkey ...). Any other tool creating + private keys in PEM format can be used as well." + - "Mutually exclusive with O(account_key_content)." + - "Required if O(account_key_content) is not used." + type: path + aliases: [ account_key ] + account_key_content: + description: + - "Content of the ACME account RSA or Elliptic Curve key." + - "Mutually exclusive with O(account_key_src)." + - "Required if O(account_key_src) is not used." + - "B(Warning:) the content will be written into a temporary file, which will + be deleted by Ansible when the module completes. Since this is an + important private key — it can be used to change the account key, + or to revoke your certificates without knowing their private keys + —, this might not be acceptable." + - "In case C(cryptography) is used, the content is not written into a + temporary file. It can still happen that it is written to disk by + Ansible in the process of moving the module with its argument to + the node where it is executed." + type: str + account_key_passphrase: + description: + - Phassphrase to use to decode the account key. + - "B(Note:) this is not supported by the C(openssl) backend, only by the C(cryptography) backend." + type: str + version_added: 1.6.0 + account_uri: + description: + - "If specified, assumes that the account URI is as given. If the + account key does not match this account, or an account with this + URI does not exist, the module fails." + type: str +''' + + # No account data documentation fragment + NO_ACCOUNT = r''' +notes: + - "If a new enough version of the C(cryptography) library + is available (see Requirements for details), it will be used + instead of the C(openssl) binary. This can be explicitly disabled + or enabled with the O(select_crypto_backend) option. Note that using + the C(openssl) binary will be slower." +options: {} +''' + + CERTIFICATE = r''' +options: + csr: + description: + - "File containing the CSR for the new certificate." + - "Can be created with M(community.crypto.openssl_csr)." + - "The CSR may contain multiple Subject Alternate Names, but each one + will lead to an individual challenge that must be fulfilled for the + CSR to be signed." + - "B(Note): the private key used to create the CSR B(must not) be the + account key. This is a bad idea from a security point of view, and + the CA should not accept the CSR. The ACME server should return an + error in this case." + - Precisely one of O(csr) or O(csr_content) must be specified. + type: path + csr_content: + description: + - "Content of the CSR for the new certificate." + - "Can be created with M(community.crypto.openssl_csr_pipe)." + - "The CSR may contain multiple Subject Alternate Names, but each one + will lead to an individual challenge that must be fulfilled for the + CSR to be signed." + - "B(Note): the private key used to create the CSR B(must not) be the + account key. This is a bad idea from a security point of view, and + the CA should not accept the CSR. The ACME server should return an + error in this case." + - Precisely one of O(csr) or O(csr_content) must be specified. + type: str +''' diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py b/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py index 74d0bc1ea..7f9b954c0 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/acme.py @@ -21,6 +21,8 @@ from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.urls import fetch_url from ansible.module_utils.six import PY3 +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec + from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import ( OpenSSLCLIBackend, ) @@ -42,7 +44,9 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor ) from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + compute_cert_id, nopad_b64, + parse_retry_after, ) try: @@ -153,6 +157,9 @@ class ACMEDirectory(object): self.module, msg='Was not able to obtain nonce, giving up after 5 retries', info=info, response=response) retry_count += 1 + def has_renewal_info_endpoint(self): + return 'renewalInfo' in self.directory + class ACMEClient(object): ''' @@ -168,9 +175,9 @@ class ACMEClient(object): self.backend = backend self.version = module.params['acme_version'] # account_key path and content are mutually exclusive - self.account_key_file = module.params['account_key_src'] - self.account_key_content = module.params['account_key_content'] - self.account_key_passphrase = module.params['account_key_passphrase'] + self.account_key_file = module.params.get('account_key_src') + self.account_key_content = module.params.get('account_key_content') + self.account_key_passphrase = module.params.get('account_key_passphrase') # Grab account URI from module parameters. # Make sure empty string is treated as None. @@ -383,24 +390,94 @@ class ACMEClient(object): self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None) return result, info + def get_renewal_info( + self, + cert_id=None, + cert_info=None, + cert_filename=None, + cert_content=None, + include_retry_after=False, + retry_after_relative_with_timezone=True, + ): + if not self.directory.has_renewal_info_endpoint(): + raise ModuleFailException('The ACME endpoint does not support ACME Renewal Information retrieval') + + if cert_id is None: + cert_id = compute_cert_id(self.backend, cert_info=cert_info, cert_filename=cert_filename, cert_content=cert_content) + url = '{base}{cert_id}'.format(base=self.directory.directory['renewalInfo'], cert_id=cert_id) + + data, info = self.get_request(url, parse_json_result=True, fail_on_error=True, get_only=True) + + # Include Retry-After header if asked for + if include_retry_after and 'retry-after' in info: + try: + data['retryAfter'] = parse_retry_after( + info['retry-after'], + relative_with_timezone=retry_after_relative_with_timezone, + ) + except ValueError: + pass + return data + def get_default_argspec(): ''' Provides default argument spec for the options documented in the acme doc fragment. + + DEPRECATED: will be removed in community.crypto 3.0.0 ''' return dict( - account_key_src=dict(type='path', aliases=['account_key']), - account_key_content=dict(type='str', no_log=True), - account_key_passphrase=dict(type='str', no_log=True), - account_uri=dict(type='str'), acme_directory=dict(type='str', required=True), acme_version=dict(type='int', required=True, choices=[1, 2]), validate_certs=dict(type='bool', default=True), select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']), request_timeout=dict(type='int', default=10), + account_key_src=dict(type='path', aliases=['account_key']), + account_key_content=dict(type='str', no_log=True), + account_key_passphrase=dict(type='str', no_log=True), + account_uri=dict(type='str'), ) +def create_default_argspec( + with_account=True, + require_account_key=True, + with_certificate=False, +): + ''' + Provides default argument spec for the options documented in the acme doc fragment. + ''' + result = ArgumentSpec( + argument_spec=dict( + acme_directory=dict(type='str', required=True), + acme_version=dict(type='int', required=True, choices=[1, 2]), + validate_certs=dict(type='bool', default=True), + select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']), + request_timeout=dict(type='int', default=10), + ), + ) + if with_account: + result.update_argspec( + account_key_src=dict(type='path', aliases=['account_key']), + account_key_content=dict(type='str', no_log=True), + account_key_passphrase=dict(type='str', no_log=True), + account_uri=dict(type='str'), + ) + if require_account_key: + result.update(required_one_of=[['account_key_src', 'account_key_content']]) + result.update(mutually_exclusive=[['account_key_src', 'account_key_content']]) + if with_certificate: + result.update_argspec( + csr=dict(type='path'), + csr_content=dict(type='str'), + ) + result.update( + required_one_of=[['csr', 'csr_content']], + mutually_exclusive=[['csr', 'csr_content']], + ) + return result + + def create_backend(module, needs_acme_v2): if not HAS_IPADDRESS: module.fail_json(msg=missing_required_lib('ipaddress'), exception=IPADDRESS_IMPORT_ERROR) diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/backend_cryptography.py b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_cryptography.py index 0722c1f99..b652240dc 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/backend_cryptography.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_cryptography.py @@ -11,6 +11,7 @@ __metaclass__ = type import base64 import binascii +import datetime import os import traceback @@ -19,7 +20,9 @@ from ansible.module_utils.common.text.converters import to_bytes, to_native, to_ from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CertificateInformation, CryptoBackend, + _parse_acme_timestamp, ) from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import ( @@ -35,27 +38,40 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.io import re from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + from ansible_collections.community.crypto.plugins.module_utils.crypto.math import ( convert_int_to_bytes, convert_int_to_hex, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_now_datetime, - ensure_utc_timezone, - parse_name_field, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, cryptography_name_to_oid, + cryptography_serial_number_of_cert, get_not_valid_after, + get_not_valid_before, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( extract_first_pem, ) +from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( + parse_name_field, +) + +from ansible_collections.community.crypto.plugins.module_utils.time import ( + ensure_utc_timezone, + from_epoch_seconds, + get_epoch_seconds, + get_now_datetime, + get_relative_time_option, + UTC, +) + CRYPTOGRAPHY_MINIMAL_VERSION = '1.5' CRYPTOGRAPHY_ERROR = None @@ -170,6 +186,32 @@ class CryptographyBackend(CryptoBackend): def __init__(self, module): super(CryptographyBackend, self).__init__(module) + def get_now(self): + return get_now_datetime(with_timezone=CRYPTOGRAPHY_TIMEZONE) + + def parse_acme_timestamp(self, timestamp_str): + return _parse_acme_timestamp(timestamp_str, with_timezone=CRYPTOGRAPHY_TIMEZONE) + + def parse_module_parameter(self, value, name): + try: + return get_relative_time_option(value, name, backend='cryptography', with_timezone=CRYPTOGRAPHY_TIMEZONE) + except OpenSSLObjectError as exc: + raise BackendException(to_native(exc)) + + def interpolate_timestamp(self, timestamp_start, timestamp_end, percentage): + start = get_epoch_seconds(timestamp_start) + end = get_epoch_seconds(timestamp_end) + return from_epoch_seconds(start + percentage * (end - start), with_timezone=CRYPTOGRAPHY_TIMEZONE) + + def get_utc_datetime(self, *args, **kwargs): + kwargs_ext = dict(kwargs) + if CRYPTOGRAPHY_TIMEZONE and ('tzinfo' not in kwargs_ext and len(args) < 8): + kwargs_ext['tzinfo'] = UTC + result = datetime.datetime(*args, **kwargs_ext) + if CRYPTOGRAPHY_TIMEZONE and ('tzinfo' in kwargs or len(args) >= 8): + result = ensure_utc_timezone(result) + return result + def parse_key(self, key_file=None, key_content=None, passphrase=None): ''' Parses an RSA or Elliptic Curve key file in PEM format and returns key_data. @@ -376,7 +418,7 @@ class CryptographyBackend(CryptoBackend): raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e)) if now is None: - now = get_now_datetime(with_timezone=CRYPTOGRAPHY_TIMEZONE) + now = self.get_now() elif CRYPTOGRAPHY_TIMEZONE: now = ensure_utc_timezone(now) return (get_not_valid_after(cert) - now).days @@ -386,3 +428,44 @@ class CryptographyBackend(CryptoBackend): Given a Criterium object, creates a ChainMatcher object. ''' return CryptographyChainMatcher(criterium, self.module) + + def get_cert_information(self, cert_filename=None, cert_content=None): + ''' + Return some information on a X.509 certificate as a CertificateInformation object. + ''' + if cert_filename is not None: + cert_content = read_file(cert_filename) + else: + cert_content = to_bytes(cert_content) + + # Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf. + cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or '') + + try: + cert = cryptography.x509.load_pem_x509_certificate(cert_content, _cryptography_backend) + except Exception as e: + if cert_filename is None: + raise BackendException('Cannot parse certificate: {0}'.format(e)) + raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e)) + + ski = None + try: + ext = cert.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier) + ski = ext.value.digest + except cryptography.x509.ExtensionNotFound: + pass + + aki = None + try: + ext = cert.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier) + aki = ext.value.key_identifier + except cryptography.x509.ExtensionNotFound: + pass + + return CertificateInformation( + not_valid_after=get_not_valid_after(cert), + not_valid_before=get_not_valid_before(cert), + serial_number=cryptography_serial_number_of_cert(cert), + subject_key_identifier=ski, + authority_key_identifier=aki, + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/backend_openssl_cli.py b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_openssl_cli.py index 9a1ed1f5a..9aab187ac 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/backend_openssl_cli.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backend_openssl_cli.py @@ -20,6 +20,7 @@ import traceback from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes from ansible_collections.community.crypto.plugins.module_utils.acme.backends import ( + CertificateInformation, CryptoBackend, ) @@ -30,6 +31,8 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64 +from ansible_collections.community.crypto.plugins.module_utils.crypto.math import convert_bytes_to_int + try: import ipaddress except ImportError: @@ -39,6 +42,33 @@ except ImportError: _OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C') +def _extract_date(out_text, name, cert_filename_suffix=""): + try: + date_str = re.search(r"\s+%s\s*:\s+(.*)" % name, out_text).group(1) + return datetime.datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z') + except AttributeError: + raise BackendException("No '{0}' date found{1}".format(name, cert_filename_suffix)) + except ValueError as exc: + raise BackendException("Failed to parse '{0}' date{1}: {2}".format(name, cert_filename_suffix, exc)) + + +def _decode_octets(octets_text): + return binascii.unhexlify(re.sub(r"(\s|:)", "", octets_text).encode("utf-8")) + + +def _extract_octets(out_text, name, required=True, potential_prefixes=None): + regexp = r"\s+%s:\s*\n\s+%s([A-Fa-f0-9]{2}(?::[A-Fa-f0-9]{2})*)\s*\n" % ( + name, + ('(?:%s)' % '|'.join(re.escape(pp) for pp in potential_prefixes)) if potential_prefixes else '', + ) + match = re.search(regexp, out_text, re.MULTILINE | re.DOTALL) + if match is not None: + return _decode_octets(match.group(1)) + if not required: + return None + raise BackendException("No '{0}' octet string found".format(name)) + + class OpenSSLCLIBackend(CryptoBackend): def __init__(self, module, openssl_binary=None): super(OpenSSLCLIBackend, self).__init__(module) @@ -89,10 +119,12 @@ class OpenSSLCLIBackend(CryptoBackend): dummy, out, dummy = self.module.run_command( openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + out_text = to_text(out, errors='surrogate_or_strict') + if account_key_type == 'rsa': - pub_hex, pub_exp = re.search( - r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups() + pub_hex = re.search(r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent", out_text, re.MULTILINE | re.DOTALL).group(1) + + pub_exp = re.search(r"\npublicExponent: ([0-9]+)", out_text, re.MULTILINE | re.DOTALL).group(1) pub_exp = "{0:x}".format(int(pub_exp)) if len(pub_exp) % 2: pub_exp = "0{0}".format(pub_exp) @@ -104,17 +136,19 @@ class OpenSSLCLIBackend(CryptoBackend): 'jwk': { "kty": "RSA", "e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))), - "n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + "n": nopad_b64(_decode_octets(pub_hex)), }, 'hash': 'sha256', } elif account_key_type == 'ec': pub_data = re.search( r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + out_text, + re.MULTILINE | re.DOTALL, + ) if pub_data is None: raise KeyParsingError('cannot parse elliptic curve key') - pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8")) + pub_hex = _decode_octets(pub_data.group(1)) asn1_oid_curve = pub_data.group(2).lower() nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256': @@ -303,13 +337,8 @@ class OpenSSLCLIBackend(CryptoBackend): openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"] dummy, out, dummy = self.module.run_command( openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) - try: - not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", to_text(out, errors='surrogate_or_strict')).group(1) - not_after = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z') - except AttributeError: - raise BackendException("No 'Not after' date found{0}".format(cert_filename_suffix)) - except ValueError: - raise BackendException("Failed to parse 'Not after' date{0}".format(cert_filename_suffix)) + out_text = to_text(out, errors='surrogate_or_strict') + not_after = _extract_date(out_text, 'Not After', cert_filename_suffix=cert_filename_suffix) if now is None: now = datetime.datetime.now() return (not_after - now).days @@ -319,3 +348,43 @@ class OpenSSLCLIBackend(CryptoBackend): Given a Criterium object, creates a ChainMatcher object. ''' raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.') + + def get_cert_information(self, cert_filename=None, cert_content=None): + ''' + Return some information on a X.509 certificate as a CertificateInformation object. + ''' + filename = cert_filename + data = None + if cert_filename is not None: + cert_filename_suffix = ' in {0}'.format(cert_filename) + else: + filename = '/dev/stdin' + data = to_bytes(cert_content) + cert_filename_suffix = '' + + openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"] + dummy, out, dummy = self.module.run_command( + openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE) + out_text = to_text(out, errors='surrogate_or_strict') + + not_after = _extract_date(out_text, 'Not After', cert_filename_suffix=cert_filename_suffix) + not_before = _extract_date(out_text, 'Not Before', cert_filename_suffix=cert_filename_suffix) + + sn = re.search( + r" Serial Number: ([0-9]+)", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if sn: + serial = int(sn.group(1)) + else: + serial = convert_bytes_to_int(_extract_octets(out_text, 'Serial Number', required=True)) + + ski = _extract_octets(out_text, 'X509v3 Subject Key Identifier', required=False) + aki = _extract_octets(out_text, 'X509v3 Authority Key Identifier', required=False, potential_prefixes=['keyid:', '']) + + return CertificateInformation( + not_valid_after=not_after, + not_valid_before=not_before, + serial_number=serial, + subject_key_identifier=ski, + authority_key_identifier=aki, + ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py b/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py index 2d95a3ee3..7c08fae95 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/backends.py @@ -9,9 +9,78 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +from collections import namedtuple import abc +import datetime +import re from ansible.module_utils import six +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + BackendException, +) + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + +from ansible_collections.community.crypto.plugins.module_utils.time import ( + ensure_utc_timezone, + from_epoch_seconds, + get_epoch_seconds, + get_now_datetime, + get_relative_time_option, + remove_timezone, +) + + +CertificateInformation = namedtuple( + 'CertificateInformation', + ( + 'not_valid_after', + 'not_valid_before', + 'serial_number', + 'subject_key_identifier', + 'authority_key_identifier', + ), +) + + +_FRACTIONAL_MATCHER = re.compile(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(|\.\d+)(Z|[+-]\d{2}:?\d{2}.*)$') + + +def _reduce_fractional_digits(timestamp_str): + """ + Given a RFC 3339 timestamp that includes too many digits for the fractional seconds part, reduces these to at most 6. + """ + # RFC 3339 (https://www.rfc-editor.org/info/rfc3339) + m = _FRACTIONAL_MATCHER.match(timestamp_str) + if not m: + raise BackendException('Cannot parse ISO 8601 timestamp {0!r}'.format(timestamp_str)) + timestamp, fractional, timezone = m.groups() + if len(fractional) > 7: + # Python does not support anything smaller than microseconds + # (Golang supports nanoseconds, Boulder often emits more fractional digits, which Python chokes on) + fractional = fractional[:7] + return '%s%s%s' % (timestamp, fractional, timezone) + + +def _parse_acme_timestamp(timestamp_str, with_timezone): + """ + Parses a RFC 3339 timestamp. + """ + # RFC 3339 (https://www.rfc-editor.org/info/rfc3339) + timestamp_str = _reduce_fractional_digits(timestamp_str) + for format in ('%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S.%fZ', '%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S.%f%z'): + # Note that %z won't work with Python 2... https://stackoverflow.com/a/27829491 + try: + result = datetime.datetime.strptime(timestamp_str, format) + except ValueError: + pass + else: + return ensure_utc_timezone(result) if with_timezone else remove_timezone(result) + raise BackendException('Cannot parse ISO 8601 timestamp {0!r}'.format(timestamp_str)) @six.add_metaclass(abc.ABCMeta) @@ -19,6 +88,30 @@ class CryptoBackend(object): def __init__(self, module): self.module = module + def get_now(self): + return get_now_datetime(with_timezone=False) + + def parse_acme_timestamp(self, timestamp_str): + # RFC 3339 (https://www.rfc-editor.org/info/rfc3339) + return _parse_acme_timestamp(timestamp_str, with_timezone=False) + + def parse_module_parameter(self, value, name): + try: + return get_relative_time_option(value, name, backend='cryptography', with_timezone=False) + except OpenSSLObjectError as exc: + raise BackendException(to_native(exc)) + + def interpolate_timestamp(self, timestamp_start, timestamp_end, percentage): + start = get_epoch_seconds(timestamp_start) + end = get_epoch_seconds(timestamp_end) + return from_epoch_seconds(start + percentage * (end - start), with_timezone=False) + + def get_utc_datetime(self, *args, **kwargs): + result = datetime.datetime(*args, **kwargs) + if 'tzinfo' in kwargs or len(args) >= 8: + result = remove_timezone(result) + return result + @abc.abstractmethod def parse_key(self, key_file=None, key_content=None, passphrase=None): ''' @@ -74,3 +167,12 @@ class CryptoBackend(object): ''' Given a Criterium object, creates a ChainMatcher object. ''' + + def get_cert_information(self, cert_filename=None, cert_content=None): + ''' + Return some information on a X.509 certificate as a CertificateInformation object. + ''' + # Not implementing this method in a backend is DEPRECATED and will be + # disallowed in community.crypto 3.0.0. This method will be marked as + # @abstractmethod by then. + raise BackendException('This backend does not support get_cert_information()') diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py b/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py index 3a87ffec1..116ca4206 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/challenges.py @@ -103,7 +103,7 @@ class Challenge(object): # https://tools.ietf.org/html/rfc8555#section-8.4 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest()) - record = (resource + identifier[1:]) if identifier.startswith('*.') else '{0}.{1}'.format(resource, identifier) + record = '{0}.{1}'.format(resource, identifier[2:] if identifier.startswith('*.') else identifier) return { 'resource': resource, 'resource_value': value, @@ -283,13 +283,21 @@ class Authorization(object): return self.status == 'valid' return self.wait_for_validation(client, challenge_type) + def can_deactivate(self): + ''' + Deactivates this authorization. + https://community.letsencrypt.org/t/authorization-deactivation/19860/2 + https://tools.ietf.org/html/rfc8555#section-7.5.2 + ''' + return self.status in ('valid', 'pending') + def deactivate(self, client): ''' Deactivates this authorization. https://community.letsencrypt.org/t/authorization-deactivation/19860/2 https://tools.ietf.org/html/rfc8555#section-7.5.2 ''' - if self.status != 'valid': + if not self.can_deactivate(): return authz_deactivate = { 'status': 'deactivated' diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py b/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py index 732b430df..98c28445f 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/orders.py @@ -32,6 +32,7 @@ class Order(object): self.identifiers = [] for identifier in data['identifiers']: self.identifiers.append((identifier['type'], identifier['value'])) + self.replaces_cert_id = data.get('replaces') self.finalize_uri = data.get('finalize') self.certificate_uri = data.get('certificate') self.authorization_uris = data['authorizations'] @@ -44,6 +45,7 @@ class Order(object): self.status = None self.identifiers = [] + self.replaces_cert_id = None self.finalize_uri = None self.certificate_uri = None self.authorization_uris = [] @@ -62,7 +64,7 @@ class Order(object): return result @classmethod - def create(cls, client, identifiers): + def create(cls, client, identifiers, replaces_cert_id=None): ''' Start a new certificate order (ACME v2 protocol). https://tools.ietf.org/html/rfc8555#section-7.4 @@ -76,6 +78,8 @@ class Order(object): new_order = { "identifiers": acme_identifiers } + if replaces_cert_id is not None: + new_order["replaces"] = replaces_cert_id result, info = client.send_signed_request( client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201]) return cls.from_json(client, result, info['location']) diff --git a/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py b/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py index 217b6de47..ba460444b 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py +++ b/ansible_collections/community/crypto/plugins/module_utils/acme/utils.py @@ -10,6 +10,7 @@ __metaclass__ = type import base64 +import datetime import re import textwrap import traceback @@ -19,6 +20,10 @@ from ansible.module_utils.six.moves.urllib.parse import unquote from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException +from ansible_collections.community.crypto.plugins.module_utils.crypto.math import convert_int_to_bytes + +from ansible_collections.community.crypto.plugins.module_utils.time import get_now_datetime + def nopad_b64(data): return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "") @@ -65,8 +70,61 @@ def pem_to_der(pem_filename=None, pem_content=None): def process_links(info, callback): ''' Process link header, calls callback for every link header with the URL and relation as options. + + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link ''' if 'link' in info: link = info['link'] for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link): callback(unquote(url), relation) + + +def parse_retry_after(value, relative_with_timezone=True, now=None): + ''' + Parse the value of a Retry-After header and return a timestamp. + + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + ''' + # First try a number of seconds + try: + delta = datetime.timedelta(seconds=int(value)) + if now is None: + now = get_now_datetime(relative_with_timezone) + return now + delta + except ValueError: + pass + + try: + return datetime.datetime.strptime(value, '%a, %d %b %Y %H:%M:%S GMT') + except ValueError: + pass + + raise ValueError('Cannot parse Retry-After header value %s' % repr(value)) + + +def compute_cert_id( + backend, + cert_info=None, + cert_filename=None, + cert_content=None, + none_if_required_information_is_missing=False, +): + # Obtain certificate info if not provided + if cert_info is None: + cert_info = backend.get_cert_information(cert_filename=cert_filename, cert_content=cert_content) + + # Convert Authority Key Identifier to string + if cert_info.authority_key_identifier is None: + if none_if_required_information_is_missing: + return None + raise ModuleFailException('Certificate has no Authority Key Identifier extension') + aki = to_native(base64.urlsafe_b64encode(cert_info.authority_key_identifier)).replace('=', '') + + # Convert serial number to string + serial_bytes = convert_int_to_bytes(cert_info.serial_number) + if ord(serial_bytes[:1]) >= 128: + serial_bytes = b'\x00' + serial_bytes + serial = to_native(base64.urlsafe_b64encode(serial_bytes)).replace('=', '') + + # Compose cert ID + return '{aki}.{serial}'.format(aki=aki, serial=serial) diff --git a/ansible_collections/community/crypto/plugins/module_utils/argspec.py b/ansible_collections/community/crypto/plugins/module_utils/argspec.py new file mode 100644 index 000000000..e583609dd --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/argspec.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from ansible.module_utils.basic import AnsibleModule + + +def _ensure_list(value): + if value is None: + return [] + return list(value) + + +class ArgumentSpec: + def __init__(self, argument_spec=None, mutually_exclusive=None, required_together=None, required_one_of=None, required_if=None, required_by=None): + self.argument_spec = argument_spec or {} + self.mutually_exclusive = _ensure_list(mutually_exclusive) + self.required_together = _ensure_list(required_together) + self.required_one_of = _ensure_list(required_one_of) + self.required_if = _ensure_list(required_if) + self.required_by = required_by or {} + + def update_argspec(self, **kwargs): + self.argument_spec.update(kwargs) + return self + + def update(self, mutually_exclusive=None, required_together=None, required_one_of=None, required_if=None, required_by=None): + if mutually_exclusive: + self.mutually_exclusive.extend(mutually_exclusive) + if required_together: + self.required_together.extend(required_together) + if required_one_of: + self.required_one_of.extend(required_one_of) + if required_if: + self.required_if.extend(required_if) + if required_by: + for k, v in required_by.items(): + if k in self.required_by: + v = list(self.required_by[k]) + list(v) + self.required_by[k] = v + return self + + def merge(self, other): + self.update_argspec(**other.argument_spec) + self.update( + mutually_exclusive=other.mutually_exclusive, + required_together=other.required_together, + required_one_of=other.required_one_of, + required_if=other.required_if, + required_by=other.required_by, + ) + return self + + def create_ansible_module_helper(self, clazz, args, **kwargs): + return clazz( + *args, + argument_spec=self.argument_spec, + mutually_exclusive=self.mutually_exclusive, + required_together=self.required_together, + required_one_of=self.required_one_of, + required_if=self.required_if, + required_by=self.required_by, + **kwargs) + + def create_ansible_module(self, **kwargs): + return self.create_ansible_module_helper(AnsibleModule, (), **kwargs) + + +__all__ = ('ArgumentSpec', ) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py index f56f22d33..1ec43e9f2 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/math.py @@ -42,9 +42,18 @@ def quick_is_not_prime(n): that we could not detect quickly whether it is not prime. ''' if n <= 2: - return True + return n < 2 # The constant in the next line is the product of all primes < 200 - if simple_gcd(n, 7799922041683461553249199106329813876687996789903550945093032474868511536164700810) > 1: + prime_product = 7799922041683461553249199106329813876687996789903550945093032474868511536164700810 + gcd = simple_gcd(n, prime_product) + if gcd > 1: + if n < 200 and gcd == n: + # Explicitly check for all primes < 200 + return n not in ( + 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, + 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, + 181, 191, 193, 197, 199, + ) return True # TODO: maybe do some iterations of Miller-Rabin to increase confidence # (https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test) @@ -101,16 +110,27 @@ if sys.version_info[0] >= 3: def _convert_int_to_bytes(count, no): return no.to_bytes(count, byteorder='big') + def _convert_bytes_to_int(data): + return int.from_bytes(data, byteorder='big', signed=False) + def _to_hex(no): return hex(no)[2:] else: # Python 2 def _convert_int_to_bytes(count, n): + if n == 0 and count == 0: + return '' h = '%x' % n if len(h) > 2 * count: raise Exception('Number {1} needs more than {0} bytes!'.format(count, n)) return ('0' * (2 * count - len(h)) + h).decode('hex') + def _convert_bytes_to_int(data): + v = 0 + for x in data: + v = (v << 8) | ord(x) + return v + def _to_hex(no): return '%x' % no @@ -144,3 +164,10 @@ def convert_int_to_hex(no, digits=None): if digits is not None and len(value) < digits: value = '0' * (digits - len(value)) + value return value + + +def convert_bytes_to_int(data): + """ + Convert a byte string to an unsigned integer in network byte order. + """ + return _convert_bytes_to_int(data) diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate.py index 7bc93d934..595748fb9 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate.py @@ -15,9 +15,9 @@ import traceback from ansible.module_utils import six from ansible.module_utils.basic import missing_required_lib -from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec -from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( OpenSSLObjectError, diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_entrust.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_entrust.py index 7dc4641e1..37351daeb 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_entrust.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_entrust.py @@ -18,8 +18,6 @@ from ansible_collections.community.crypto.plugins.module_utils.ecs.api import EC from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( load_certificate, - get_now_datetime, - get_relative_time_option, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( @@ -34,6 +32,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac CertificateProvider, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, + get_relative_time_option, +) + try: from cryptography.x509.oid import NameOID except ImportError: @@ -44,7 +47,12 @@ class EntrustCertificateBackend(CertificateBackend): def __init__(self, module, backend): super(EntrustCertificateBackend, self).__init__(module, backend) self.trackingId = None - self.notAfter = get_relative_time_option(module.params['entrust_not_after'], 'entrust_not_after', backend=self.backend) + self.notAfter = get_relative_time_option( + module.params['entrust_not_after'], + 'entrust_not_after', + backend=self.backend, + with_timezone=CRYPTOGRAPHY_TIMEZONE, + ) if self.csr_content is None and self.csr_path is None: raise CertificateError( diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_info.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_info.py index 5db6c3586..b612f8b18 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_info.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_info.py @@ -23,7 +23,6 @@ from ansible_collections.community.crypto.plugins.module_utils.version import Lo from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( load_certificate, get_fingerprint_of_bytes, - get_now_datetime, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( @@ -40,6 +39,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac get_publickey_info, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, +) + MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' CRYPTOGRAPHY_IMP_ERR = None diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_ownca.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_ownca.py index 4d312e6b7..bd4860dff 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_ownca.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_ownca.py @@ -22,11 +22,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( load_privatekey, load_certificate, - get_relative_time_option, select_message_digest, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + CRYPTOGRAPHY_TIMEZONE, cryptography_compare_public_keys, cryptography_key_needs_digest_for_signing, cryptography_serial_number_of_cert, @@ -44,6 +44,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac CertificateProvider, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + try: import cryptography from cryptography import x509 @@ -59,8 +63,18 @@ class OwnCACertificateBackendCryptography(CertificateBackend): self.create_subject_key_identifier = module.params['ownca_create_subject_key_identifier'] self.create_authority_key_identifier = module.params['ownca_create_authority_key_identifier'] - self.notBefore = get_relative_time_option(module.params['ownca_not_before'], 'ownca_not_before', backend=self.backend) - self.notAfter = get_relative_time_option(module.params['ownca_not_after'], 'ownca_not_after', backend=self.backend) + self.notBefore = get_relative_time_option( + module.params['ownca_not_before'], + 'ownca_not_before', + backend=self.backend, + with_timezone=CRYPTOGRAPHY_TIMEZONE, + ) + self.notAfter = get_relative_time_option( + module.params['ownca_not_after'], + 'ownca_not_after', + backend=self.backend, + with_timezone=CRYPTOGRAPHY_TIMEZONE, + ) self.digest = select_message_digest(module.params['ownca_digest']) self.version = module.params['ownca_version'] self.serial_number = x509.random_serial_number() diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_selfsigned.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_selfsigned.py index edd8d8d77..d7135d355 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_selfsigned.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/certificate_selfsigned.py @@ -14,11 +14,11 @@ import os from random import randrange from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_relative_time_option, select_message_digest, ) from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( + CRYPTOGRAPHY_TIMEZONE, cryptography_key_needs_digest_for_signing, cryptography_serial_number_of_cert, cryptography_verify_certificate_signature, @@ -34,6 +34,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac CertificateProvider, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + try: import cryptography from cryptography import x509 @@ -48,8 +52,18 @@ class SelfSignedCertificateBackendCryptography(CertificateBackend): super(SelfSignedCertificateBackendCryptography, self).__init__(module, 'cryptography') self.create_subject_key_identifier = module.params['selfsigned_create_subject_key_identifier'] - self.notBefore = get_relative_time_option(module.params['selfsigned_not_before'], 'selfsigned_not_before', backend=self.backend) - self.notAfter = get_relative_time_option(module.params['selfsigned_not_after'], 'selfsigned_not_after', backend=self.backend) + self.notBefore = get_relative_time_option( + module.params['selfsigned_not_before'], + 'selfsigned_not_before', + backend=self.backend, + with_timezone=CRYPTOGRAPHY_TIMEZONE, + ) + self.notAfter = get_relative_time_option( + module.params['selfsigned_not_after'], + 'selfsigned_not_after', + backend=self.backend, + with_timezone=CRYPTOGRAPHY_TIMEZONE, + ) self.digest = select_message_digest(module.params['selfsigned_digest']) self.version = module.params['selfsigned_version'] self.serial_number = x509.random_serial_number() diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/common.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/common.py index 67f87dd0c..6616249c4 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/common.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/common.py @@ -10,26 +10,19 @@ __metaclass__ = type from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec as _ArgumentSpec -class ArgumentSpec: - def __init__(self, argument_spec, mutually_exclusive=None, required_together=None, required_one_of=None, required_if=None, required_by=None): - self.argument_spec = argument_spec - self.mutually_exclusive = mutually_exclusive or [] - self.required_together = required_together or [] - self.required_one_of = required_one_of or [] - self.required_if = required_if or [] - self.required_by = required_by or {} +class ArgumentSpec(_ArgumentSpec): def create_ansible_module_helper(self, clazz, args, **kwargs): - return clazz( - *args, - argument_spec=self.argument_spec, - mutually_exclusive=self.mutually_exclusive, - required_together=self.required_together, - required_one_of=self.required_one_of, - required_if=self.required_if, - required_by=self.required_by, - **kwargs) - - def create_ansible_module(self, **kwargs): - return self.create_ansible_module_helper(AnsibleModule, (), **kwargs) + result = super(ArgumentSpec, self).create_ansible_module_helper(clazz, args, **kwargs) + result.deprecate( + "The crypto.module_backends.common module utils is deprecated and will be removed from community.crypto 3.0.0." + " Use the argspec module utils from community.crypto instead.", + version='3.0.0', + collection_name='community.crypto', + ) + return result + + +__all__ = ('AnsibleModule', 'ArgumentSpec') diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr.py index 4ab14e527..6ce7e2438 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/csr.py @@ -17,6 +17,8 @@ from ansible.module_utils import six from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.common.text.converters import to_native, to_text +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec + from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( @@ -49,8 +51,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac get_csr_info, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec - MINIMAL_CRYPTOGRAPHY_VERSION = '1.3' diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey.py index dc13107b7..36d50ae3c 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey.py @@ -17,6 +17,8 @@ from ansible.module_utils import six from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.common.text.converters import to_bytes +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec + from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( @@ -42,8 +44,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac get_privatekey_info, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec - MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_convert.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_convert.py index fdcc901e0..4a1aca600 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_convert.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/module_backends/privatekey_convert.py @@ -15,12 +15,14 @@ from ansible.module_utils import six from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.common.text.converters import to_bytes -from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion +from ansible_collections.community.crypto.plugins.module_utils.argspec import ArgumentSpec from ansible_collections.community.crypto.plugins.module_utils.io import ( load_file, ) +from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion + from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( CRYPTOGRAPHY_HAS_X25519, CRYPTOGRAPHY_HAS_X448, @@ -37,8 +39,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import identify_private_key_format, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec - MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' diff --git a/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py b/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py index 8b59a3b70..862f5b8fc 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py +++ b/ansible_collections/community/crypto/plugins/module_utils/crypto/support.py @@ -9,19 +9,25 @@ __metaclass__ = type import abc -import datetime import errno import hashlib import os -import re from ansible.module_utils import six -from ansible.module_utils.common.text.converters import to_native, to_bytes +from ansible.module_utils.common.text.converters import to_bytes from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import ( identify_pem_format, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( # noqa: F401, pylint: disable=unused-import + # These imports are for backwards compatibility + get_now_datetime, + ensure_utc_timezone, + convert_relative_to_datetime, + get_relative_time_option, +) + try: from OpenSSL import crypto HAS_PYOPENSSL = True @@ -279,86 +285,6 @@ def parse_ordered_name_field(input_list, name_field_name): return result -def get_now_datetime(with_timezone): - if with_timezone: - return datetime.datetime.now(tz=datetime.timezone.utc) - return datetime.datetime.utcnow() - - -def ensure_utc_timezone(timestamp): - if timestamp.tzinfo is not None: - return timestamp - return timestamp.astimezone(datetime.timezone.utc) - - -def convert_relative_to_datetime(relative_time_string, with_timezone=False): - """Get a datetime.datetime or None from a string in the time format described in sshd_config(5)""" - - parsed_result = re.match( - r"^(?P<prefix>[+-])((?P<weeks>\d+)[wW])?((?P<days>\d+)[dD])?((?P<hours>\d+)[hH])?((?P<minutes>\d+)[mM])?((?P<seconds>\d+)[sS]?)?$", - relative_time_string) - - if parsed_result is None or len(relative_time_string) == 1: - # not matched or only a single "+" or "-" - return None - - offset = datetime.timedelta(0) - if parsed_result.group("weeks") is not None: - offset += datetime.timedelta(weeks=int(parsed_result.group("weeks"))) - if parsed_result.group("days") is not None: - offset += datetime.timedelta(days=int(parsed_result.group("days"))) - if parsed_result.group("hours") is not None: - offset += datetime.timedelta(hours=int(parsed_result.group("hours"))) - if parsed_result.group("minutes") is not None: - offset += datetime.timedelta( - minutes=int(parsed_result.group("minutes"))) - if parsed_result.group("seconds") is not None: - offset += datetime.timedelta( - seconds=int(parsed_result.group("seconds"))) - - now = get_now_datetime(with_timezone=with_timezone) - if parsed_result.group("prefix") == "+": - return now + offset - else: - return now - offset - - -def get_relative_time_option(input_string, input_name, backend='cryptography', with_timezone=False): - """Return an absolute timespec if a relative timespec or an ASN1 formatted - string is provided. - - The return value will be a datetime object for the cryptography backend, - and a ASN1 formatted string for the pyopenssl backend.""" - result = to_native(input_string) - if result is None: - raise OpenSSLObjectError( - 'The timespec "%s" for %s is not valid' % - input_string, input_name) - # Relative time - if result.startswith("+") or result.startswith("-"): - result_datetime = convert_relative_to_datetime(result, with_timezone=with_timezone) - if backend == 'pyopenssl': - return result_datetime.strftime("%Y%m%d%H%M%SZ") - elif backend == 'cryptography': - return result_datetime - # Absolute time - if backend == 'cryptography': - for date_fmt in ['%Y%m%d%H%M%SZ', '%Y%m%d%H%MZ', '%Y%m%d%H%M%S%z', '%Y%m%d%H%M%z']: - try: - res = datetime.datetime.strptime(result, date_fmt) - except ValueError: - pass - else: - if with_timezone: - res = res.astimezone(datetime.timezone.utc) - return res - - raise OpenSSLObjectError( - 'The time spec "%s" for %s is invalid' % - (input_string, input_name) - ) - - def select_message_digest(digest_string): digest = None if digest_string == 'sha256': diff --git a/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py b/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py index f59766651..8efb2ad9c 100644 --- a/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py +++ b/ansible_collections/community/crypto/plugins/module_utils/openssh/certificate.py @@ -31,11 +31,15 @@ from hashlib import sha256 from ansible.module_utils import six from ansible.module_utils.common.text.converters import to_text -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import convert_relative_to_datetime from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import ( OpensshParser, _OpensshWriter, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + add_or_remove_timezone as _add_or_remove_timezone, + convert_relative_to_datetime, + UTC as _UTC, +) # See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD _USER_TYPE = 1 @@ -66,14 +70,8 @@ _ECDSA_CURVE_IDENTIFIERS_LOOKUP = { _USE_TIMEZONE = sys.version_info >= (3, 6) -def _ensure_utc_timezone_if_use_timezone(value): - if not _USE_TIMEZONE or value.tzinfo is not None: - return value - return value.astimezone(_datetime.timezone.utc) - - -_ALWAYS = _ensure_utc_timezone_if_use_timezone(datetime(1970, 1, 1)) -_FOREVER = datetime(9999, 12, 31, 23, 59, 59, 999999, _datetime.timezone.utc) if _USE_TIMEZONE else datetime.max +_ALWAYS = _add_or_remove_timezone(datetime(1970, 1, 1), with_timezone=_USE_TIMEZONE) +_FOREVER = datetime(9999, 12, 31, 23, 59, 59, 999999, _UTC) if _USE_TIMEZONE else datetime.max _CRITICAL_OPTIONS = ( 'force-command', @@ -198,7 +196,7 @@ class OpensshCertificateTimeParameters(object): else: for time_format in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: - result = _ensure_utc_timezone_if_use_timezone(datetime.strptime(time_string, time_format)) + result = _add_or_remove_timezone(datetime.strptime(time_string, time_format), with_timezone=_USE_TIMEZONE) except ValueError: pass if result is None: diff --git a/ansible_collections/community/crypto/plugins/module_utils/time.py b/ansible_collections/community/crypto/plugins/module_utils/time.py new file mode 100644 index 000000000..4adc4620e --- /dev/null +++ b/ansible_collections/community/crypto/plugins/module_utils/time.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +import datetime +import re +import sys + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( + OpenSSLObjectError, +) + + +try: + UTC = datetime.timezone.utc +except AttributeError: + _DURATION_ZERO = datetime.timedelta(0) + + class _UTCClass(datetime.tzinfo): + def utcoffset(self, dt): + return _DURATION_ZERO + + def dst(self, dt): + return _DURATION_ZERO + + def tzname(self, dt): + return 'UTC' + + def fromutc(self, dt): + return dt + + def __repr__(self): + return 'UTC' + + UTC = _UTCClass() + + +def get_now_datetime(with_timezone): + if with_timezone: + return datetime.datetime.now(tz=UTC) + return datetime.datetime.utcnow() + + +def ensure_utc_timezone(timestamp): + if timestamp.tzinfo is UTC: + return timestamp + if timestamp.tzinfo is None: + # We assume that naive datetime objects use timezone UTC! + return timestamp.replace(tzinfo=UTC) + return timestamp.astimezone(UTC) + + +def remove_timezone(timestamp): + # Convert to native datetime object + if timestamp.tzinfo is None: + return timestamp + if timestamp.tzinfo is not UTC: + timestamp = timestamp.astimezone(UTC) + return timestamp.replace(tzinfo=None) + + +def add_or_remove_timezone(timestamp, with_timezone): + return ensure_utc_timezone(timestamp) if with_timezone else remove_timezone(timestamp) + + +if sys.version_info < (3, 3): + def get_epoch_seconds(timestamp): + epoch = datetime.datetime(1970, 1, 1, tzinfo=UTC if timestamp.tzinfo is not None else None) + delta = timestamp - epoch + try: + return delta.total_seconds() + except AttributeError: + # Python 2.6 and earlier: total_seconds() does not yet exist, so we use the formula from + # https://docs.python.org/2/library/datetime.html#datetime.timedelta.total_seconds + return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 10**6) / 10**6 +else: + def get_epoch_seconds(timestamp): + return timestamp.timestamp() + + +def from_epoch_seconds(timestamp, with_timezone): + if with_timezone: + return datetime.datetime.fromtimestamp(timestamp, UTC) + return datetime.datetime.utcfromtimestamp(timestamp) + + +def convert_relative_to_datetime(relative_time_string, with_timezone=False, now=None): + """Get a datetime.datetime or None from a string in the time format described in sshd_config(5)""" + + parsed_result = re.match( + r"^(?P<prefix>[+-])((?P<weeks>\d+)[wW])?((?P<days>\d+)[dD])?((?P<hours>\d+)[hH])?((?P<minutes>\d+)[mM])?((?P<seconds>\d+)[sS]?)?$", + relative_time_string) + + if parsed_result is None or len(relative_time_string) == 1: + # not matched or only a single "+" or "-" + return None + + offset = datetime.timedelta(0) + if parsed_result.group("weeks") is not None: + offset += datetime.timedelta(weeks=int(parsed_result.group("weeks"))) + if parsed_result.group("days") is not None: + offset += datetime.timedelta(days=int(parsed_result.group("days"))) + if parsed_result.group("hours") is not None: + offset += datetime.timedelta(hours=int(parsed_result.group("hours"))) + if parsed_result.group("minutes") is not None: + offset += datetime.timedelta( + minutes=int(parsed_result.group("minutes"))) + if parsed_result.group("seconds") is not None: + offset += datetime.timedelta( + seconds=int(parsed_result.group("seconds"))) + + if now is None: + now = get_now_datetime(with_timezone=with_timezone) + else: + now = add_or_remove_timezone(now, with_timezone=with_timezone) + + if parsed_result.group("prefix") == "+": + return now + offset + else: + return now - offset + + +def get_relative_time_option(input_string, input_name, backend='cryptography', with_timezone=False, now=None): + """Return an absolute timespec if a relative timespec or an ASN1 formatted + string is provided. + + The return value will be a datetime object for the cryptography backend, + and a ASN1 formatted string for the pyopenssl backend.""" + result = to_native(input_string) + if result is None: + raise OpenSSLObjectError( + 'The timespec "%s" for %s is not valid' % + input_string, input_name) + # Relative time + if result.startswith("+") or result.startswith("-"): + result_datetime = convert_relative_to_datetime(result, with_timezone=with_timezone, now=now) + if backend == 'pyopenssl': + return result_datetime.strftime("%Y%m%d%H%M%SZ") + elif backend == 'cryptography': + return result_datetime + # Absolute time + if backend == 'pyopenssl': + return input_string + elif backend == 'cryptography': + for date_fmt, length in [ + ('%Y%m%d%H%M%SZ', 15), # this also parses '202401020304Z', but as datetime(2024, 1, 2, 3, 0, 4) + ('%Y%m%d%H%MZ', 13), + ('%Y%m%d%H%M%S%z', 14 + 5), # this also parses '202401020304+0000', but as datetime(2024, 1, 2, 3, 0, 4, tzinfo=...) + ('%Y%m%d%H%M%z', 12 + 5), + ]: + if len(result) != length: + continue + try: + res = datetime.datetime.strptime(result, date_fmt) + except ValueError: + pass + else: + return add_or_remove_timezone(res, with_timezone=with_timezone) + + raise OpenSSLObjectError( + 'The time spec "%s" for %s is invalid' % + (input_string, input_name) + ) diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account.py b/ansible_collections/community/crypto/plugins/modules/acme_account.py index 1e8d64a57..960bad313 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_account.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_account.py @@ -37,7 +37,8 @@ seealso: - module: community.crypto.acme_inspect description: Allows to debug problems. extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -169,11 +170,9 @@ account_uri: import base64 -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -188,8 +187,8 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec() + argument_spec.update_argspec( terms_agreed=dict(type='bool', default=False), state=dict(type='str', required=True, choices=['absent', 'present', 'changed_key']), allow_creation=dict(type='bool', default=True), @@ -202,14 +201,9 @@ def main(): alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']), key=dict(type='str', required=True, no_log=True), )) - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], - ), + ) + argument_spec.update( mutually_exclusive=( - ['account_key_src', 'account_key_content'], ['new_account_key_src', 'new_account_key_content'], ), required_if=( @@ -217,8 +211,8 @@ def main(): # new_account_key_src and new_account_key_content are specified ['state', 'changed_key', ['new_account_key_src', 'new_account_key_content'], True], ), - supports_check_mode=True, ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, True) if module.params['external_account_binding']: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_account_info.py b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py index ac4617c90..33313fe75 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_account_info.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_account_info.py @@ -25,7 +25,8 @@ notes: - "This module was called C(acme_account_facts) before Ansible 2.8. The usage did not change." extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme - community.crypto.attributes.info_module @@ -213,11 +214,9 @@ order_uris: version_added: 1.5.0 ''' -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -270,20 +269,11 @@ def get_order(client, order_url): def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec() + argument_spec.update_argspec( retrieve_orders=dict(type='str', default='ignore', choices=['ignore', 'url_list', 'object_list']), - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], - ), - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ), - supports_check_mode=True, ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, True) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py b/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py new file mode 100644 index 000000000..7783236f0 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_ari_info.py @@ -0,0 +1,142 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: acme_ari_info +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Retrieves ACME Renewal Information (ARI) for a certificate +description: + - "Allows to retrieve renewal information on a certificate obtained with the + L(ACME protocol,https://tools.ietf.org/html/rfc8555)." + - "This module only works with the ACME v2 protocol, and requires the ACME server + to support the ARI extension (U(https://datatracker.ietf.org/doc/draft-ietf-acme-ari/)). + This module implements version 3 of the ARI draft." +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.no_account + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + certificate_path: + description: + - A path to the X.509 certificate to request information for. + - Exactly one of O(certificate_path) and O(certificate_content) must be provided. + type: path + certificate_content: + description: + - The content of the X.509 certificate to request information for. + - Exactly one of O(certificate_path) and O(certificate_content) must be provided. + type: str +seealso: + - module: community.crypto.acme_certificate + description: Allows to obtain a certificate using the ACME protocol + - module: community.crypto.acme_certificate_revoke + description: Allows to revoke a certificate using the ACME protocol +''' + +EXAMPLES = ''' +- name: Retrieve renewal information for a certificate + community.crypto.acme_ari_info: + certificate_path: /etc/httpd/ssl/sample.com.crt + register: cert_data + +- name: Show the certificate renewal information + ansible.builtin.debug: + var: cert_data.renewal_info +''' + +RETURN = ''' +renewal_info: + description: The ARI renewal info object (U(https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.2)). + returned: success + type: dict + contains: + suggestedWindow: + description: + - Describes the window during which the certificate should be renewed. + type: dict + returned: always + contains: + start: + description: + - The start of the window during which the certificate should be renewed. + - The format is specified in L(RFC 3339,https://www.rfc-editor.org/info/rfc3339). + returned: always + type: str + sample: '2021-01-03T00:00:00Z' + end: + description: + - The end of the window during which the certificate should be renewed. + - The format is specified in L(RFC 3339,https://www.rfc-editor.org/info/rfc3339). + returned: always + type: str + sample: '2021-01-03T00:00:00Z' + explanationURL: + description: + - A URL pointing to a page which may explain why the suggested renewal window is what it is. + - For example, it may be a page explaining the CA's dynamic load-balancing strategy, or a + page documenting which certificates are affected by a mass revocation event. Should be shown + to the user. + returned: depends on the ACME server + type: str + sample: https://example.com/docs/ari + retryAfter: + description: + - A timestamp before the next retry to ask for this information should not be made. + returned: depends on the ACME server + type: str + sample: '2024-04-29T01:17:10.236921+00:00' +''' + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + + +def main(): + argument_spec = create_default_argspec(with_account=False) + argument_spec.update_argspec( + certificate_path=dict(type='path'), + certificate_content=dict(type='str'), + ) + argument_spec.update( + required_one_of=( + ['certificate_path', 'certificate_content'], + ), + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + backend = create_backend(module, True) + + try: + client = ACMEClient(module, backend) + if not client.directory.has_renewal_info_endpoint(): + module.fail_json(msg='The ACME endpoint does not support ACME Renewal Information retrieval') + renewal_info = client.get_renewal_info( + cert_filename=module.params['certificate_path'], + cert_content=module.params['certificate_content'], + include_retry_after=True, + ) + module.exit_json(renewal_info=renewal_info) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate.py index 21a6d6ae9..8729996c0 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate.py @@ -58,7 +58,7 @@ seealso: link: https://tools.ietf.org/html/rfc8555 - name: ACME TLS ALPN Challenge Extension description: The specification of the V(tls-alpn-01) challenge (RFC 8737). - link: https://www.rfc-editor.org/rfc/rfc8737.html-05 + link: https://www.rfc-editor.org/rfc/rfc8737.html - module: community.crypto.acme_challenge_cert_helper description: Helps preparing V(tls-alpn-01) challenges. - module: community.crypto.openssl_privatekey @@ -77,8 +77,12 @@ seealso: description: Allows to create, modify or delete an ACME account. - module: community.crypto.acme_inspect description: Allows to debug problems. + - module: community.crypto.acme_certificate_deactivate_authz + description: Allows to deactivate (invalidate) ACME v2 orders. extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account + - community.crypto.acme.certificate - community.crypto.attributes - community.crypto.attributes.files - community.crypto.attributes.actiongroup_acme @@ -138,32 +142,8 @@ options: - 'tls-alpn-01' - 'no challenge' csr: - description: - - "File containing the CSR for the new certificate." - - "Can be created with M(community.crypto.openssl_csr) or C(openssl req ...)." - - "The CSR may contain multiple Subject Alternate Names, but each one - will lead to an individual challenge that must be fulfilled for the - CSR to be signed." - - "I(Note): the private key used to create the CSR I(must not) be the - account key. This is a bad idea from a security point of view, and - the CA should not accept the CSR. The ACME server should return an - error in this case." - - Precisely one of O(csr) or O(csr_content) must be specified. - type: path aliases: ['src'] csr_content: - description: - - "Content of the CSR for the new certificate." - - "Can be created with M(community.crypto.openssl_csr_pipe) or C(openssl req ...)." - - "The CSR may contain multiple Subject Alternate Names, but each one - will lead to an individual challenge that must be fulfilled for the - CSR to be signed." - - "I(Note): the private key used to create the CSR I(must not) be the - account key. This is a bad idea from a security point of view, and - the CA should not accept the CSR. The ACME server should return an - error in this case." - - Precisely one of O(csr) or O(csr_content) must be specified. - type: str version_added: 1.2.0 data: description: @@ -292,6 +272,32 @@ options: - "The identifier must be of the form V(C4:A7:B1:A4:7B:2C:71:FA:DB:E1:4B:90:75:FF:C4:15:60:85:89:10)." type: str + include_renewal_cert_id: + description: + - Determines whether to request renewal of an existing certificate according to + L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-5). + - This is only used when the certificate specified in O(dest) or O(fullchain_dest) already exists. + - V(never) never sends the certificate ID of the certificate to renew. V(always) will always send it. + - V(when_ari_supported) only sends the certificate ID if the ARI endpoint is found in the ACME directory. + - Generally you should use V(when_ari_supported) if you know that the ACME service supports a compatible + draft (or final version, once it is out) of the ARI extension. V(always) should never be necessary. + If you are not sure, or if you receive strange errors on invalid C(replaces) values in order objects, + use V(never), which also happens to be the default. + - ACME servers might refuse to create new orders with C(replaces) for certificates that already have an + existing order. This can happen if this module is used to create an order, and then the playbook/role + fails in case the challenges cannot be set up. If the playbook/role does not record the order data to + continue with the existing order, but tries to create a new one on the next run, creating the new order + might fail. For this reason, this option should only be set to a value different from V(never) if the + role/playbook using it keeps track of order data accross restarts, or if it takes care to deactivate + orders whose processing is aborted. Orders can be deactivated with the + M(community.crypto.acme_certificate_deactivate_authz) module. + type: str + choices: + - never + - when_ari_supported + - always + default: never + version_added: 2.20.0 ''' EXAMPLES = r''' @@ -375,7 +381,7 @@ EXAMPLES = r''' # state: present # wait: true # # Note: route53 requires TXT entries to be enclosed in quotes -# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value | regex_replace('^(.*)$', '\"\\1\"') }}" +# value: "{{ sample_com_challenge.challenge_data['sample.com']['dns-01'].resource_value | community.dns.quote_txt(always_quote=true) }}" # when: sample_com_challenge is changed and 'sample.com' in sample_com_challenge.challenge_data # # Alternative way: @@ -390,7 +396,7 @@ EXAMPLES = r''' # wait: true # # Note: item.value is a list of TXT entries, and route53 # # requires every entry to be enclosed in quotes -# value: "{{ item.value | map('regex_replace', '^(.*)$', '\"\\1\"' ) | list }}" +# value: "{{ item.value | map('community.dns.quote_txt', always_quote=true) | list }}" # loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}" # when: sample_com_challenge is changed @@ -446,39 +452,55 @@ challenge_data: - Per identifier / challenge type challenge data. - Since Ansible 2.8.5, only challenges which are not yet valid are returned. returned: changed - type: list - elements: dict + type: dict contains: - resource: - description: The challenge resource that must be created for validation. - returned: changed - type: str - sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA - resource_original: - description: - - The original challenge resource including type identifier for V(tls-alpn-01) - challenges. - returned: changed and O(challenge) is V(tls-alpn-01) - type: str - sample: DNS:example.com - resource_value: + identifier: description: - - The value the resource has to produce for the validation. - - For V(http-01) and V(dns-01) challenges, the value can be used as-is. - - "For V(tls-alpn-01) challenges, note that this return value contains a - Base64 encoded version of the correct binary blob which has to be put - into the acmeValidation x509 extension; see - U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) - for details. To do this, you might need the P(ansible.builtin.b64decode#filter) Jinja filter - to extract the binary blob from this return value." + - For every identifier, provides a dictionary of challenge types mapping to challenge data. + - The keys in this dictionary are the identifiers. C(identifier) is a placeholder used in the documentation. + - Note that the keys are not valid Jinja2 identifiers. returned: changed - type: str - sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA - record: - description: The full DNS record's name for the challenge. - returned: changed and challenge is V(dns-01) - type: str - sample: _acme-challenge.example.com + type: dict + contains: + challenge-type: + description: + - Data for every challenge type. + - The keys in this dictionary are the challenge types. C(challenge-type) is a placeholder used in the documentation. + Possible keys are V(http-01), V(dns-01), and V(tls-alpn-01). + - Note that the keys are not valid Jinja2 identifiers. + returned: changed + type: dict + contains: + resource: + description: The challenge resource that must be created for validation. + returned: changed + type: str + sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA + resource_original: + description: + - The original challenge resource including type identifier for V(tls-alpn-01) + challenges. + returned: changed and O(challenge) is V(tls-alpn-01) + type: str + sample: DNS:example.com + resource_value: + description: + - The value the resource has to produce for the validation. + - For V(http-01) and V(dns-01) challenges, the value can be used as-is. + - "For V(tls-alpn-01) challenges, note that this return value contains a + Base64 encoded version of the correct binary blob which has to be put + into the acmeValidation x509 extension; see + U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) + for details. To do this, you might need the P(ansible.builtin.b64decode#filter) Jinja filter + to extract the binary blob from this return value." + returned: changed + type: str + sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA + record: + description: The full DNS record's name for the challenge. + returned: changed and challenge is V(dns-01) + type: str + sample: _acme-challenge.example.com challenge_data_dns: description: - List of TXT values per DNS record, in case challenge is V(dns-01). @@ -547,11 +569,9 @@ all_chains: import os -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -585,6 +605,7 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.orders impor ) from ansible_collections.community.crypto.plugins.module_utils.acme.utils import ( + compute_cert_id, pem_to_der, ) @@ -621,6 +642,7 @@ class ACMECertificateClient(object): self.order_uri = self.data.get('order_uri') if self.data else None self.all_chains = None self.select_chain_matcher = [] + self.include_renewal_cert_id = module.params['include_renewal_cert_id'] if self.module.params['select_chain']: for criterium_idx, criterium in enumerate(self.module.params['select_chain']): @@ -678,6 +700,15 @@ class ACMECertificateClient(object): # stored in self.order_uri by the constructor). return self.order_uri is None + def _get_cert_info_or_none(self): + if self.module.params.get('dest'): + filename = self.module.params['dest'] + else: + filename = self.module.params['fullchain_dest'] + if not os.path.exists(filename): + return None + return self.client.backend.get_cert_information(cert_filename=filename) + def start_challenges(self): ''' Create new authorizations for all identifiers of the CSR, @@ -692,7 +723,19 @@ class ACMECertificateClient(object): authz = Authorization.create(self.client, identifier_type, identifier) self.authorizations[authz.combined_identifier] = authz else: - self.order = Order.create(self.client, self.identifiers) + replaces_cert_id = None + if ( + self.include_renewal_cert_id == 'always' or + (self.include_renewal_cert_id == 'when_ari_supported' and self.client.directory.has_renewal_info_endpoint()) + ): + cert_info = self._get_cert_info_or_none() + if cert_info is not None: + replaces_cert_id = compute_cert_id( + self.client.backend, + cert_info=cert_info, + none_if_required_information_is_missing=True, + ) + self.order = Order.create(self.client, self.identifiers, replaces_cert_id) self.order_uri = self.order.url self.order.load_authorizations(self.client) self.authorizations.update(self.order.authorizations) @@ -854,15 +897,14 @@ class ACMECertificateClient(object): def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(with_certificate=True) + argument_spec.argument_spec['csr']['aliases'] = ['src'] + argument_spec.update_argspec( modify_account=dict(type='bool', default=True), account_email=dict(type='str'), agreement=dict(type='str'), terms_agreed=dict(type='bool', default=False), challenge=dict(type='str', default='http-01', choices=['http-01', 'dns-01', 'tls-alpn-01', NO_CHALLENGE]), - csr=dict(type='path', aliases=['src']), - csr_content=dict(type='str'), data=dict(type='dict'), dest=dict(type='path', aliases=['cert']), fullchain_dest=dict(type='path', aliases=['fullchain']), @@ -878,20 +920,14 @@ def main(): subject_key_identifier=dict(type='str'), authority_key_identifier=dict(type='str'), )), - )) - module = AnsibleModule( - argument_spec=argument_spec, - required_one_of=( - ['account_key_src', 'account_key_content'], + include_renewal_cert_id=dict(type='str', choices=['never', 'when_ari_supported', 'always'], default='never'), + ) + argument_spec.update( + required_one_of=[ ['dest', 'fullchain_dest'], - ['csr', 'csr_content'], - ), - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ['csr', 'csr_content'], - ), - supports_check_mode=True, + ], ) + module = argument_spec.create_ansible_module(supports_check_mode=True) backend = create_backend(module, False) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py new file mode 100644 index 000000000..133f777d6 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_deactivate_authz.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: acme_certificate_deactivate_authz +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Deactivate all authz for an ACME v2 order +description: + - "Deactivate all authentication objects (authz) for an ACME v2 order, + which effectively deactivates (invalidates) the order itself." + - "Authentication objects are bound to an account key and remain valid + for a certain amount of time, and can be used to issue certificates + without having to re-authenticate the domain. This can be a security + concern." + - "Another reason to use this module is to deactivate an order whose + processing failed when using O(community.crypto.acme_certificate#module:include_renewal_cert_id)." +seealso: + - module: community.crypto.acme_certificate +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.account + - community.crypto.attributes + - community.crypto.attributes.actiongroup_acme +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + order_uri: + description: + - The ACME v2 order to deactivate. + - Can be obtained from RV(community.crypto.acme_certificate#module:order_uri). + type: str + required: true +''' + +EXAMPLES = r''' +- name: Deactivate all authzs for an order + community.crypto.acme_certificate_deactivate_authz: + account_key_content: "{{ account_private_key }}" + order_uri: "{{ certificate_result.order_uri }}" +''' + +RETURN = '''#''' + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.account import ( + ACMEAccount, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ( + ModuleFailException, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.orders import ( + Order, +) + + +def main(): + argument_spec = create_default_argspec() + argument_spec.update_argspec( + order_uri=dict(type='str', required=True), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + if module.params['acme_version'] == 1: + module.fail_json('The module does not support acme_version=1') + + backend = create_backend(module, False) + + try: + client = ACMEClient(module, backend) + account = ACMEAccount(client) + + dummy, account_data = account.setup_account(allow_creation=False) + if account_data is None: + raise ModuleFailException(msg='Account does not exist or is deactivated.') + + order = Order.from_url(client, module.params['order_uri']) + order.load_authorizations(client) + + changed = False + for authz in order.authorizations.values(): + if not authz.can_deactivate(): + continue + changed = True + if module.check_mode: + continue + try: + authz.deactivate(client) + except Exception: + # ignore errors + pass + if authz.status != 'deactivated': + module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url)) + + module.exit_json(changed=changed) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py new file mode 100644 index 000000000..1e2b16918 --- /dev/null +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_renewal_info.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: acme_certificate_renewal_info +author: "Felix Fontein (@felixfontein)" +version_added: 2.20.0 +short_description: Determine whether a certificate should be renewed or not +description: + - Uses various information to determine whether a certificate should be renewed or not. + - If available, the ARI extension (ACME Renewal Information, U(https://datatracker.ietf.org/doc/draft-ietf-acme-ari/)) + is used. This module implements version 3 of the ARI draft." +extends_documentation_fragment: + - community.crypto.acme.basic + - community.crypto.acme.no_account + - community.crypto.attributes + - community.crypto.attributes.info_module +options: + certificate_path: + description: + - A path to the X.509 certificate to determine renewal of. + - In case the certificate does not exist, the module will always return RV(should_renew=true). + - O(certificate_path) and O(certificate_content) are mutually exclusive. + type: path + certificate_content: + description: + - The content of the X.509 certificate to determine renewal of. + - O(certificate_path) and O(certificate_content) are mutually exclusive. + type: str + use_ari: + description: + - Whether to use ARI information, if available. + - Set this to V(false) if the ACME server implements ARI in a way that is incompatible with this module. + type: bool + default: true + ari_algorithm: + description: + - If ARI information is used, selects which algorithm is used to determine whether to renew now. + - V(standard) selects the L(algorithm provided in the the ARI specification, + https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#name-renewalinfo-objects). + - V(start) returns RV(should_renew=true) once the start of the renewal interval has been reached. + type: str + choices: + - standard + - start + default: standard + remaining_days: + description: + - The number of days the certificate must have left being valid. + - For example, if O(remaining_days=20), this check causes RV(should_renew=true) if the + certificate is valid for less than 20 days. + type: int + remaining_percentage: + description: + - The percentage of the certificate's validity period that should be left. + - For example, if O(remaining_percentage=0.1), and the certificate's validity period is 90 days, + this check causes RV(should_renew=true) if the certificate is valid for less than 9 days. + - Must be a value between 0 and 1. + type: float + now: + description: + - Use this timestamp instead of the current timestamp to determine whether a certificate should be renewed. + - Time can be specified either as relative time or as absolute timestamp. + - Time will always be interpreted as UTC. + - Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer + + C([w | d | h | m | s]) (for example V(+32w1d2h)). + type: str +seealso: + - module: community.crypto.acme_certificate + description: Allows to obtain a certificate using the ACME protocol + - module: community.crypto.acme_ari_info + description: Obtain renewal information for a certificate +''' + +EXAMPLES = ''' +- name: Retrieve renewal information for a certificate + community.crypto.acme_certificate_renewal_info: + certificate_path: /etc/httpd/ssl/sample.com.crt + register: cert_data + +- name: Should the certificate be renewed? + ansible.builtin.debug: + var: cert_data.should_renew +''' + +RETURN = ''' +should_renew: + description: + - Whether the certificate should be renewed. + - If no certificate is provided, or the certificate is expired, will always be V(true). + returned: success + type: bool + sample: true + +msg: + description: + - Information on the reason for renewal. + - Should be shown to the user, as in case of ARI triggered renewal it can contain important + information, for example on forced revocations for misissued certificates. + type: str + returned: success + sample: The certificate does not exist. + +supports_ari: + description: + - Whether ARI information was used to determine renewal. This can be used to determine whether to + specify O(community.crypto.acme_certificate#module:include_renewal_cert_id=when_ari_supported) + for the M(community.crypto.acme_certificate) module. + - If O(use_ari=false), this will always be V(false). + returned: success + type: bool + sample: true + +cert_id: + description: + - The certificate ID according to the L(ARI specification, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.1). + returned: success, the certificate exists, and has an Authority Key Identifier X.509 extension + type: str + sample: aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE +''' + +import os +import random + +from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( + create_backend, + create_default_argspec, + ACMEClient, +) + +from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException + +from ansible_collections.community.crypto.plugins.module_utils.acme.utils import compute_cert_id + + +def main(): + argument_spec = create_default_argspec(with_account=False) + argument_spec.update_argspec( + certificate_path=dict(type='path'), + certificate_content=dict(type='str'), + use_ari=dict(type='bool', default=True), + ari_algorithm=dict(type='str', choices=['standard', 'start'], default='standard'), + remaining_days=dict(type='int'), + remaining_percentage=dict(type='float'), + now=dict(type='str'), + ) + argument_spec.update( + mutually_exclusive=( + ['certificate_path', 'certificate_content'], + ), + ) + module = argument_spec.create_ansible_module(supports_check_mode=True) + backend = create_backend(module, True) + + result = dict( + changed=False, + msg='The certificate is still valid and no condition was reached', + supports_ari=False, + ) + + def complete(should_renew, **kwargs): + result['should_renew'] = should_renew + result.update(kwargs) + module.exit_json(**result) + + if not module.params['certificate_path'] and not module.params['certificate_content']: + complete(True, msg='No certificate was specified') + + if module.params['certificate_path'] is not None and not os.path.exists(module.params['certificate_path']): + complete(True, msg='The certificate file does not exist') + + try: + cert_info = backend.get_cert_information( + cert_filename=module.params['certificate_path'], + cert_content=module.params['certificate_content'], + ) + cert_id = compute_cert_id(backend, cert_info=cert_info, none_if_required_information_is_missing=True) + if cert_id is not None: + result['cert_id'] = cert_id + + if module.params['now']: + now = backend.parse_module_parameter(module.params['now'], 'now') + else: + now = backend.get_now() + + if now >= cert_info.not_valid_after: + complete(True, msg='The certificate has already expired') + + client = ACMEClient(module, backend) + if cert_id is not None and module.params['use_ari'] and client.directory.has_renewal_info_endpoint(): + renewal_info = client.get_renewal_info(cert_id=cert_id) + window_start = backend.parse_acme_timestamp(renewal_info['suggestedWindow']['start']) + window_end = backend.parse_acme_timestamp(renewal_info['suggestedWindow']['end']) + msg_append = '' + if 'explanationURL' in renewal_info: + msg_append = '. Information on renewal interval: {0}'.format(renewal_info['explanationURL']) + result['supports_ari'] = True + if now > window_end: + complete(True, msg='The suggested renewal interval provided by ARI is in the past{0}'.format(msg_append)) + if module.params['ari_algorithm'] == 'start': + if now > window_start: + complete(True, msg='The suggested renewal interval provided by ARI has begun{0}'.format(msg_append)) + else: + random_time = backend.interpolate_timestamp(window_start, window_end, random.random()) + if now > random_time: + complete( + True, + msg='The picked random renewal time {0} in sugested renewal internal provided by ARI is in the past{1}'.format( + random_time, + msg_append, + ), + ) + + if module.params['remaining_days'] is not None: + remaining_days = (cert_info.not_valid_after - now).days + if remaining_days < module.params['remaining_days']: + complete(True, msg='The certificate expires in {0} days'.format(remaining_days)) + + if module.params['remaining_percentage'] is not None: + timestamp = backend.interpolate_timestamp(cert_info.not_valid_before, cert_info.not_valid_after, 1 - module.params['remaining_percentage']) + if timestamp < now: + complete( + True, + msg="The remaining percentage {0}% of the certificate's lifespan was reached on {1}".format( + module.params['remaining_percentage'] * 100, + timestamp, + ), + ) + + complete(False) + except ModuleFailException as e: + e.do_fail(module) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py b/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py index 022862e60..2661a1525 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_certificate_revoke.py @@ -37,7 +37,8 @@ seealso: - module: community.crypto.acme_inspect description: Allows to debug problems. extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -127,11 +128,9 @@ EXAMPLES = ''' RETURN = '''#''' -from ansible.module_utils.basic import AnsibleModule - from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -152,24 +151,23 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.utils import def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(require_account_key=False) + argument_spec.update_argspec( private_key_src=dict(type='path'), private_key_content=dict(type='str', no_log=True), private_key_passphrase=dict(type='str', no_log=True), certificate=dict(type='path', required=True), revoke_reason=dict(type='int'), - )) - module = AnsibleModule( - argument_spec=argument_spec, + ) + argument_spec.update( required_one_of=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), mutually_exclusive=( ['account_key_src', 'account_key_content', 'private_key_src', 'private_key_content'], ), - supports_check_mode=False, ) + module = argument_spec.create_ansible_module() backend = create_backend(module, False) try: diff --git a/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py b/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py index 48b65f998..edd2c3331 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_challenge_cert_helper.py @@ -165,16 +165,16 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.io import ( read_file, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_now_datetime, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, set_not_valid_after, set_not_valid_before, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, +) + CRYPTOGRAPHY_IMP_ERR = None try: import cryptography diff --git a/ansible_collections/community/crypto/plugins/modules/acme_inspect.py b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py index a2c76507e..c7ee49765 100644 --- a/ansible_collections/community/crypto/plugins/modules/acme_inspect.py +++ b/ansible_collections/community/crypto/plugins/modules/acme_inspect.py @@ -42,7 +42,8 @@ seealso: description: The specification of the C(tls-alpn-01) challenge (RFC 8737). link: https://www.rfc-editor.org/rfc/rfc8737.html extends_documentation_fragment: - - community.crypto.acme + - community.crypto.acme.basic + - community.crypto.acme.account - community.crypto.attributes - community.crypto.attributes.actiongroup_acme attributes: @@ -247,12 +248,11 @@ output_json: - ... ''' -from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text from ansible_collections.community.crypto.plugins.module_utils.acme.acme import ( create_backend, - get_default_argspec, + create_default_argspec, ACMEClient, ) @@ -263,18 +263,14 @@ from ansible_collections.community.crypto.plugins.module_utils.acme.errors impor def main(): - argument_spec = get_default_argspec() - argument_spec.update(dict( + argument_spec = create_default_argspec(require_account_key=False) + argument_spec.update_argspec( url=dict(type='str'), method=dict(type='str', choices=['get', 'post', 'directory-only'], default='get'), content=dict(type='str'), fail_on_acme_error=dict(type='bool', default=True), - )) - module = AnsibleModule( - argument_spec=argument_spec, - mutually_exclusive=( - ['account_key_src', 'account_key_content'], - ), + ) + argument_spec.update( required_if=( ['method', 'get', ['url']], ['method', 'post', ['url', 'content']], @@ -282,6 +278,7 @@ def main(): ['method', 'post', ['account_key_src', 'account_key_content'], True], ), ) + module = argument_spec.create_ansible_module() backend = create_backend(module, False) result = dict() diff --git a/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py index 2c1238d48..0276556ab 100644 --- a/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/ecs_certificate.py @@ -938,8 +938,8 @@ def main(): module.fail_json(msg='The cert_expiry field is invalid when request_type="reissue".') elif module.params['cert_lifetime']: module.fail_json(msg='The cert_lifetime field is invalid when request_type="reissue".') - # Only a reissued request can omit the CSR - else: + # Reissued or renew request can omit the CSR + elif module.params['request_type'] != 'renew': module_params_csr = module.params['csr'] if module_params_csr is None: module.fail_json(msg='The csr field is required when request_type={0}'.format(module.params['request_type'])) diff --git a/ansible_collections/community/crypto/plugins/modules/get_certificate.py b/ansible_collections/community/crypto/plugins/modules/get_certificate.py index 6ae9439d3..d4b38afbd 100644 --- a/ansible_collections/community/crypto/plugins/modules/get_certificate.py +++ b/ansible_collections/community/crypto/plugins/modules/get_certificate.py @@ -220,10 +220,6 @@ from ansible.module_utils.common.text.converters import to_bytes from ansible_collections.community.crypto.plugins.module_utils.version import LooseVersion -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_now_datetime, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, cryptography_oid_to_name, @@ -232,6 +228,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp get_not_valid_before, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_now_datetime, +) + MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' CREATE_DEFAULT_CONTEXT_IMP_ERR = None diff --git a/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py index 8379937f7..9e8c20e29 100644 --- a/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py +++ b/ansible_collections/community/crypto/plugins/modules/x509_certificate_info.py @@ -406,10 +406,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo OpenSSLObjectError, ) -from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( - get_relative_time_option, -) - from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( CRYPTOGRAPHY_TIMEZONE, ) @@ -418,6 +414,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac select_backend, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + def main(): module = AnsibleModule( diff --git a/ansible_collections/community/crypto/plugins/modules/x509_crl.py b/ansible_collections/community/crypto/plugins/modules/x509_crl.py index 527975b88..f8eb8d85e 100644 --- a/ansible_collections/community/crypto/plugins/modules/x509_crl.py +++ b/ansible_collections/community/crypto/plugins/modules/x509_crl.py @@ -470,7 +470,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im load_certificate, parse_name_field, parse_ordered_name_field, - get_relative_time_option, select_message_digest, ) @@ -506,6 +505,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac get_crl_info, ) +from ansible_collections.community.crypto.plugins.module_utils.time import ( + get_relative_time_option, +) + MINIMAL_CRYPTOGRAPHY_VERSION = '1.2' CRYPTOGRAPHY_IMP_ERR = None |